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