Skip to content

Commit e2dd4d7

Browse files
authored
Merge pull request #9 from flashtrace/feat/serve-schemas
feat: serve the flashtrace JSON Schemas verbatim
2 parents b3a319d + 78b76d0 commit e2dd4d7

7 files changed

Lines changed: 562 additions & 2 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
// Turns the build's schema-problems.json into something a person will notice.
2+
//
3+
// A file the site cannot serve is skipped rather than fatal (see build.mjs), so
4+
// a broken schema otherwise leaves no trace in a green deploy. This closes that
5+
// gap: run annotations always, plus - where the caller sets REPORT_ISSUE=1 and
6+
// the workflow grants issues:write - a single tracking issue.
7+
//
8+
// The issue is a living record, not a log. Its body always shows the current
9+
// state, so someone opening it sees what is wrong now rather than reconstructing
10+
// it from a pile of comments; every change also appends a comment saying what
11+
// moved, so the history is still there for anyone who wants it. The body
12+
// carries the problem set it was written from, which is what lets the next run
13+
// tell "nothing changed" from "different problems" and diff the two.
14+
//
15+
// Node rather than shell: no jq to depend on, and the workflows have already
16+
// set up Node by the time this runs.
17+
//
18+
// report() takes its gh as an argument and reaches for nothing global, so it
19+
// can be driven end to end against a fake. Anything that shells out to the
20+
// real gh lives below the entry-point guard, where a test cannot reach it.
21+
import { execFileSync } from 'node:child_process';
22+
import { createHash } from 'node:crypto';
23+
import { existsSync, readFileSync } from 'node:fs';
24+
import process from 'node:process';
25+
import { pathToFileURL } from 'node:url';
26+
27+
const LABEL = 'schema-problem';
28+
const TITLE = 'Schema files under schemas/ are not being served';
29+
const REPO_URL = 'https://github.com/flashtrace/flashtrace';
30+
const STATE_OPEN = '<!-- schema-problems-state:';
31+
32+
// Reduces a problem set to what the comparison is about: which paths, and why
33+
// each one. Stringifying the raw array would fold in array order and key
34+
// insertion order too, so the same problems arriving in a different order - or
35+
// upstream growing an incidental field - would read as a changed set and
36+
// re-comment on every deploy.
37+
//
38+
// Sorted by code point rather than localeCompare, which can call two distinct
39+
// paths equal and leave their order dependent on the order they arrived in.
40+
const normalize = (problems) => [...problems]
41+
.map(({ path, reason }) => [path, reason])
42+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
43+
44+
// Identifies the problem set, not the run: the same problems seen by ten
45+
// consecutive deploys should produce one comment, not ten.
46+
export function fingerprint(problems) {
47+
return createHash('sha256').update(JSON.stringify(normalize(problems))).digest('hex').slice(0, 12);
48+
}
49+
50+
// The body doubles as the store: no external state to keep in step with the
51+
// issue, and a body that was hand-edited into nonsense degrades to "unknown
52+
// previous state" rather than to a wrong diff.
53+
export function embedState(problems) {
54+
// Escaping > means the payload can never contain --> and close the comment
55+
// early. A reason quoting a parser error is not a controlled string.
56+
const json = JSON.stringify({ fingerprint: fingerprint(problems), problems }).replace(/>/g, '\\u003e');
57+
return `${STATE_OPEN}${json} -->`;
58+
}
59+
60+
export function readState(body) {
61+
const at = (body ?? '').indexOf(STATE_OPEN);
62+
if (at === -1) return null;
63+
const start = at + STATE_OPEN.length;
64+
const end = body.indexOf('-->', start);
65+
if (end === -1) return null;
66+
try {
67+
return JSON.parse(body.slice(start, end).trim());
68+
} catch {
69+
return null;
70+
}
71+
}
72+
73+
// Keyed by path, so a file whose reason changed reads as one changed entry
74+
// rather than as a simultaneous disappearance and arrival.
75+
export function diffProblems(prev, next) {
76+
const was = new Map(prev.map((p) => [p.path, p.reason]));
77+
const now = new Map(next.map((p) => [p.path, p.reason]));
78+
return {
79+
added: next.filter((p) => !was.has(p.path)),
80+
removed: prev.filter((p) => !now.has(p.path)),
81+
changed: next.filter((p) => was.has(p.path) && was.get(p.path) !== p.reason),
82+
};
83+
}
84+
85+
// A reason is often a parser's own words, so it can hold a pipe or a newline
86+
// and quietly wreck the row it sits in.
87+
const cell = (s) => String(s).replace(/\s*[\r\n]+\s*/g, ' ').replace(/\|/g, '\\|');
88+
89+
// Workflow commands are line-oriented and ::-delimited, so the same uncontrolled
90+
// reason that can wreck a table row can inject commands of its own into the
91+
// run's command stream - ::error::, ::add-mask::, anything. GitHub's escaping
92+
// for this is percent-encoding; property values additionally need : and ,,
93+
// which are what separate the properties from each other and from the message.
94+
const cmdData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
95+
const cmdProp = (s) => cmdData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
96+
97+
const table = (problems) => [
98+
'| File | Why it was skipped |',
99+
'|---|---|',
100+
...problems.map((p) => `| \`schemas/${cell(p.path)}\` | ${cell(p.reason)} |`),
101+
];
102+
103+
const runNote = (runUrl, verb) => (runUrl ? [`<sub>${verb} by [this run](${runUrl}).</sub>`] : []);
104+
105+
// The version alone can lie about the cause: a schema most often comes back
106+
// because a PR here taught src/schemas.mjs a layout that changed on purpose,
107+
// which moves the site commit and leaves the release untouched. Naming both
108+
// keeps "resolved at v1.2.3" from crediting a release that never changed.
109+
const siteNote = (siteRef) => {
110+
if (!siteRef?.sha) return '';
111+
const short = siteRef.sha.slice(0, 7);
112+
return siteRef.url ? ` (site [\`${short}\`](${siteRef.url}))` : ` (site \`${short}\`)`;
113+
};
114+
115+
export const builtAt = ({ version, siteRef }) => `\`${version}\`${siteNote(siteRef)}`;
116+
117+
export function issueBody({ version, siteRef, served, problems, runUrl }) {
118+
return [
119+
'Some files under `schemas/` are not being served by the site. The site itself deployed normally - this is only about the files below.',
120+
'',
121+
`**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.`,
122+
'',
123+
`### Not being served, as of ${builtAt({ version, siteRef })}`,
124+
'',
125+
...table(problems),
126+
'',
127+
`${served} schema(s) went out normally.`,
128+
'',
129+
'<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>',
130+
...runNote(runUrl, 'Updated'),
131+
'',
132+
embedState(problems),
133+
].join('\n');
134+
}
135+
136+
export function resolvedBody({ version, siteRef, runUrl }) {
137+
return [
138+
`**Resolved.** Every file under \`schemas/\` is being served again as of ${builtAt({ version, siteRef })}.`,
139+
'',
140+
'What was wrong, and when it changed, is in the comments below.',
141+
'',
142+
...runNote(runUrl, 'Closed'),
143+
].join('\n');
144+
}
145+
146+
// When the previous set is unreadable there is no diff to state, and stating
147+
// one anyway would announce every long-standing problem as newly broken. Say
148+
// what is actually known: the current set, and why it is not a comparison.
149+
export function unknownPreviousComment({ version, siteRef, problems, runUrl }) {
150+
return [
151+
`Rebuilt at ${builtAt({ version, siteRef })}.`,
152+
'',
153+
'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.',
154+
'',
155+
...table(problems),
156+
'',
157+
'The issue body above now shows the full current state, and the next build will be able to diff against it again.',
158+
'',
159+
...runNote(runUrl, 'Updated'),
160+
].join('\n');
161+
}
162+
163+
export function changeComment({ version, siteRef, diff, runUrl }) {
164+
const lines = [`Rebuilt at ${builtAt({ version, siteRef })}, and the problems changed.`, ''];
165+
if (diff.added.length > 0) {
166+
lines.push(`**No longer served (${diff.added.length})**`, '', ...table(diff.added), '');
167+
}
168+
if (diff.changed.length > 0) {
169+
lines.push(`**Still not served, for a different reason (${diff.changed.length})**`, '', ...table(diff.changed), '');
170+
}
171+
if (diff.removed.length > 0) {
172+
lines.push(
173+
`**Served again (${diff.removed.length})**`,
174+
'',
175+
...diff.removed.map((p) => `- \`schemas/${p.path}\``),
176+
'',
177+
);
178+
}
179+
lines.push('The issue body above now shows the full current state.', '', ...runNote(runUrl, 'Updated'));
180+
return lines.join('\n');
181+
}
182+
183+
// resolved is null when the previous set was unreadable: say so, rather than
184+
// closing with a silent gap where the list of what came back should be.
185+
export function closeComment({ version, siteRef, resolved, runUrl }) {
186+
const lines = [`Rebuilt at ${builtAt({ version, siteRef })} with nothing skipped - closing.`, ''];
187+
if (resolved === null) {
188+
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.', '');
189+
} else if (resolved.length > 0) {
190+
lines.push(
191+
`**Served again (${resolved.length})**`,
192+
'',
193+
...resolved.map((p) => `- \`schemas/${p.path}\``),
194+
'',
195+
);
196+
}
197+
lines.push(...runNote(runUrl, 'Closed'));
198+
return lines.join('\n');
199+
}
200+
201+
// Returns a short tag for what it did, so a caller can assert on the decision
202+
// rather than on log text.
203+
export function report({ data, gh, log = console.log, reportIssue = false, runUrl, siteRef }) {
204+
const { version = 'unknown', schemas: served = 0, problems = [] } = data;
205+
// Deliberately not part of the fingerprint: siteRef moves on every merge to
206+
// main, so folding it in would make every unrelated deploy re-comment on an
207+
// unchanged issue - the exact noise the fingerprint exists to prevent.
208+
const built = { version, siteRef };
209+
210+
// Annotations cost no permissions and land on the run itself, so they happen
211+
// whether or not this workflow may touch issues.
212+
for (const p of problems) {
213+
const message = cmdData(cell(`schemas/${p.path} ${p.reason}`));
214+
log(`::warning title=${cmdProp('Schema not served')}::${message}`);
215+
}
216+
217+
if (!reportIssue) {
218+
log(`${problems.length} problem(s) in ${version}; issue reporting is off for this workflow`);
219+
return 'annotated';
220+
}
221+
222+
const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number'));
223+
const existing = open[0]?.number;
224+
// The body is kept as written, not just parsed: it is what a failed close
225+
// has to be rolled back to. previous is null when there is no issue or when
226+
// the body carries no state - "cannot diff", kept distinct from "diffed to
227+
// nothing" all the way down, so an unreadable body never announces every
228+
// long-standing problem as newly broken.
229+
const existingBody = existing
230+
? JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body
231+
: null;
232+
const previous = existing ? readState(existingBody)?.problems ?? null : null;
233+
234+
if (problems.length === 0) {
235+
if (!existing) {
236+
log('clean build - nothing to report');
237+
return 'clean';
238+
}
239+
// Body first: a reader arriving from the close notification should not
240+
// find a table of problems that no longer exist. That ordering leaves a
241+
// window, though - if the close fails, an open issue is left claiming to
242+
// be resolved, with the table it should still be showing gone. That state
243+
// is worse than either call simply not having happened, so put the old
244+
// body back and let the next clean build try the whole thing again.
245+
gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl }));
246+
try {
247+
gh('issue', 'close', String(existing), '--comment',
248+
closeComment({ ...built, resolved: previous, runUrl }));
249+
} catch (error) {
250+
try {
251+
gh('issue', 'edit', String(existing), '--body', existingBody);
252+
log(`could not close #${existing} - restored its body, so it still reports the last known problems`);
253+
} catch {
254+
// Nothing left to try, so say precisely what state the issue is in -
255+
// it is one nobody would otherwise expect.
256+
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.`);
257+
}
258+
throw error;
259+
}
260+
log(`clean build - closed #${existing}`);
261+
return 'closed';
262+
}
263+
264+
if (!existing) {
265+
// --force so a missing label is created and an existing one left usable,
266+
// rather than the first report ever filed failing on a label nobody made.
267+
gh('label', 'create', LABEL, '--color', 'd93f0b', '--force',
268+
'--description', 'A schema under schemas/ is not being served');
269+
gh('issue', 'create', '--title', TITLE, '--label', LABEL,
270+
'--body', issueBody({ ...built, served, problems, runUrl }));
271+
log(`opened a tracking issue for ${problems.length} problem(s)`);
272+
return 'opened';
273+
}
274+
275+
if (previous && fingerprint(previous) === fingerprint(problems)) {
276+
log(`#${existing} already reports exactly these ${problems.length} problem(s) - staying quiet`);
277+
return 'unchanged';
278+
}
279+
280+
gh('issue', 'edit', String(existing), '--body', issueBody({ ...built, served, problems, runUrl }));
281+
if (previous === null) {
282+
gh('issue', 'comment', String(existing), '--body', unknownPreviousComment({ ...built, problems, runUrl }));
283+
log(`#${existing} carries no readable state - updated it with the current ${problems.length} problem(s), without a diff`);
284+
return 'restated';
285+
}
286+
gh('issue', 'comment', String(existing), '--body',
287+
changeComment({ ...built, diff: diffProblems(previous, problems), runUrl }));
288+
log(`problem set changed - updated #${existing}`);
289+
return 'updated';
290+
}
291+
292+
// Only when run as a program: importing this module must never be able to
293+
// reach the real gh.
294+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
295+
const REPORT = 'schema-problems.json';
296+
if (!existsSync(REPORT)) {
297+
// The build failed before schema discovery. Whatever went wrong, it is not
298+
// this script's story to tell, and staying silent avoids closing a real
299+
// issue on the strength of a build that never looked.
300+
console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`);
301+
process.exit(0);
302+
}
303+
const {
304+
GITHUB_SERVER_URL = 'https://github.com',
305+
GITHUB_REPOSITORY,
306+
GITHUB_RUN_ID,
307+
GITHUB_SHA,
308+
} = process.env;
309+
// On a repository_dispatch from an upstream release this is the head of main,
310+
// which is the right answer: it names the site code that did the deploying.
311+
// Outside Actions, fall back to the checkout someone ran the build from.
312+
const localSha = () => {
313+
try {
314+
return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
315+
} catch {
316+
return ''; // not a checkout, or no git - the version alone will have to do
317+
}
318+
};
319+
const sha = GITHUB_SHA || localSha();
320+
report({
321+
data: JSON.parse(readFileSync(REPORT, 'utf8')),
322+
// Named explicitly rather than inferred from the working directory, so a
323+
// run from outside a checkout - or from one whose origin points elsewhere -
324+
// still reports against the repository being deployed. Appended, because
325+
// the subcommand has to come first. Local runs without the variable keep
326+
// gh's own inference.
327+
gh: (...args) => execFileSync('gh', GITHUB_REPOSITORY ? [...args, '--repo', GITHUB_REPOSITORY] : args,
328+
{ encoding: 'utf8' }).trim(),
329+
reportIssue: process.env.REPORT_ISSUE === '1',
330+
runUrl: GITHUB_REPOSITORY && GITHUB_RUN_ID
331+
? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`
332+
: undefined,
333+
siteRef: sha
334+
? { sha, url: GITHUB_REPOSITORY ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${sha}` : undefined }
335+
: undefined,
336+
});
337+
}

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ jobs:
5353
env:
5454
FLASHTRACE_REF: ${{ steps.ref.outputs.tag }}
5555

56+
# annotations only - REPORT_ISSUE is deliberately unset, because a PR
57+
# should not file an issue about upstream content it did not touch.
58+
# Filing belongs to the deploy; here this only makes a skipped schema
59+
# visible while reviewing.
60+
- name: Report skipped schemas
61+
if: always()
62+
continue-on-error: true
63+
run: node .github/scripts/report-schema-problems.mjs
64+
5665
# also exercised here so artifact packaging failures surface in CI,
5766
# not first during a deploy
5867
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0

.github/workflows/deploy.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ permissions:
1313
contents: read
1414
pages: write
1515
id-token: write
16+
# for the schema problem report in the build job below
17+
issues: write
1618

1719
concurrency:
1820
group: pages
@@ -65,6 +67,18 @@ jobs:
6567
env:
6668
FLASHTRACE_REF: ${{ steps.ref.outputs.tag }}
6769

70+
# The build skips a schema it cannot serve instead of failing, so without
71+
# this a broken schema would ship as a green deploy and nobody would know.
72+
# continue-on-error: reporting the problem must never become a worse
73+
# problem than the one being reported.
74+
- name: Report skipped schemas
75+
if: always()
76+
continue-on-error: true
77+
run: node .github/scripts/report-schema-problems.mjs
78+
env:
79+
REPORT_ISSUE: '1'
80+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81+
6882
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
6983
with:
7084
path: dist

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules/
22
dist/
33
flashtrace/
4+
schema-problems.json

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ Ask refining questions before you start writing.
1313
| Folder | Purpose |
1414
|---|---|
1515
| src/ | Source code, used from 'build.mjs' to create 'dist/' |
16-
| public/ | Assets that should be included exactly as they are in the final 'dist/' |
16+
| 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 |
1717
| dist/ | Uncommitted, generated build output |
1818
| flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input |
19-
| .github/ | Continuous integration/deployment workflows |
19+
| .github/ | Continuous integration/deployment workflows, plus the scripts they run |
2020

2121
'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them.
2222
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`.

0 commit comments

Comments
 (0)