Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 337 additions & 0 deletions .github/scripts/report-schema-problems.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
// Turns the build's schema-problems.json into something a person will notice.
//
// A file the site cannot serve is skipped rather than fatal (see build.mjs), so
// a broken schema otherwise leaves no trace in a green deploy. This closes that
// gap: run annotations always, plus - where the caller sets REPORT_ISSUE=1 and
// the workflow grants issues:write - a single tracking issue.
//
// The issue is a living record, not a log. Its body always shows the current
// state, so someone opening it sees what is wrong now rather than reconstructing
// it from a pile of comments; every change also appends a comment saying what
// moved, so the history is still there for anyone who wants it. The body
// carries the problem set it was written from, which is what lets the next run
// tell "nothing changed" from "different problems" and diff the two.
//
// Node rather than shell: no jq to depend on, and the workflows have already
// set up Node by the time this runs.
//
// report() takes its gh as an argument and reaches for nothing global, so it
// can be driven end to end against a fake. Anything that shells out to the
// real gh lives below the entry-point guard, where a test cannot reach it.
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import process from 'node:process';
import { pathToFileURL } from 'node:url';

const LABEL = 'schema-problem';
const TITLE = 'Schema files under schemas/ are not being served';
const REPO_URL = 'https://github.com/flashtrace/flashtrace';
const STATE_OPEN = '<!-- schema-problems-state:';

// Reduces a problem set to what the comparison is about: which paths, and why
// each one. Stringifying the raw array would fold in array order and key
// insertion order too, so the same problems arriving in a different order - or
// upstream growing an incidental field - would read as a changed set and
// re-comment on every deploy.
//
// Sorted by code point rather than localeCompare, which can call two distinct
// paths equal and leave their order dependent on the order they arrived in.
const normalize = (problems) => [...problems]
.map(({ path, reason }) => [path, reason])
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));

// Identifies the problem set, not the run: the same problems seen by ten
// consecutive deploys should produce one comment, not ten.
export function fingerprint(problems) {
return createHash('sha256').update(JSON.stringify(normalize(problems))).digest('hex').slice(0, 12);
}

// The body doubles as the store: no external state to keep in step with the
// issue, and a body that was hand-edited into nonsense degrades to "unknown
// previous state" rather than to a wrong diff.
export function embedState(problems) {
// Escaping > means the payload can never contain --> and close the comment
// early. A reason quoting a parser error is not a controlled string.
const json = JSON.stringify({ fingerprint: fingerprint(problems), problems }).replace(/>/g, '\\u003e');
return `${STATE_OPEN}${json} -->`;
}

export function readState(body) {
const at = (body ?? '').indexOf(STATE_OPEN);
if (at === -1) return null;
const start = at + STATE_OPEN.length;
const end = body.indexOf('-->', start);
if (end === -1) return null;
try {
return JSON.parse(body.slice(start, end).trim());
} catch {
return null;
}
}

// Keyed by path, so a file whose reason changed reads as one changed entry
// rather than as a simultaneous disappearance and arrival.
export function diffProblems(prev, next) {
const was = new Map(prev.map((p) => [p.path, p.reason]));
const now = new Map(next.map((p) => [p.path, p.reason]));
return {
added: next.filter((p) => !was.has(p.path)),
removed: prev.filter((p) => !now.has(p.path)),
changed: next.filter((p) => was.has(p.path) && was.get(p.path) !== p.reason),
};
}

// A reason is often a parser's own words, so it can hold a pipe or a newline
// and quietly wreck the row it sits in.
const cell = (s) => String(s).replace(/\s*[\r\n]+\s*/g, ' ').replace(/\|/g, '\\|');

// Workflow commands are line-oriented and ::-delimited, so the same uncontrolled
// reason that can wreck a table row can inject commands of its own into the
// run's command stream - ::error::, ::add-mask::, anything. GitHub's escaping
// for this is percent-encoding; property values additionally need : and ,,
// which are what separate the properties from each other and from the message.
const cmdData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
const cmdProp = (s) => cmdData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');

const table = (problems) => [
'| File | Why it was skipped |',
'|---|---|',
...problems.map((p) => `| \`schemas/${cell(p.path)}\` | ${cell(p.reason)} |`),
];

const runNote = (runUrl, verb) => (runUrl ? [`<sub>${verb} by [this run](${runUrl}).</sub>`] : []);

// The version alone can lie about the cause: a schema most often comes back
// because a PR here taught src/schemas.mjs a layout that changed on purpose,
// which moves the site commit and leaves the release untouched. Naming both
// keeps "resolved at v1.2.3" from crediting a release that never changed.
const siteNote = (siteRef) => {
if (!siteRef?.sha) return '';
const short = siteRef.sha.slice(0, 7);
return siteRef.url ? ` (site [\`${short}\`](${siteRef.url}))` : ` (site \`${short}\`)`;
};

export const builtAt = ({ version, siteRef }) => `\`${version}\`${siteNote(siteRef)}`;

export function issueBody({ version, siteRef, served, problems, runUrl }) {
return [
'Some files under `schemas/` are not being served by the site. The site itself deployed normally - this is only about the files below.',
'',
`**To fix:** correct it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose.`,
'',
`### Not being served, as of ${builtAt({ version, siteRef })}`,
'',
...table(problems),
'',
`${served} schema(s) went out normally.`,
'',
'<sub>Maintained by the deploy workflow. This table always reflects the most recent build, each change is recorded as a comment below, and the issue closes itself once a build finds nothing wrong. Edits to this body are overwritten.</sub>',
...runNote(runUrl, 'Updated'),
'',
embedState(problems),
].join('\n');
}

export function resolvedBody({ version, siteRef, runUrl }) {
return [
`**Resolved.** Every file under \`schemas/\` is being served again as of ${builtAt({ version, siteRef })}.`,
'',
'What was wrong, and when it changed, is in the comments below.',
'',
...runNote(runUrl, 'Closed'),
].join('\n');
}

// When the previous set is unreadable there is no diff to state, and stating
// one anyway would announce every long-standing problem as newly broken. Say
// what is actually known: the current set, and why it is not a comparison.
export function unknownPreviousComment({ version, siteRef, problems, runUrl }) {
return [
`Rebuilt at ${builtAt({ version, siteRef })}.`,
'',
'The previous state could not be read from this issue\'s body, so what changed since the last build cannot be shown. The full current set is below - some of it may have been here all along.',
'',
...table(problems),
'',
'The issue body above now shows the full current state, and the next build will be able to diff against it again.',
'',
...runNote(runUrl, 'Updated'),
].join('\n');
}

export function changeComment({ version, siteRef, diff, runUrl }) {
const lines = [`Rebuilt at ${builtAt({ version, siteRef })}, and the problems changed.`, ''];
if (diff.added.length > 0) {
lines.push(`**No longer served (${diff.added.length})**`, '', ...table(diff.added), '');
}
if (diff.changed.length > 0) {
lines.push(`**Still not served, for a different reason (${diff.changed.length})**`, '', ...table(diff.changed), '');
}
if (diff.removed.length > 0) {
lines.push(
`**Served again (${diff.removed.length})**`,
'',
...diff.removed.map((p) => `- \`schemas/${p.path}\``),
'',
);
}
lines.push('The issue body above now shows the full current state.', '', ...runNote(runUrl, 'Updated'));
return lines.join('\n');
}

// resolved is null when the previous set was unreadable: say so, rather than
// closing with a silent gap where the list of what came back should be.
export function closeComment({ version, siteRef, resolved, runUrl }) {
const lines = [`Rebuilt at ${builtAt({ version, siteRef })} with nothing skipped - closing.`, ''];
if (resolved === null) {
lines.push('The previous state could not be read from this issue\'s body, so which files came back cannot be listed. The comments above are the record.', '');
} else if (resolved.length > 0) {
lines.push(
`**Served again (${resolved.length})**`,
'',
...resolved.map((p) => `- \`schemas/${p.path}\``),
'',
);
}
lines.push(...runNote(runUrl, 'Closed'));
return lines.join('\n');
}

// Returns a short tag for what it did, so a caller can assert on the decision
// rather than on log text.
export function report({ data, gh, log = console.log, reportIssue = false, runUrl, siteRef }) {
const { version = 'unknown', schemas: served = 0, problems = [] } = data;
// Deliberately not part of the fingerprint: siteRef moves on every merge to
// main, so folding it in would make every unrelated deploy re-comment on an
// unchanged issue - the exact noise the fingerprint exists to prevent.
const built = { version, siteRef };

// Annotations cost no permissions and land on the run itself, so they happen
// whether or not this workflow may touch issues.
for (const p of problems) {
const message = cmdData(cell(`schemas/${p.path} ${p.reason}`));
log(`::warning title=${cmdProp('Schema not served')}::${message}`);
}

if (!reportIssue) {
log(`${problems.length} problem(s) in ${version}; issue reporting is off for this workflow`);
return 'annotated';
}

const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number'));
const existing = open[0]?.number;
// The body is kept as written, not just parsed: it is what a failed close
// has to be rolled back to. previous is null when there is no issue or when
// the body carries no state - "cannot diff", kept distinct from "diffed to
// nothing" all the way down, so an unreadable body never announces every
// long-standing problem as newly broken.
const existingBody = existing
? JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body
: null;
const previous = existing ? readState(existingBody)?.problems ?? null : null;

if (problems.length === 0) {
if (!existing) {
log('clean build - nothing to report');
return 'clean';
}
// Body first: a reader arriving from the close notification should not
// find a table of problems that no longer exist. That ordering leaves a
// window, though - if the close fails, an open issue is left claiming to
// be resolved, with the table it should still be showing gone. That state
// is worse than either call simply not having happened, so put the old
// body back and let the next clean build try the whole thing again.
gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl }));
try {
gh('issue', 'close', String(existing), '--comment',
closeComment({ ...built, resolved: previous, runUrl }));
} catch (error) {
try {
gh('issue', 'edit', String(existing), '--body', existingBody);
log(`could not close #${existing} - restored its body, so it still reports the last known problems`);
} catch {
// Nothing left to try, so say precisely what state the issue is in -
// it is one nobody would otherwise expect.
log(`could not close #${existing}, and could not restore its body: it is open and reads as resolved. The comments hold what was wrong; the next failing build rewrites the body.`);
}
throw error;
}
log(`clean build - closed #${existing}`);
return 'closed';
}

if (!existing) {
// --force so a missing label is created and an existing one left usable,
// rather than the first report ever filed failing on a label nobody made.
gh('label', 'create', LABEL, '--color', 'd93f0b', '--force',
'--description', 'A schema under schemas/ is not being served');
gh('issue', 'create', '--title', TITLE, '--label', LABEL,
'--body', issueBody({ ...built, served, problems, runUrl }));
log(`opened a tracking issue for ${problems.length} problem(s)`);
return 'opened';
}

if (previous && fingerprint(previous) === fingerprint(problems)) {
log(`#${existing} already reports exactly these ${problems.length} problem(s) - staying quiet`);
return 'unchanged';
}

gh('issue', 'edit', String(existing), '--body', issueBody({ ...built, served, problems, runUrl }));
if (previous === null) {
gh('issue', 'comment', String(existing), '--body', unknownPreviousComment({ ...built, problems, runUrl }));
log(`#${existing} carries no readable state - updated it with the current ${problems.length} problem(s), without a diff`);
return 'restated';
}
gh('issue', 'comment', String(existing), '--body',
changeComment({ ...built, diff: diffProblems(previous, problems), runUrl }));
log(`problem set changed - updated #${existing}`);
return 'updated';
}

// Only when run as a program: importing this module must never be able to
// reach the real gh.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const REPORT = 'schema-problems.json';
if (!existsSync(REPORT)) {
// The build failed before schema discovery. Whatever went wrong, it is not
// this script's story to tell, and staying silent avoids closing a real
// issue on the strength of a build that never looked.
console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`);
process.exit(0);
}
const {
GITHUB_SERVER_URL = 'https://github.com',
GITHUB_REPOSITORY,
GITHUB_RUN_ID,
GITHUB_SHA,
} = process.env;
// On a repository_dispatch from an upstream release this is the head of main,
// which is the right answer: it names the site code that did the deploying.
// Outside Actions, fall back to the checkout someone ran the build from.
const localSha = () => {
try {
return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return ''; // not a checkout, or no git - the version alone will have to do
}
};
const sha = GITHUB_SHA || localSha();
report({
data: JSON.parse(readFileSync(REPORT, 'utf8')),
// Named explicitly rather than inferred from the working directory, so a
// run from outside a checkout - or from one whose origin points elsewhere -
// still reports against the repository being deployed. Appended, because
// the subcommand has to come first. Local runs without the variable keep
// gh's own inference.
gh: (...args) => execFileSync('gh', GITHUB_REPOSITORY ? [...args, '--repo', GITHUB_REPOSITORY] : args,
{ encoding: 'utf8' }).trim(),
reportIssue: process.env.REPORT_ISSUE === '1',
runUrl: GITHUB_REPOSITORY && GITHUB_RUN_ID
? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`
: undefined,
siteRef: sha
? { sha, url: GITHUB_REPOSITORY ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${sha}` : undefined }
: undefined,
});
}
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ jobs:
env:
FLASHTRACE_REF: ${{ steps.ref.outputs.tag }}

# annotations only - REPORT_ISSUE is deliberately unset, because a PR
# should not file an issue about upstream content it did not touch.
# Filing belongs to the deploy; here this only makes a skipped schema
# visible while reviewing.
- name: Report skipped schemas
if: always()
continue-on-error: true
run: node .github/scripts/report-schema-problems.mjs

# also exercised here so artifact packaging failures surface in CI,
# not first during a deploy
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ permissions:
contents: read
pages: write
id-token: write
# for the schema problem report in the build job below
issues: write

concurrency:
group: pages
Expand Down Expand Up @@ -65,6 +67,18 @@ jobs:
env:
FLASHTRACE_REF: ${{ steps.ref.outputs.tag }}

# The build skips a schema it cannot serve instead of failing, so without
# this a broken schema would ship as a green deploy and nobody would know.
# continue-on-error: reporting the problem must never become a worse
# problem than the one being reported.
- name: Report skipped schemas
if: always()
continue-on-error: true
run: node .github/scripts/report-schema-problems.mjs
env:
REPORT_ISSUE: '1'
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/
flashtrace/
schema-problems.json
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ Ask refining questions before you start writing.
| Folder | Purpose |
|---|---|
| src/ | Source code, used from 'build.mjs' to create 'dist/' |
| public/ | Assets that should be included exactly as they are in the final 'dist/' |
| public/ | Assets that should be included exactly as they are in the final 'dist/'; copied last, so a file here silently overwrites a generated file at the same path |
| dist/ | Uncommitted, generated build output |
| flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input |
| .github/ | Continuous integration/deployment workflows |
| .github/ | Continuous integration/deployment workflows, plus the scripts they run |

'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them.
It looks for the docs at `$FLASHTRACE_DOCS`, then './flashtrace/docs', then '../flashtrace/docs' - if none exist, clone the tool repo first: `git clone https://github.com/flashtrace/flashtrace`.
Expand Down
Loading