diff --git a/README.md b/README.md
index 9008e7a..e26d4aa 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,8 @@ Every page has one title and one description. The pages read from the action tak
The share image, `share.png`, is Penny's `in-sync` header in light from the action's `assets/mascot` at the pinned tag, placed on a 1200 by 630 ground and rendered with resvg on every build (`src/lib/share-image.ts`). A new release of the art changes it with no work here.
+The site's own search (Pagefind) leaves the decision records out: `outOfSearch` in `src/lib/site.ts` names them, and the loader sets `pagefind: false` on each. They are most of the pages and use the product's words more than the guides do, so with them in the index a search for "tick" or "drift" answered with records. Weighing them down was tried first and does not work in Pagefind: a title match is boosted apart from a page's weight. The list of records stays in the index, so "decision records" or a record's number finds it, and search engines still index every record. `test/search.test.ts` asks the built index the questions a new user types.
+
The sitemap (`sitemap-index.xml`) and `robots.txt` follow `DOCS_SITE` and `DOCS_BASE`. The pages in `UNLISTED` in `src/lib/site.ts` (the 404 and the style check) carry `noindex` and stay out of the sitemap. `test/built-site.test.ts` checks all of this in `dist/`.
The `lighthouse` job in CI builds with the production address and runs Lighthouse three times on each URL in `lighthouserc.json`, as a phone. It fails when a median score for performance, accessibility, best practices or SEO is under 90 on any of them, and keeps the reports as the `lighthouse` artifact. To run it locally you need Chrome:
diff --git a/src/content/docs/get-started.mdx b/src/content/docs/get-started.mdx
index e68806f..c201dab 100644
--- a/src/content/docs/get-started.mdx
+++ b/src/content/docs/get-started.mdx
@@ -87,9 +87,10 @@ accept, everything the pull request holds, and what to do when there is none.
## Tick
Tick a stack's box on the dashboard issue, or in the console on the repo's Dashboard tab. The app
-picks the tick up within about a second, judges it with the action's own tick rule, opens the
-deployment record and starts your workflow. Your runner previews the stack again and deploys only
-when the change is the one you ticked.
+picks a tick on the issue up within about a second. A tick in the console holds five seconds
+first, with an **Undo** button. The app then judges the tick with the action's own tick rule,
+opens the deployment record and starts your workflow. Your runner previews the stack again and
+deploys only when the change is the one you ticked.
Using the dashboard explains the rows and the boxes, and
Using the app the console.
diff --git a/src/content/docs/hosted-app/install.mdx b/src/content/docs/hosted-app/install.mdx
index c142589..64d7a55 100644
--- a/src/content/docs/hosted-app/install.mdx
+++ b/src/content/docs/hosted-app/install.mdx
@@ -57,6 +57,10 @@ count toward your plan. See Plans and adopted
These are the permissions the app asks for, and what each one is used for.
+This table is being corrected: the app's registration on GitHub is being fixed for Workflows,
+Checks and Members, and the table will follow what GitHub shows on a real install, so until
+then go by GitHub's own screen where the two differ.
+
| Permission | Access | What for |
| --- | --- | --- |
| Metadata | Read | Required by GitHub for every app. |
@@ -170,7 +174,8 @@ deployment records your workflow deploys. With `sluiceway[bot]` on it:
preview gives the same diff hash and value fingerprint, as for any tick.
Ticking and Rescan from the app work in a repo whose `sluiceway.yaml` names `sluiceway[bot]` in
-`recordWriters`, and in any other repo they open the dashboard on GitHub.
+`recordWriters`. In any other repo a box opens the dashboard on GitHub, and the rescan box is
+there too.
It allows nothing else. The app cannot deploy a stack nobody ticked, and a stack that deletes
or replaces resources still waits for a person's tick.
diff --git a/src/content/docs/hosted-app/plans.mdx b/src/content/docs/hosted-app/plans.mdx
index f2002e5..873b8eb 100644
--- a/src/content/docs/hosted-app/plans.mdx
+++ b/src/content/docs/hosted-app/plans.mdx
@@ -56,24 +56,33 @@ The adopted repos past the line are listed greyed out, in a group of their own c
free tier, or past your plan's name:
- **The org view** lists each by name with its state counts, and no stacks or rows.
-- **Their own pages** are not shown, so Rescan for them opens the dashboard on GitHub.
+- **Their own pages** show the repo's name, its state counts, **Open the dashboard on GitHub**
+ and **Choose the active repos**, and no stacks. Their rescan box is on that dashboard.
- **The audit log** says how many lines each one has, and not the lines. The CSV holds what the
page shows.
- **Insights** name each one with its deploys, and do not count them.
A repo past the line keeps its dashboard on GitHub, and it works as always: scans, ticks and
- deploys go on exactly as they did. The app keeps its facts, and shows them again as soon as
- the repo is active.
+ deploys go on exactly as they did. The app keeps its facts as it does for an active repo, and
+ shows them again as soon as the repo is active.
## History
The audit log shows the deploys inside your plan's history, and its last sentence says how far
-back that is and on which plan. Nothing is deleted at the line: when your plan grows, the older
-lines are shown again.
+back that is and on which plan. What is older than your plan's history is deleted, each night.
+A bigger plan keeps more from the day it starts. It does not bring back lines that were deleted
+before it.
-Insights always cover the last 12 weeks against the 12 before, on every plan.
+One thing is kept whatever its age: the newest state of each stack, with its last deploy, so
+the Overview tab can always draw the row.
+
+Insights cover 12 weeks against the 12 before, and 6 months or 1 year on a plan whose history
+covers them. On Free only the last 30 days have their numbers.
+
+How long it keeps it
+has every window in one list.
## Upgrade
@@ -87,8 +96,8 @@ line. Choose a plan, monthly or yearly, and pay on Stripe's Checkout page. There
- **VAT**: Stripe works out the tax from your billing address. A business in the EU can enter
its VAT id.
-- **A promotion code** goes in on the Checkout page. A code that brings the price to 0 needs no
- card, and the Plan section names the code.
+- **A promotion code** goes in on the Checkout page of a monthly plan. The yearly Checkout takes
+ none. A code that brings the price to 0 needs no card, and the Plan section names the code.
You come back to Settings, which says Stripe is confirming the payment until it has.
@@ -98,9 +107,11 @@ You come back to Settings, which says Stripe is confirming the payment until it
Team Plus or monthly and yearly, update your card, read your invoices, or cancel there.
When you cancel, or a payment fails, your plan holds until the end of the period you paid for,
-and the installation falls back to Free after it. Nothing is deleted: the app shows what Free
-shows, and shows the rest again if a plan starts again. Settings says when your plan renews or
-ends, and what then happens.
+and the installation falls back to Free after it. The app then shows what Free shows, and
+keeps the wider history for 30 days more. Start a paid plan again within those 30 days and
+nothing is lost. After them, the lines older than Free's history are deleted. A move to a
+smaller plan keeps its wider history for 30 days in the same way. Settings says when your plan
+renews or ends, and the day the older lines are deleted.
To pay by invoice, write to hello@sluiceway.dev. An
invoice plan is changed the same way.
diff --git a/src/content/docs/hosted-app/using-the-app.mdx b/src/content/docs/hosted-app/using-the-app.mdx
index 500bf8b..c103823 100644
--- a/src/content/docs/hosted-app/using-the-app.mdx
+++ b/src/content/docs/hosted-app/using-the-app.mdx
@@ -14,8 +14,9 @@ import { glossary, page, ROUTES } from "../../../lib/routes";
Sign in at console.sluiceway.dev with GitHub. The app reads
-which installations you may see once, when you sign in, and keeps no token of yours. A session
-lasts eight hours, so a change of your membership on GitHub shows at your next sign-in.
+which installations and repos you may see when you sign in, and keeps no token of yours. A
+session lasts 30 days from the last day you used it. While you use it, the app asks GitHub
+again every hour, so a change of your membership on GitHub shows within the hour.
The top bar shows where you are, an org or one of its repos, and switches between them. An org
has the tabs Overview, Insights, Audit and Settings. A repo has Dashboard, Insights and Audit.
@@ -32,10 +33,10 @@ The org's Overview tab is every stack of every repo the app covers, at `console.
sync, folded. Or By repo, one panel per repo.
A row is one link to its repo. It says what the dashboard's row says, without the changes: the
-state, how many resources it deletes or replaces, drift outside the code, and the stack's last
-deploy with who ticked it or merged it, the commit and the run.
-A deploy the dashboard's trail lists as an outside deploy
-is marked "deployed outside the dashboard".
+state, how many resources it deletes or replaces, drift outside the code, and how the stack's
+last deploy ended, with who ticked it or merged it. A deploy the
+dashboard's trail lists as an outside deploy is marked
+"deployed outside the dashboard", with its short commit, and names nobody.
Under the stacks:
@@ -50,7 +51,9 @@ Under the stacks:
The Dashboard tab is the repo's dashboard issue drawn again, in the same order, with the same
headings and sentences, Penny's header included. The app draws
-it with the action's own renderer, at the version these docs describe, from the facts it keeps.
+it with the action's own renderer, from the facts it keeps. It carries a release of the action
+of its own, which can be older than the one these docs describe, so a sentence that a newer
+release words differently reads in the console as that older release wrote it.
The app keeps no diff, so a pending or drifted row is drawn the way a
redacted row is: its counts and no change lines. The changes
@@ -93,7 +96,8 @@ A read-only dashboard has no boxes
on GitHub.
Ticking and Rescan from the app work in a repo whose `sluiceway.yaml` names `sluiceway[bot]` in
-`recordWriters`, and in any other repo they open the dashboard on GitHub.
+`recordWriters`. In any other repo a box opens the dashboard on GitHub, and the rescan box is
+there too.
You can also tick and rescan from a terminal, or have a coding agent do it with a token you
make: the command line asks the app, which judges
@@ -109,13 +113,17 @@ The app starts your workflow, which runs a full scan, and the rescan box on the
unticked. A rescan deploys nothing, so it is asked at once, with no Undo. The toast says "Asked
GitHub to scan" and the repo's name. Asking again while that scan runs starts nothing new.
-On a repo that does not name the app in `recordWriters`, and on one past your plan, Rescan opens
-the dashboard on GitHub, where the rescan box is.
+The button is drawn only where the app can ask for the scan. A repo that does not name the app
+in `recordWriters`, and one past your plan, has no Rescan on its own pages: its rescan box is on
+its dashboard on GitHub. The org's Rescan menu lists every adopted repo, one that the app scans
+as "Rescans here" and any other as "Rescan on GitHub", which is a link to that dashboard.
## Insights
-Insights, for an org or for one repo, cover the last 12 weeks, from Monday in UTC, each number
-against the 12 weeks before.
+Insights, for an org or for one repo, cover a window you choose: 12 weeks, and 6 months or
+1 year where your plan's history covers them. Weeks start on Monday in UTC, and each number is
+set against the same span before it. On Free the app keeps 30 days of history, so only the last
+weeks have their numbers.
- **Lead time**: from the merge of a pull request to the deploy of its merge commit, the median.
A deploy of a commit with no merged pull request, such as a direct push, is not measured.
@@ -125,7 +133,8 @@ against the 12 weeks before.
Four charts show lead time per week, deploys per week, stacks drifted per week, and where the
time goes between a merge and its deploy: to the scan, to the preview, waiting for a tick, and
-the deploy itself. The org's Insights end with one row per repo.
+the deploy itself. The org's Insights end with a table of the repos that had deploys in the
+window, and one sentence that names the repos with none.
A number the facts cannot give says "Not measured yet" in one sentence, with no number.
@@ -133,17 +142,21 @@ A number the facts cannot give says "Not measured yet" in one sentence, with no
The Audit tab lists one line per deploy, newest first: when, the repo, the stack, who ticked or
who merged, the commit, and the result in the trail's words. A deploy outside the dashboard is
-flagged in red, "outside the dashboard", and names nobody, because the tool's history never says
-who.
+flagged in bold after its result, "⚠ outside the dashboard", and names nobody, because the tool's
+history never says who.
A tick from the command line counts as the person
whose token it used, and its line says "via the command line".
-Filter by repo, by stack or to the flagged lines only. Each filter is a plain link, so a filtered
-log has an address you can paste into a ticket. **CSV** downloads what the page shows, with the
-same filters, the time in UTC and the whole commit.
+The buttons above the log filter it: every deploy, the flagged lines only, and from the org, one
+repo. There is no button for one stack: add `?stack=` and the stack id to the address. Every
+filter is part of the address, so a filtered log can be pasted into a ticket. **Download CSV**
+downloads what the page shows, with the same filters, the time in UTC and the whole commit.
-The log goes back as far as your plan's history, and its last sentence says how far.
+The log shows one month at a time, the newest first, with **Older** and **Newer** under it. It
+goes back as far as your plan's history, and its last sentence says how far. What is older than
+that is deleted each night: see
+How long it keeps it.
## Edit mode
@@ -154,10 +167,25 @@ the settings of the block you select beside it. Every change redraws the dashboa
Every setting is a documented key of `sluiceway.yaml` that the action reads without the app. What
you build keeps working exactly the same if you stop using the app.
-- **The dashboard**: `dashboard.title`, `personality` (Penny's header), `redact`, `showValues`,
- `recentlyDeployed`, `timeZone` and `readOnly`, and `drift.enabled`.
+Select a block of the dashboard, and its own settings open:
+
+- **Header picture**: `personality`, Penny's header and voice.
+- **Title** and **Counts line**: `dashboard.title`, and whether a count of 0 is shown.
+- **A section**: drag it to move it, and turn it on or off where the action has a key for that.
+ Pending and Preview failed cannot be hidden.
+- **Pending**: Row detail, which is how much a pending row shows, the Caution that names
+ destroys, the Deploy all box, and `showValues`.
+- **Drifted**: the Repair all box.
+- **In sync**: the `ignore` list with its reasons.
+- **Recently deployed**: `recentlyDeployed` and `timeZone`.
+- **Rescan box** and **Footer**: each on or off.
+
+Under **The whole dashboard** are the settings that belong to no block:
+
+- **The dashboard**: `dashboard.title`, `redact`, `readOnly` and `drift.enabled`.
+- **Templates**, below.
- **Who may tick**: `tickers`, and `recordWriters` as the app on the list or off it.
-- **Stacks**: per stack, `deploy` and `tickers`, and the `ignore` list with its reasons.
+- **Stacks**: per stack, `deploy` and `tickers`.
A setting at its default takes its key out of the file. See
Configuration for what each key does.
@@ -167,16 +195,23 @@ A setting at its default takes its key out of the file. See
A template is a named set of those keys, applied in one click over the settings you have, and
adjusted after. It touches no key it does not name.
-- **Minimal**: `personality` off, rows redacted, and the last 3 deploys in the trail.
-- **Classic**: every dashboard key and drift at the action's defaults.
-- **Ops desk**: the last 30 deploys in the trail and drift on. Set your time zone yourself.
+- **Minimal**: `personality` off, a pending row cut to its first line and what must be seen,
+ the In sync section off, no count of 0, and the last 3 deploys in the trail.
+- **Classic**: every dashboard key, the layout and drift at the action's defaults.
+- **Ops desk**: the last 30 deploys in the trail, drift on, every line of a pending row and
+ every count. Set your time zone yourself.
+
+No template turns `redact` on. To keep resource names and values off the issue, set Redact
+yourself, under The dashboard.
-### Save opens a pull request
+### A change is a pull request
-**Save** opens one pull request on the branch `sluiceway/dashboard`, from the commit the dashboard
-was scanned at. It changes `sluiceway.yaml` and nothing else, keeps every other key and every
-comment as they were, and its description names each change and shows the dashboard as it will
-look. A person merges it, and the next scan draws the dashboard that way.
+A bar above the dashboard counts your changes, with **Discard** and **Review pull request**.
+Review shows the pull request before it exists: its title, its branch, `sluiceway/dashboard`,
+and the diff of `sluiceway.yaml`. **Open the pull request** then opens it, from the commit the
+dashboard was scanned at. It changes `sluiceway.yaml` and nothing else, keeps every other key and
+every comment as they were, and its description names each change and shows the dashboard as it
+will look. A person merges it, and the next scan draws the dashboard that way.
The app never changes the file behind anyone's back. While a pull request from edit mode is open,
the app names it rather than open a second one. When the file moved on the default branch since
@@ -193,5 +228,10 @@ The org's Settings tab holds what the app decides, and nothing that belongs in `
- **Active repos**: which adopted repos your plan shows.
- **The installation**: the repos the app is installed on, with a link to GitHub's page to change
them.
-- **Access per repo**: whether each adopted repo names the app in `recordWriters`, with a button
- that opens the one-line pull request to add it or take it out.
+- **The app's access**: whether each adopted repo names the app in `recordWriters`, with a button
+ that opens the pull request to add it or take it out.
+- **Second approver**: what your plan does with the Sluiceway protection rule, and who may
+ approve. See Plans and adopted repos.
+- **Delete everything**, last and apart: an admin of the org, or the person whose account it is,
+ deletes all the app keeps of the installation at once. See
+ If you stop using the app.
diff --git a/src/content/docs/hosted-app/what-the-app-keeps.mdx b/src/content/docs/hosted-app/what-the-app-keeps.mdx
index 336c1e8..66020c6 100644
--- a/src/content/docs/hosted-app/what-the-app-keeps.mdx
+++ b/src/content/docs/hosted-app/what-the-app-keeps.mdx
@@ -51,6 +51,28 @@ for that shape.
- **No card, email, address, business name or tax id.** Stripe keeps those. The app keeps your
plan, what Stripe says about the subscription, when the period ends, and Stripe's ids.
+## How long it keeps it
+
+- **History**: deploys, ticks, rescans and merges are kept for your plan's history, which is 30
+ days on Free, 13 months on Team and 3 years on Team Plus. What is older is deleted each night.
+- **The newest state of each stack** is kept whatever its age: its last deploy, a deployment
+ record that is still open, and the newest scan of its repo.
+- **A plan that narrows**, by falling back to Free or moving to a smaller plan, keeps its wider
+ history for 30 days. A paid plan that starts again within them loses nothing. After them,
+ what the new plan does not keep is deleted.
+- **A repo taken out of the installation**: its facts are kept for 30 days, then deleted.
+- **An uninstalled app**: everything of the installation is kept for 30 days, then deleted.
+- **Delete everything**, in the org's Settings, deletes it all at once.
+- **Backups**: the database is backed up each night, and a backup is kept for up to 12 months.
+ Deleted data leaves the backups as they age out.
+
+After a deletion the app keeps the account's name on GitHub and the date, to confirm the
+deletion when you ask.
+
+The terms and the
+privacy policy say the same, with what is kept
+about a person: sessions, tokens and logs.
+
## No cloud credential, ever
The app never holds, asks for or supplies a cloud or infrastructure credential. Your credentials
@@ -86,8 +108,8 @@ The app is a view of what GitHub holds, and catches up with it.
last point it saw: the dashboard, the deployment records and the workflow runs. GitHub sends
the webhooks it missed again too. A downtime delays what the app shows and never loses it.
- **What the app shows follows GitHub.** A dashboard written again, a record closed, a repo
- removed from the installation: the app brings its facts up to date. A repo the installation no
- longer covers keeps its lines in the audit log.
+ removed from the installation: the app brings its facts up to date. The facts of a repo the
+ installation no longer covers are kept for 30 days, then deleted.
## If you stop using the app
@@ -95,3 +117,9 @@ Uninstall it from GitHub's settings for the installation. Your dashboards, your
your deploys go on as before. Everything edit mode wrote is a documented key of
`sluiceway.yaml` that the action reads on its own, so the dashboard looks the same. Take
`sluiceway[bot]` out of `recordWriters` when you no longer want the app to open records.
+
+The app keeps what it has of the installation for 30 days after the uninstall, then deletes it.
+To have it gone at once, use **Delete everything**, the last section of the org's Settings tab,
+in place of uninstalling: an admin of the org, or the person whose account it is, confirms by
+typing the org's name, the app deletes everything it keeps of the installation, and then
+uninstalls itself. While Stripe still charges a plan, it asks you to cancel the plan first.
diff --git a/src/lib/anchor-redirects.ts b/src/lib/anchor-redirects.ts
index 2e977cf..c787956 100644
--- a/src/lib/anchor-redirects.ts
+++ b/src/lib/anchor-redirects.ts
@@ -33,6 +33,10 @@ export const ANCHOR_REDIRECTS: Record> = {
"what-it-looks-like": {
"the-parts": "what-it-looks-like#read-the-dashboard-from-the-top",
},
+ // Edit mode has no Save: the button is Review pull request (2026-10-01).
+ "hosted-app/using-the-app": {
+ "save-opens-a-pull-request": "hosted-app/using-the-app#a-change-is-a-pull-request",
+ },
"the-header": {
"the-pictures": "the-header#every-picture-one-per-state",
"with-the-destroy-sign": "the-header#signs-for-deletes-and-replaces",
diff --git a/src/lib/pages.ts b/src/lib/pages.ts
index b9db026..6fe742d 100644
--- a/src/lib/pages.ts
+++ b/src/lib/pages.ts
@@ -275,6 +275,57 @@ function generated(from: string, markdown: string): Part {
return { from, markdown, headings: [] };
}
+// The steps of "Run the action yourself".
+
+/** The README paragraph about a first scan that goes wrong, by its bold opening. */
+const FIRST_SCAN = "Before the first scan";
+
+/**
+ * The README's "Get started" section as a page a reader can follow. The README writes its
+ * steps as paragraphs that each open on a bold sentence, and ends on the workflow file. Here
+ * each step is a numbered heading, so there is a count, a place to come back to and an entry
+ * in "On this page", and the file is handed back apart, for the page to show first. The
+ * paragraph about a first scan that goes red is not a step: it is handed back as `after`,
+ * under a heading that says when to read it.
+ *
+ * Throws when the section no longer has that shape, so a rewritten README is looked at.
+ */
+export function selfRunSteps(markdown: string): {
+ workflow: string;
+ steps: string[];
+ after: string;
+} {
+ const workflow = markdown.match(/^```yaml\n[\s\S]*?\n```$/m)?.[0];
+ if (workflow === undefined) {
+ throw new Error('The README\'s "Get started" section has no workflow file in a yaml block.');
+ }
+ const paragraphs = markdown
+ .replace(workflow, "")
+ .split(/\n{2,}/)
+ .map((paragraph) => paragraph.trim())
+ .filter((paragraph) => paragraph !== "")
+ .map((paragraph) => {
+ const step = paragraph.match(/^\*\*(.+?)\.\*\*\s+([\s\S]+)$/);
+ if (!step) {
+ throw new Error(
+ `A paragraph of the README's "Get started" section does not open on a bold sentence: "${paragraph.slice(0, 60)}".`,
+ );
+ }
+ return { title: step[1] ?? "", body: step[2] ?? "" };
+ });
+ const red = paragraphs.find((paragraph) => paragraph.title === FIRST_SCAN);
+ if (red === undefined) {
+ throw new Error(`The README's "Get started" section has no "${FIRST_SCAN}" paragraph.`);
+ }
+ return {
+ workflow,
+ steps: paragraphs
+ .filter((paragraph) => paragraph !== red)
+ .map(({ title, body }, i) => `### ${i + 1}. ${title}\n\n${body}`),
+ after: `### When the first scan is red\n\n${red.body}`,
+ };
+}
+
// The decision records.
export interface DecisionRecord {
@@ -472,6 +523,10 @@ export function pages(repoUrl: string, base: string): Page[] {
.filter((h) => h.depth === 2)
.map((h) => plainHeading(h.raw).replace(/\[([^\]]+)\]\([^)]*\)/, "$1"));
+ // The README's steps for running the action alone, as one heading and its body.
+ const getStarted = sectionPart(README, "Get started", { retitle: "The steps" });
+ const selfRun = selfRunSteps(getStarted.markdown.split("\n").slice(1).join("\n"));
+
return [
{
id: "how-it-works",
@@ -510,12 +565,32 @@ export function pages(repoUrl: string, base: string): Page[] {
`With the app, [one pull request](${base}/${GET_STARTED.withTheApp}/) holds the workflow and a first \`sluiceway.yaml\`, and the console guides the rest. [How the app and the action work together](${base}/${GET_STARTED.together}/) says what the app adds and what stays the same.`,
].join("\n"),
),
- // First, so a reader checks the runner and tool versions before any step. Here since the
- // README moved it, so get-started/#requirements keeps working.
+ // The file first: it is what the reader came for, and the steps say what goes in it.
+ generated(
+ README,
+ [
+ "## The workflow file",
+ "",
+ "This is all the action needs: one job with one Sluiceway step, in `.github/workflows/deploy-dashboard.yml` on the default branch. The comments mark where your own steps go. Read it now, and merge it at the end of the steps below.",
+ "",
+ selfRun.workflow,
+ ].join("\n"),
+ ),
+ // Before the steps, so a reader checks the runner and tool versions first. Here since
+ // the README moved it, so get-started/#requirements keeps working.
sectionPart("docs/reference.md", "Requirements"),
// Under a heading of its own, so the steps do not read as part of Requirements. The id
// stays get-started, which links to the README section expect.
- sectionPart(README, "Get started", { retitle: "The steps" }),
+ { ...getStarted, markdown: getStarted.markdown.split("\n", 1)[0] ?? "" },
+ // The last step says to add the file, which is no longer under it.
+ generated(
+ README,
+ [
+ ...selfRun.steps,
+ `The file is [at the top of this page](${base}/${GET_STARTED.runTheActionYourself}/#the-workflow-file).`,
+ selfRun.after,
+ ].join("\n\n"),
+ ),
],
},
{
diff --git a/src/lib/site.ts b/src/lib/site.ts
index e186894..19831b9 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -12,6 +12,20 @@ export const WAITLIST_URL = "";
*/
export const UNLISTED = ["404", "style-check"];
+/**
+ * Whether a page is left out of the search index: each decision record. They are most of the
+ * site's pages and use the product's words more often than the guides do, so search answered
+ * "tick" and "drift" with the reasoning of a past decision, some of it superseded, in place of
+ * the instruction. Pagefind cannot weigh a whole page down: a match in a title is boosted apart
+ * from the weight of the words, and a short page full of one word still scores near the top.
+ *
+ * The list of records stays in the index, so "decision records" or a record's number finds the
+ * list, and the sidebar and the guides link each record. Search engines still index them.
+ */
+export function outOfSearch(id: string): boolean {
+ return /^why\/\d{4}-/.test(id);
+}
+
/** The landing page, which the header and footer link to as "Sluiceway". */
export const LANDING_URL = "https://sluiceway.dev/";
diff --git a/src/loaders/action-docs.ts b/src/loaders/action-docs.ts
index 971bef1..7ab0ac3 100644
--- a/src/loaders/action-docs.ts
+++ b/src/loaders/action-docs.ts
@@ -27,6 +27,7 @@ import {
urlFor,
} from "../lib/pages";
import { actionNames, schemaKeys } from "../lib/reference";
+import { outOfSearch } from "../lib/site";
import { REPO_URL } from "../lib/source";
interface Heading {
@@ -61,6 +62,7 @@ export function actionDocsLoader(): Loader {
...(page.sidebarLabel ? { sidebar: { label: page.sidebarLabel } } : {}),
...(page.headerPicture ? { headerPicture: page.headerPicture } : {}),
...(page.eyebrow ? { eyebrow: page.eyebrow } : {}),
+ ...(outOfSearch(page.id) ? { pagefind: false } : {}),
},
});
const body = page.parts.map((p) => p.markdown).join("\n\n");
diff --git a/src/styles/theme.css b/src/styles/theme.css
index e4c7e66..d6ff090 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -54,8 +54,13 @@
the header clear of it. */
--sl-menu-button-size: 44px;
/* `ch` is the width of Inter's "0", about 1.33 average characters: 54ch holds a line of
- about 72 characters, the measure 30-pages.md asks for. */
- --sl-content-width: 54ch;
+ about 72 characters, the measure for prose. */
+ --sw-measure: 54ch;
+ /* The column is wider than the measure, for what is read whole and not as a paragraph: a
+ code block and a table take all of it. Beside "On this page" on a 1340px screen that comes
+ to about 80 characters of code, and on a wider screen to about 90. Everything else stays
+ at the measure, see "The measure" below. */
+ --sl-content-width: 86ch;
--sl-sidebar-width: 18rem;
}
@@ -306,6 +311,22 @@ mobile-starlight-toc summary .toggle {
line-height: 30px;
}
+/* The measure. Prose, headings, lists, callouts and pictures stay at 54ch in a column that is
+ wider, and code blocks and tables run to the column's edge, so a line of a workflow is read
+ whole and not scrolled to. `:where` keeps this at no specificity: an element with a width of
+ its own keeps it. The start page has a layout of its own (landing.css). */
+:where(:root:not([data-has-hero]) .sl-markdown-content)
+> :where(:not(.expressive-code, .sw-codeblock, table)) {
+ max-width: var(--sw-measure);
+}
+:root:not([data-has-hero]) h1#_top {
+ max-width: var(--sw-measure);
+}
+/* A page with no sidebar and no hero, the 404, is one centred column of prose. */
+:root:not([data-has-sidebar], [data-has-hero]) {
+ --sl-content-width: var(--sw-measure);
+}
+
/* Links: ink with a `water-deep` underline, the design system's sw-link. */
.sl-markdown-content a:not(:where(.not-content *)) {
color: var(--ink);
diff --git a/test/code-width.test.ts b/test/code-width.test.ts
new file mode 100644
index 0000000..cf40dfe
--- /dev/null
+++ b/test/code-width.test.ts
@@ -0,0 +1,37 @@
+// Prose keeps its measure and code runs wider. One width for the whole column cut 96 of 217
+// code blocks off on a desktop screen (the 2026-10-01 review, H4): a line of code holds about
+// 60 characters in 54ch, and the workflow's own comments are longer.
+//
+// A browser is what measures this for real. This holds the two numbers and the rule that
+// keeps them apart, in the stylesheet the build ships.
+
+import { describe, expect, test } from "bun:test";
+import { readdirSync, readFileSync } from "node:fs";
+
+const theme = readFileSync("src/styles/theme.css", "utf8");
+
+function custom(name: string): string {
+ return theme.match(new RegExp(`${name}:\\s*([^;]+);`))?.[1] ?? "";
+}
+
+describe("the column", () => {
+ test("prose keeps the measure of about 72 characters", () => {
+ expect(custom("--sw-measure")).toBe("54ch");
+ });
+
+ test("the column itself is wide enough for 80 characters of code", () => {
+ expect(Number.parseFloat(custom("--sl-content-width"))).toBeGreaterThanOrEqual(80);
+ expect(custom("--sl-content-width")).toEndWith("ch");
+ });
+
+ test("the built stylesheet narrows everything but code and tables to the measure", () => {
+ const css = readdirSync("dist/_astro")
+ .filter((name) => name.endsWith(".css"))
+ .map((name) => readFileSync(`dist/_astro/${name}`, "utf8"))
+ .join("\n");
+ const rule = css.match(/[^{}]*\{[^{}]*max-width:\s*var\(--sw-measure\)[^{}]*\}/)?.[0] ?? "";
+ expect(rule).toContain(".sl-markdown-content");
+ expect(rule).toContain(".expressive-code");
+ expect(rule).toContain("table");
+ });
+});
diff --git a/test/hosted-app.test.ts b/test/hosted-app.test.ts
new file mode 100644
index 0000000..f7c8a20
--- /dev/null
+++ b/test/hosted-app.test.ts
@@ -0,0 +1,179 @@
+// The Hosted app pages say what the app does. They are written here by hand, so nothing but
+// these tests holds them to the app: each one is a sentence the 2026-10-01 review found wrong
+// against the app's source and the console, and the words the console shows in its place.
+//
+// Run after `bun run build`, as `bun run check` does.
+
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+
+const ENTITIES: Record = {
+ amp: "&",
+ quot: '"',
+ "#39": "'",
+ "#x27": "'",
+ lt: "<",
+ gt: ">",
+ nbsp: " ",
+};
+
+/** The words of a built page's , as a reader sees them, on one line. */
+function words(id: string): string {
+ const html = readFileSync(`dist/${id}/index.html`, "utf8");
+ const main = html.slice(html.indexOf(""));
+ return main
+ .replace(/<(script|style)[\s\S]*?<\/\1>/g, " ")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/&(amp|quot|#39|#x27|lt|gt|nbsp);/g, (_, name: string) => ENTITIES[name] ?? "")
+ .replace(/’/g, "'")
+ .replace(/\s+/g, " ");
+}
+
+/** The words of one section, from its heading's id to the next heading of that level. */
+function section(id: string, anchor: string): string {
+ const html = readFileSync(`dist/${id}/index.html`, "utf8");
+ const start = html.indexOf(` id="${anchor}"`);
+ if (start < 0) throw new Error(`The page ${id} has no heading with the id "${anchor}".`);
+ const level = html.slice(html.lastIndexOf("]`));
+ return html
+ .slice(start, next < 0 ? html.indexOf("") : start + next)
+ .replace(/<[^>]+>/g, " ")
+ .replace(/&(amp|quot|#39|#x27|lt|gt|nbsp);/g, (_, name: string) => ENTITIES[name] ?? "")
+ .replace(/’/g, "'")
+ .replace(/\s+/g, " ");
+}
+
+const using = words("hosted-app/using-the-app");
+const plans = words("hosted-app/plans");
+const keeps = words("hosted-app/what-the-app-keeps");
+const install = words("hosted-app/install");
+const getStarted = words("get-started");
+
+describe("what the app keeps, and for how long (H1)", () => {
+ test('no page says "nothing is deleted"', () => {
+ for (const page of [using, plans, keeps, install, getStarted]) {
+ expect(page).not.toMatch(/nothing is deleted/i);
+ }
+ });
+
+ test("Plans says history past the plan is deleted each night", () => {
+ const history = section("hosted-app/plans", "history");
+ expect(history).toContain("deleted");
+ expect(history).toContain("each night");
+ expect(history).not.toContain("shown again");
+ });
+
+ test("Plans says a plan that ends keeps its wider history for 30 days", () => {
+ const cancel = section("hosted-app/plans", "change-or-cancel");
+ expect(cancel).toContain("30 days");
+ expect(cancel).toContain("deleted");
+ });
+
+ test("What the app keeps has the three windows, the 30 days and Delete everything", () => {
+ const howLong = section("hosted-app/what-the-app-keeps", "how-long-it-keeps-it");
+ for (const said of ["30 days", "13 months", "3 years", "each night"]) {
+ expect(howLong).toContain(said);
+ }
+ expect(keeps).not.toContain("keeps its lines in the audit log");
+ const stop = section("hosted-app/what-the-app-keeps", "if-you-stop-using-the-app");
+ expect(stop).toContain("30 days");
+ expect(stop).toContain("Delete everything");
+ });
+
+ test("the pages link the published terms and privacy policy", () => {
+ const html = readFileSync("dist/hosted-app/what-the-app-keeps/index.html", "utf8");
+ expect(html).toContain('href="https://sluiceway.dev/terms"');
+ expect(html).toContain('href="https://sluiceway.dev/privacy"');
+ });
+});
+
+describe("Using the app says what the console shows (H3)", () => {
+ // The review's number, what the page said, and what the console says.
+ const gone: [finding: string, said: string][] = [
+ ["A6", "eight hours"],
+ ["A6", "at your next sign-in"],
+ ["A7", "the commit and the run"],
+ ["A8", "at the version these docs describe"],
+ ["A9", "in any other repo they open the dashboard on GitHub"],
+ ["A10", "cover the last 12 weeks"],
+ ["A11", "end with one row per repo"],
+ ["A12", "flagged in red"],
+ ["A13", "by stack or to the flagged"],
+ ["A14", "Each filter is a plain link"],
+ ["A17", "rows redacted"],
+ ["A18", "Save opens"],
+ ["A19", "Access per repo"],
+ ];
+ for (const [finding, said] of gone) {
+ test(`${finding}: no longer says "${said}"`, () => {
+ expect(using).not.toContain(said);
+ });
+ }
+
+ const shown: [finding: string, label: string][] = [
+ ["A6", "30 days"],
+ ["A6", "every hour"],
+ ["A9", "Rescan on GitHub"],
+ ["A10", "6 months"],
+ ["A12", "⚠"],
+ ["A15", "Download CSV"],
+ ["A16", "Row detail"],
+ ["A18", "Review pull request"],
+ ["A18", "Open the pull request"],
+ ["A19", "The app's access"],
+ ];
+ for (const [finding, label] of shown) {
+ test(`${finding}: says "${label}"`, () => {
+ expect(using).toContain(label);
+ });
+ }
+
+ test("A17: Minimal is not said to redact, and is said to leave In sync off", () => {
+ const templates = section("hosted-app/using-the-app", "templates");
+ const minimal = templates.slice(templates.indexOf("Minimal"), templates.indexOf("Classic"));
+ expect(minimal).not.toMatch(/redact/i);
+ expect(minimal).toContain("In sync");
+ // Someone who wants names off the issue is told which key does that.
+ expect(templates).toContain("redact");
+ });
+
+ test("the address the console links for a tick keeps its heading", () => {
+ const html = readFileSync("dist/hosted-app/using-the-app/index.html", "utf8");
+ expect(html).toContain(' id="tick-from-the-app"');
+ });
+});
+
+describe("Plans and Get started (M11)", () => {
+ test("A20: Insights are not said to cover 12 weeks on every plan", () => {
+ const history = section("hosted-app/plans", "history");
+ expect(history).not.toContain("on every plan");
+ expect(history).toContain("6 months");
+ });
+
+ test("A21: a promotion code is for the monthly price", () => {
+ const upgrade = section("hosted-app/plans", "upgrade");
+ expect(upgrade).toMatch(/promotion code[^.]*monthly/i);
+ });
+
+ test("A22: a repo past the plan has a page", () => {
+ expect(plans).not.toContain("are not shown, so Rescan");
+ expect(plans).toContain("Choose the active repos");
+ });
+
+ test("A23: a tick in the console holds five seconds for Undo", () => {
+ const tick = section("get-started", "tick");
+ expect(tick).toContain("five seconds");
+ expect(tick).toContain("Undo");
+ });
+});
+
+describe("the permissions table (H2)", () => {
+ test("says it is being corrected, and what follows", () => {
+ const accept = section("hosted-app/install", "what-github-asks-you-to-accept");
+ expect(accept).toContain("being corrected");
+ for (const permission of ["Workflows", "Checks", "Members"]) {
+ expect(accept).toContain(permission);
+ }
+ });
+});
diff --git a/test/search.test.ts b/test/search.test.ts
new file mode 100644
index 0000000..e826cff
--- /dev/null
+++ b/test/search.test.ts
@@ -0,0 +1,109 @@
+// Search answers with the guides. Most pages of the site are decision records, and they use the
+// product's words more often than the guides do, so they filled the first results for "tick",
+// "drift" or "destroy". They are left out of the index (src/lib/site.ts), and these are the
+// queries of the 2026-10-01 review, asked of dist/pagefind the way the search dialog asks them.
+//
+// Run after `bun run build`, as `bun run check` does.
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { outOfSearch } from "../src/lib/site";
+
+const INDEX = resolve("dist/pagefind");
+
+if (!existsSync(`${INDEX}/pagefind.js`)) {
+ throw new Error("dist/ has no search index. Run `bun run build` before `bun test`.");
+}
+
+interface Result {
+ /** The page id, such as `guides/configuration`. */
+ id: string;
+ title: string;
+}
+
+interface Pagefind {
+ options(options: { basePath: string; baseUrl: string }): Promise;
+ init(): Promise;
+ search(query: string): Promise<{
+ results: { data(): Promise<{ url: string; meta: { title?: string } }> }[];
+ }>;
+}
+
+let pagefind: Pagefind;
+
+beforeAll(async () => {
+ // Pagefind's own client, the file the dialog loads, reading the index from disk. With the
+ // site at "/", a result's URL is its page id between slashes, whatever DOCS_BASE the build had.
+ pagefind = await import(pathToFileURL(`${INDEX}/pagefind.js`).href);
+ await pagefind.options({ basePath: pathToFileURL(`${INDEX}/`).href, baseUrl: "/" });
+ await pagefind.init();
+});
+
+async function search(query: string, count = 10): Promise {
+ const found = await pagefind.search(query);
+ const data = await Promise.all(found.results.slice(0, count).map((result) => result.data()));
+ return data.map(({ url, meta }) => ({
+ id: url.replace(/^\/|\/$/g, ""),
+ title: meta.title ?? "",
+ }));
+}
+
+// What a new user types, and the pages that answer it. One of them is in the first five.
+// "cli" is not here: the Command line page never says the word, which is the action's to fix.
+const QUERIES: [query: string, answers: string[]][] = [
+ ["tick", ["using-the-dashboard", "hosted-app/using-the-app"]],
+ ["drift", ["guides/configuration", "using-the-dashboard", "reference/sluiceway-yaml"]],
+ ["tickers", ["reference/sluiceway-yaml", "guides/configuration", "guides/security"]],
+ ["destroy", ["using-the-dashboard", "reference/sluiceway-yaml", "reference/glossary"]],
+ ["preview failed", ["using-the-dashboard"]],
+ ["permissions", ["guides/workflow", "hosted-app/install", "guides/security"]],
+ ["credentials", ["guides/credentials"]],
+ ["on-merge", ["guides/configuration", "using-the-dashboard", "guides/security"]],
+ ["token", ["hosted-app/command-line", "guides/credentials", "guides/security"]],
+ ["command line", ["hosted-app/command-line"]],
+ ["install", ["hosted-app/install", "get-started"]],
+];
+
+describe("search", () => {
+ for (const [query, answers] of QUERIES) {
+ test(`"${query}" answers with a guide, not a decision record`, async () => {
+ const results = (await search(query)).map((result) => result.id);
+ expect(results.length).toBeGreaterThan(0);
+ expect(results.filter(outOfSearch)).toEqual([]);
+ expect(results.slice(0, 5).some((id) => answers.includes(id))).toBe(true);
+ });
+ }
+
+ test("a record's own title does not bring the record up", async () => {
+ const results = await search("every tick keeps its own event", 40);
+ expect(results.filter((result) => outOfSearch(result.id))).toEqual([]);
+ });
+
+ test("the list of records is found by what it is and by a record's number", async () => {
+ for (const query of ["decision records", "0005"]) {
+ const [first] = await search(query, 1);
+ expect(first?.id).toBe("why");
+ }
+ });
+});
+
+describe("a decision record", () => {
+ const record = readdirSync("dist/why").find((name) => name.startsWith("0005-")) ?? "";
+ const html = readFileSync(`dist/why/${record}/index.html`, "utf8");
+
+ test("is out of the site's search and still open to search engines", () => {
+ expect(outOfSearch(`why/${record}`)).toBe(true);
+ expect(html).not.toContain("data-pagefind-body");
+ expect(html).not.toMatch(/]*noindex/);
+ const sitemap = readFileSync("dist/sitemap-0.xml", "utf8");
+ expect(sitemap).toContain(`/why/${record}/`);
+ });
+
+ test("the list of records and the changelog stay in the index", () => {
+ expect(outOfSearch("why")).toBe(false);
+ expect(outOfSearch("changelog")).toBe(false);
+ expect(readFileSync("dist/why/index.html", "utf8")).toContain("data-pagefind-body");
+ });
+});
diff --git a/test/self-run-steps.test.ts b/test/self-run-steps.test.ts
new file mode 100644
index 0000000..4a68c96
--- /dev/null
+++ b/test/self-run-steps.test.ts
@@ -0,0 +1,86 @@
+// Run the action yourself: the workflow file first, then the README's steps as numbered
+// headings, so a reader sees what they will add, how many steps there are, and can come back
+// to one. The README writes them as paragraphs that open on a bold sentence.
+
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { selfRunSteps } from "../src/lib/pages";
+
+const README = [
+ "**Check your setup.** Run the check. It needs nothing.",
+ "",
+ "**Before the first scan.** Two red rows are the ones new users met first.",
+ "",
+ "**Decide who may deploy.** A tick asks for a deploy.",
+ "",
+ "**Add the workflow.** This is the whole loop. Merge it once the steps above are in place.",
+ "",
+ "```yaml",
+ "name: deploy-dashboard",
+ "```",
+].join("\n");
+
+describe("selfRunSteps", () => {
+ const steps = selfRunSteps(README);
+
+ test("takes the workflow out of the last step", () => {
+ expect(steps.workflow).toBe("```yaml\nname: deploy-dashboard\n```");
+ });
+
+ test("numbers the steps, and keeps each one's words", () => {
+ expect(steps.steps).toEqual([
+ "### 1. Check your setup\n\nRun the check. It needs nothing.",
+ "### 2. Decide who may deploy\n\nA tick asks for a deploy.",
+ "### 3. Add the workflow\n\nThis is the whole loop. Merge it once the steps above are in place.",
+ ]);
+ });
+
+ test("hands back what goes wrong on a first scan apart, without a number", () => {
+ expect(steps.after).toBe(
+ "### When the first scan is red\n\nTwo red rows are the ones new users met first.",
+ );
+ });
+
+ test("stops when the README no longer has the shape it reads", () => {
+ expect(() => selfRunSteps("**Check your setup.** Run the check.")).toThrow("workflow");
+ expect(() => selfRunSteps(`A paragraph with no step.\n\n${README}`)).toThrow("bold");
+ expect(() => selfRunSteps(README.replace("Before the first scan", "Before you scan"))).toThrow(
+ "Before the first scan",
+ );
+ });
+});
+
+describe("the built page", () => {
+ const html = readFileSync("dist/get-started/run-the-action-yourself/index.html", "utf8");
+ const main = html.slice(html.indexOf(""));
+ const at = (id: string) => main.indexOf(` id="${id}"`);
+
+ test("shows the workflow file before Requirements and the steps", () => {
+ const file = main.indexOf("sluiceway/sluiceway@v0", at("the-workflow-file"));
+ expect(at("the-workflow-file")).toBeGreaterThan(-1);
+ expect(file).toBeGreaterThan(at("the-workflow-file"));
+ expect(file).toBeLessThan(at("requirements"));
+ expect(at("requirements")).toBeLessThan(at("get-started"));
+ // Once: the steps point at it and do not print it again.
+ expect(main.match(/ {
+ const order = [
+ "get-started",
+ "1-check-your-setup",
+ "2-decide-who-may-deploy",
+ "3-load-your-credentials",
+ "4-add-the-workflow",
+ "when-the-first-scan-is-red",
+ ].map(at);
+ expect(order.every((index) => index > -1)).toBe(true);
+ expect(order).toEqual([...order].sort((a, b) => a - b));
+ });
+
+ test("lists each step in On this page", () => {
+ for (const id of ["the-workflow-file", "1-check-your-setup", "4-add-the-workflow"]) {
+ expect(html).toContain(`href="#${id}"`);
+ }
+ });
+});