Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions src/content/docs/hosted-app/using-the-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href={glossary("Queued stack")}>queued</a>, 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.

Expand All @@ -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.

Expand Down
10 changes: 7 additions & 3 deletions src/content/docs/what-it-looks-like.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -26,7 +26,10 @@ export const intro = readmeDashboardIntro();
<a href={glossary("Busy stack")}>busy</a>. Under it, the
<a href={glossary("Scan line")}>scan line</a> says which commit the last scan checked out, when, in which run, and when the
last full scan was. While a scan runs, a <a href={glossary("Scan-running line")}>scan-running line</a> 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
<a href={page("guides/workflow#a-run-that-failed")}>the lines about runs that failed</a>: 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 <a href={glossary("Spinner")}>spinner</a>. `apps/worker:prod` is a
<a href={glossary("Queued stack")}>queued stack</a>: it waits for the stack it depends on. Both rows say
Expand Down Expand Up @@ -61,7 +64,8 @@ export const intro = readmeDashboardIntro();
one row, with deploys off, or on a read-only dashboard.
- **<a href={glossary("Drift")}>Drifted</a> 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 <a href={glossary("Confirm box")}>confirm box</a>.** 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
Expand Down
13 changes: 12 additions & 1 deletion src/lib/command-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export const EXAMPLES: ExampleGroup[] = [
run: null,
lastDeploy: null,
at: "2026-09-26T08:00:00Z",
confirm: null,
},
exit: 0,
},
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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,
},
],
},
{
Expand Down
103 changes: 93 additions & 10 deletions src/lib/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, Record<string, string>> = {
// 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<string, DecisionRecord> {
const out = new Map<string, DecisionRecord>();
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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.",
Expand All @@ -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;
Expand Down
15 changes: 12 additions & 3 deletions src/loaders/action-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
README_UNMAPPED,
recordId,
records,
recordsCitedIn,
siteMap,
source,
urlFor,
Expand All @@ -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);
Expand Down Expand Up @@ -161,7 +170,7 @@ async function renderPage(
ctx: LoaderContext,
page: Page,
site: SiteMap,
recordUrls: Map<string, string>,
recordUrls: (part: Part) => Map<string, string>,
): Promise<{ html: string; headings: Heading[] }> {
const headings: Heading[] = [];
const html: string[] = [];
Expand All @@ -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,
});
Expand Down
10 changes: 6 additions & 4 deletions test/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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] })),
);
});
Expand Down
87 changes: 87 additions & 0 deletions test/shared-numbers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
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 <a ${href(WRITE_BACK)}>0119</a>`);
});

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 <a ${href(PULLS)}>0119</a>)`);
}
});

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("</tr>", 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));
});
});
Loading
Loading