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