From 123f565a1ec49a7487b40e6268bdce5a6438f8ce Mon Sep 17 00:00:00 2001 From: Robbe Verhelst Date: Fri, 9 Oct 2026 08:12:31 +0200 Subject: [PATCH] Follow sluiceway v0.50.0: a tick written back, runs that failed, and ticks with dependencies from the command line The pin moves to v0.50.0. Two records share the number 0119, so record links now resolve per citing file from SHARED_NUMBERS, and the build stops on a citation it cannot place. The Command line examples are captured again from the action's fake app, Using the app follows the app's record 0330, and What it looks like names the lines about runs that failed and a failed drift check. Co-Authored-By: Claude Opus 5.5 --- .gitmodules | 2 +- src/content/docs/hosted-app/using-the-app.mdx | 9 +- src/content/docs/what-it-looks-like.mdx | 10 +- src/lib/command-line.ts | 13 ++- src/lib/pages.ts | 103 ++++++++++++++++-- src/loaders/action-docs.ts | 15 ++- test/links.test.ts | 10 +- test/shared-numbers.test.ts | 87 +++++++++++++++ vendor/sluiceway | 2 +- 9 files changed, 226 insertions(+), 25 deletions(-) create mode 100644 test/shared-numbers.test.ts diff --git a/.gitmodules b/.gitmodules index 0c1e1c1..c8bb184 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "vendor/sluiceway"] path = vendor/sluiceway url = https://github.com/sluiceway/sluiceway.git - branch = v0.49.0 + branch = v0.50.0 diff --git a/src/content/docs/hosted-app/using-the-app.mdx b/src/content/docs/hosted-app/using-the-app.mdx index c103823..1b2d72b 100644 --- a/src/content/docs/hosted-app/using-the-app.mdx +++ b/src/content/docs/hosted-app/using-the-app.mdx @@ -78,6 +78,12 @@ Dashboard tab is a real box. Tick it, and: 4. **Your runner deploys.** The run previews the stack again and deploys only when the fresh preview gives the same diff hash, as for any tick. The row moves as the dashboard's row does. +A stack with a dependency, a phase, a deploy window or deploy on merge is judged by the action's +own rules too. When it has to wait for the stacks it depends on, for its window or for the end of +a deploy freeze, its record opens queued, and a later run +of your workflow starts it. When a stack it depends on has a change nobody ticked, the tick starts +nothing and names that stack: tick it first, then this one. + A refused tick writes nothing on the issue. The refusal, in the action's own words, comes back to you on the page. @@ -87,8 +93,7 @@ decide the rest, and the row shows it. Some boxes stay on GitHub, drawn as the same square but linking to the dashboard there: - the bulk, confirm and merge boxes, and the rescan box of the issue; -- a drift repair, and a stack with a dependency, a phase, a deploy window or deploy on merge, or - one an `ignore` entry leaves out; +- a drift repair, and a stack an `ignore` entry leaves out; - every box on a repo that does not name the app in `recordWriters`. The tab then shows one line with a button that opens the pull request to add it. diff --git a/src/content/docs/what-it-looks-like.mdx b/src/content/docs/what-it-looks-like.mdx index b37e8ad..aa85f99 100644 --- a/src/content/docs/what-it-looks-like.mdx +++ b/src/content/docs/what-it-looks-like.mdx @@ -5,7 +5,7 @@ description: The example dashboard from Sluiceway's README, rendered by Sluicewa import ExampleDashboard from "../../components/ExampleDashboard.astro"; import { inlineHtml, readmeDashboardIntro } from "../../lib/action-text"; -import { glossary, ROUTES } from "../../lib/routes"; +import { glossary, page, ROUTES } from "../../lib/routes"; export const intro = readmeDashboardIntro(); @@ -26,7 +26,10 @@ export const intro = readmeDashboardIntro(); busy. Under it, the scan line says which commit the last scan checked out, when, in which run, and when the last full scan was. While a scan runs, a scan-running line under it says - since when and links the run, which this example does not show. + since when and links the run, which this example does not show. Neither does it show + the lines about runs that failed: a + run that failed before Sluiceway ran, or found a problem in `sluiceway.yaml`, says so under + the scan line, and the next scan that works counts the runs that failed since the one before. - **Deploying.** While a stack deploys, its section comes before every other. `apps/api:prod` deploys, and its row starts with a spinner. `apps/worker:prod` is a queued stack: it waits for the stack it depends on. Both rows say @@ -61,7 +64,8 @@ export const intro = readmeDashboardIntro(); one row, with deploys off, or on a read-only dashboard. - **Drifted rows.** Real infrastructure changed outside the code. `monitoring/grafana:prod` has a property changed, and `platform/external-dns:prod` has a record gone. Deploying a stack puts - it back as its code says. + it back as its code says. A stack whose drift check failed keeps its row as it was and says + `drift not checked`. - **The confirm box.** Someone ticked `Repair all 2 drifted stacks`, and the next run put a confirm box where the bulk box was. It names the stacks and who asked. A tick on the confirm box deploys them, as a tick on each row. Each stack is judged by its own diff --git a/src/lib/command-line.ts b/src/lib/command-line.ts index 520c1c7..93ca3f3 100644 --- a/src/lib/command-line.ts +++ b/src/lib/command-line.ts @@ -192,6 +192,7 @@ export const EXAMPLES: ExampleGroup[] = [ run: null, lastDeploy: null, at: "2026-09-26T08:00:00Z", + confirm: null, }, exit: 0, }, @@ -287,7 +288,7 @@ export const EXAMPLES: ExampleGroup[] = [ { heading: "`sluiceway tick`", intro: - "Ticks the stack as you and stops once the app shows the deployment record. With `--json` the answer is two of the app's: `tick`, what the tick came to, and `deploy`, the record as the app showed it. The audit log's line for it says `via the command line`.", + "Ticks the stack as you and stops once the app shows the deployment record. With `--json` the answer is two of the app's: `tick`, what the tick came to, and `deploy`, the record as the app showed it. The audit log's line for it says `via the command line`. The last example has `apps/api:prod` depend on `network:prod`, ticked first, so with `--yes` its record is queued behind it.", examples: [ { command: "sluiceway tick infra network:prod", @@ -309,6 +310,7 @@ export const EXAMPLES: ExampleGroup[] = [ status: "https://console.sluiceway.dev/api/v1/orgs/acme/repos/infra/deployments/4242", }, dashboard: DASHBOARD, + confirm: null, }, deploy: { deployment: 4242, @@ -337,6 +339,15 @@ export const EXAMPLES: ExampleGroup[] = [ }, exit: 5, }, + { + command: "sluiceway tick infra apps/api:prod --yes", + words: [ + "Asked GitHub to deploy apps/api:prod once network:prod went out. Its deployment record is queued, and resolve starts it then.", + "Deployment record 4243: waiting to start.", + `The run of resolve that starts it opens a deployment record of its own and deploys it through a fresh preview and the hash check. Follow it with sluiceway stack infra apps/api:prod, or on the dashboard: ${DASHBOARD}`, + ].join("\n"), + exit: 0, + }, ], }, { diff --git a/src/lib/pages.ts b/src/lib/pages.ts index 6fe742d..26db23f 100644 --- a/src/lib/pages.ts +++ b/src/lib/pages.ts @@ -332,8 +332,11 @@ export interface DecisionRecord { number: string; file: string; title: string; - /** "Amended by 0051" and the like, from the lines above the first h2. */ - changes: { kind: string; records: string[] }[]; + /** + * "Amended by 0051" and the like, from the lines above the first h2. `file` is the record's + * file when it is known, as for one the index adds from a later record's lead. + */ + changes: { kind: string; records: string[]; file?: string }[]; /** * The records this one says it amends or supersedes, from its "Amends 0003, 0018 and 0061" * lead. The index turns them into "Amended by" on those records when the earlier record @@ -400,6 +403,74 @@ export function records(): DecisionRecord[] { }); } +/** + * Numbers the action gave to more than one record. A number alone cannot say which one a file + * means, so each file that cites it names its record here. The build fails on a shared number + * this does not cover, and on a file that cites one without an entry. + */ +export const SHARED_NUMBERS: Record> = { + // v0.50.0 numbered both records of 2026-10-07 0119. + "0119": { + "docs/build-plan.md": + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it.md", + "docs/adr/0004-issue-body-is-a-cache-of-row-blocks.md": + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it.md", + "docs/adr/0017-the-bot-is-always-the-workflow-token.md": + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it.md", + "docs/adr/0025-the-edit-history-names-the-ticker-and-the-event-is-only-a-wake-up.md": + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it.md", + "docs/adr/0120-a-run-that-failed-says-so-under-the-scan-line.md": + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it.md", + "docs/workflow.md": + "0119-the-open-pull-requests-are-read-with-pull-requests-read-alone-and-a-list-that-fails-is-a-warning.md", + "docs/split-workflow.md": + "0119-the-open-pull-requests-are-read-with-pull-requests-read-alone-and-a-list-that-fails-is-a-warning.md", + "docs/later.md": + "0119-the-open-pull-requests-are-read-with-pull-requests-read-alone-and-a-list-that-fails-is-a-warning.md", + }, +}; + +/** + * The records the Markdown of `from` can cite, by number. A shared number it cites resolves + * through SHARED_NUMBERS, and a record citing its own number means itself. + */ +export function recordsCitedIn( + from: string, + markdown: string, + list: DecisionRecord[], +): Map { + const out = new Map(); + for (const number of new Set(list.map((r) => r.number))) { + const all = list.filter((r) => r.number === number); + if (all.length === 1) { + out.set(number, all[0] as DecisionRecord); + continue; + } + const own = all.find((r) => r.file === from); + if (own) { + out.set(number, own); + continue; + } + // A number that is already a link, as in the records index, is not cited again. + const text = markdown.replace(/\[[^\]]*\]\([^)]*\)/g, ""); + if (!new RegExp(String.raw`\b${number}\b`).test(text)) continue; + out.set(number, sharedRecord(number, from, all)); + } + return out; +} + +function sharedRecord(number: string, from: string, all: DecisionRecord[]): DecisionRecord { + const file = SHARED_NUMBERS[number]?.[from]; + const found = all.find((r) => r.file === `docs/adr/${file}`); + if (!found) { + throw new Error( + `${from} cites ${number}, which ${all.map((r) => r.file).join(" and ")} share. ` + + "Say in SHARED_NUMBERS in src/lib/pages.ts which one it means.", + ); + } + return found; +} + /** * A record's description: its title, which is the decision, with what the page holds. The * long titles go alone or with only the number, so no description passes 160 characters. @@ -511,6 +582,16 @@ export function changelogHeadings(markdown: string, repoUrl: string): string { export function pages(repoUrl: string, base: string): Page[] { const recordList = records(); + for (const [number, citing] of Object.entries(SHARED_NUMBERS)) { + const all = recordList.filter((r) => r.number === number); + for (const [from, file] of Object.entries(citing)) { + if (all.length < 2 || !all.some((r) => r.file === `docs/adr/${file}`)) { + throw new Error( + `SHARED_NUMBERS says ${from} means docs/adr/${file}, which the pinned tag does not have as a shared ${number}.`, + ); + } + } + } for (const number of Object.keys(RECORD_PICTURES)) { if (!recordList.some((r) => r.number === number)) { throw new Error( @@ -980,16 +1061,18 @@ function exampleFiles(): string { // The index of the decision records. function recordIndex(list: DecisionRecord[], base: string): string { - const byNumber = new Map(list.map((r) => [r.number, r])); - const link = (n: string) => { - const r = byNumber.get(n); - return r ? `[${n}](${base}/${recordId(r)}/)` : n; - }; const rows = list.map((r) => { + const cited = recordsCitedIn(r.file, source(r.file).markdown, list); + const link = (n: string, file?: string) => { + const to = file ? list.find((one) => one.file === file) : cited.get(n); + return to ? `[${n}](${base}/${recordId(to)}/)` : n; + }; const changes = [...r.changes, ...changesFromLeads(r, list)] - .map((c) => [c.kind, c.records.map(link).join(", ")].filter(Boolean).join(" ")) + .map((c) => + [c.kind, c.records.map((n) => link(n, c.file)).join(", ")].filter(Boolean).join(" "), + ) .join("; "); - return `| ${link(r.number)} | ${r.title.replace(/\|/g, "\\|")} | ${changes} |`; + return `| ${link(r.number, r.file)} | ${r.title.replace(/\|/g, "\\|")} | ${changes} |`; }); return [ "Each record explains one decision about Sluiceway and why it was made. A later record can amend or supersede an earlier one, and the last column says which.", @@ -1016,7 +1099,7 @@ export function changesFromLeads( if (!lead.records.includes(record.number) || said.has(`${lead.kind} ${later.number}`)) { continue; } - out.push({ kind: lead.kind, records: [later.number] }); + out.push({ kind: lead.kind, records: [later.number], file: later.file }); } } return out; diff --git a/src/loaders/action-docs.ts b/src/loaders/action-docs.ts index 7ab0ac3..72292b6 100644 --- a/src/loaders/action-docs.ts +++ b/src/loaders/action-docs.ts @@ -22,6 +22,7 @@ import { README_UNMAPPED, recordId, records, + recordsCitedIn, siteMap, source, urlFor, @@ -48,7 +49,15 @@ export function actionDocsLoader(): Loader { const all = pages(REPO_URL, base); warnUnmappedReadme(ctx, all); const site = await checkedSiteMap(ctx, all, url); - const recordUrls = new Map(records().map((r) => [r.number, url(recordId(r))])); + const list = records(); + // A number two records share links by the file that cites it (SHARED_NUMBERS). + const recordUrls = (part: Part) => + new Map( + [...recordsCitedIn(part.from, part.markdown, list)].map(([n, r]) => [ + n, + url(recordId(r)), + ]), + ); for (const page of all) { const { html, headings } = await renderPage(ctx, page, site, recordUrls); @@ -161,7 +170,7 @@ async function renderPage( ctx: LoaderContext, page: Page, site: SiteMap, - recordUrls: Map, + recordUrls: (part: Part) => Map, ): Promise<{ html: string; headings: Heading[] }> { const headings: Heading[] = []; const html: string[] = []; @@ -181,7 +190,7 @@ async function renderPage( }); out = rewriteLinks(out, part.from, site, (message) => ctx.logger.warn(message)); out = linkRecords(out, { - urls: recordUrls, + urls: recordUrls(part), recordsContext: page.recordsContext ?? false, self, }); diff --git a/test/links.test.ts b/test/links.test.ts index 255e9d9..c05e028 100644 --- a/test/links.test.ts +++ b/test/links.test.ts @@ -164,10 +164,12 @@ describe("the records index", () => { const said = record("0009", "0096"); const all = [early, said, record("0095", "", "0003 0009"), record("0096", "", "0003 0009")]; expect(changesFromLeads(early, all)).toEqual([ - { kind: "Amended by", records: ["0095"] }, - { kind: "Amended by", records: ["0096"] }, + { kind: "Amended by", records: ["0095"], file: "docs/adr/0095-x.md" }, + { kind: "Amended by", records: ["0096"], file: "docs/adr/0096-x.md" }, + ]); + expect(changesFromLeads(said, all)).toEqual([ + { kind: "Amended by", records: ["0095"], file: "docs/adr/0095-x.md" }, ]); - expect(changesFromLeads(said, all)).toEqual([{ kind: "Amended by", records: ["0095"] }]); }); test("reads the leads of the real records", () => { @@ -188,7 +190,7 @@ describe("the records index", () => { expect(early).toBeDefined(); if (!early) return; expect(early.changes.some((c) => c.records.includes("0096"))).toBe(true); - expect(changesFromLeads(early, all)).toEqual( + expect(changesFromLeads(early, all).map(({ kind, records }) => ({ kind, records }))).toEqual( ["0095", "0102", "0104", "0109"].map((n) => ({ kind: "Amended by", records: [n] })), ); }); diff --git a/test/shared-numbers.test.ts b/test/shared-numbers.test.ts new file mode 100644 index 0000000..c31c0ba --- /dev/null +++ b/test/shared-numbers.test.ts @@ -0,0 +1,87 @@ +// Two records of the action can share a number: v0.50.0 numbered both records of 2026-10-07 +// 0119. Each file that cites a shared number links the record it means, by SHARED_NUMBERS. +// +// The built pages are read from dist/, so run it after `bun run build`, as `bun run check` does. + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { records, recordsCitedIn, SHARED_NUMBERS } from "../src/lib/pages"; + +const BASE = (process.env.DOCS_BASE ?? "/docs").replace(/\/+$/, ""); +const WRITE_BACK = + "0119-a-write-that-went-over-an-edit-writes-it-back-and-the-walk-looks-through-it"; +const PULLS = + "0119-the-open-pull-requests-are-read-with-pull-requests-read-alone-and-a-list-that-fails-is-a-warning"; + +const built = (id: string) => readFileSync(`dist/${id}/index.html`, "utf8"); +const href = (record: string) => `href="${BASE}/why/${record}/"`; + +describe("a shared number", () => { + const list = records(); + + test("each shared number of the pinned tag has its entry", () => { + const counts = new Map(); + for (const r of list) counts.set(r.number, (counts.get(r.number) ?? 0) + 1); + const shared = [...counts].filter(([, n]) => n > 1).map(([number]) => number); + expect(shared.sort()).toEqual(Object.keys(SHARED_NUMBERS).sort()); + }); + + test("resolves by the file that cites it", () => { + expect(recordsCitedIn("docs/workflow.md", "(record 0119)", list).get("0119")?.file).toBe( + `docs/adr/${PULLS}.md`, + ); + expect( + recordsCitedIn( + "docs/adr/0017-the-bot-is-always-the-workflow-token.md", + "Amended by 0119", + list, + ).get("0119")?.file, + ).toBe(`docs/adr/${WRITE_BACK}.md`); + }); + + test("a record that shares a number means itself", () => { + const file = `docs/adr/${WRITE_BACK}.md`; + expect(recordsCitedIn(file, "0119", list).get("0119")?.file).toBe(file); + }); + + test("a file that does not cite it links neither", () => { + expect(recordsCitedIn("docs/security.md", "record 0118", list).has("0119")).toBe(false); + }); + + test("a file that cites it without an entry fails the build", () => { + expect(() => recordsCitedIn("docs/security.md", "record 0119", list)).toThrow( + "Say in SHARED_NUMBERS", + ); + }); +}); + +describe("the built pages link the record they mean", () => { + test("record 0017's Amended by 0119 is the write that went over an edit", () => { + // The sidebar links both records on every page, so the check reads the lead itself. + const html = built("why/0017-the-bot-is-always-the-workflow-token"); + expect(html).toContain(`Amended by 0119`); + }); + + test("the workflow guides' record 0119 is the open pull requests", () => { + for (const id of ["guides/workflow", "guides/split-workflow"]) { + expect(built(id)).toContain(`as counts (record 0119)`); + } + }); + + test("the index links each row's 0119 to its own record", () => { + const html = built("why"); + const row = (record: string) => { + const at = html.indexOf(`/why/${record}/`); + return html.slice(at, html.indexOf("", at)); + }; + // 0017 says it is amended by the write-back record; 0054 is amended by the other one, + // whose lead names it. + expect(row("0017-the-bot-is-always-the-workflow-token")).toContain(href(WRITE_BACK)); + const fiftyFour = records().find((r) => r.number === "0054"); + expect(fiftyFour).toBeDefined(); + const id = fiftyFour?.file.replace(/^docs\/adr\//, "").replace(/\.md$/, "") ?? ""; + expect(row(id)).toContain(href(PULLS)); + expect(html).toContain(href(WRITE_BACK)); + expect(html).toContain(href(PULLS)); + }); +}); diff --git a/vendor/sluiceway b/vendor/sluiceway index 1c6b618..e39af59 160000 --- a/vendor/sluiceway +++ b/vendor/sluiceway @@ -1 +1 @@ -Subproject commit 1c6b6184aa05ae3741fdf228f89c02150e4dcec1 +Subproject commit e39af5947525cfd4c45ec8bff81ff50a61250807