@norskvideo/ctl-dev-kit 0.2.54 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@
3
3
  // gated file uses the loosest mechanism that still pins what is shared:
4
4
  // - byte-verbatim: CLAUDE.md fenced core, nix/ctl-shell.nix, biome.json,
5
5
  // tsconfig.base.json, the .gitignore core block, scripts/demo (only a
6
- // product with tests/demo.spec.ts is asked for it)
6
+ // product with tests/demo.spec.ts is asked for it), publish-docs-site.yml
7
+ // (only a repo with a docs site may carry it, and never a turnkey)
7
8
  // - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
8
9
  // - base + sanctioned extension block: dprint.json (repo-specific excludes)
9
10
  // - must import the shared module: flake.nix (otherwise the product's own)
@@ -272,6 +273,10 @@ export interface CanonicalBytes {
272
273
  publishDocs: string;
273
274
  /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
274
275
  buildDocs: string;
276
+ /** conventions/publish-docs-site.yml — the docs SITE's publish (website/ ->
277
+ * Cloudflare Pages on every push to main). Verbatim; allowed only where
278
+ * there is a site to publish and never in a turnkey. */
279
+ publishDocsSite: string;
275
280
  checks: string;
276
281
  biome: string;
277
282
  tsconfigBase: string;
@@ -506,6 +511,10 @@ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = proce
506
511
  }
507
512
 
508
513
  const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/;
514
+ const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
515
+
516
+ /** The docs site's publish workflow, where a repo carries one. */
517
+ export const DOCS_SITE_WORKFLOW = ".github/workflows/publish-docs-site.yml";
509
518
 
510
519
  /** ADR-0012: exposure is declared by which docs workflow a repo carries, so the
511
520
  * gate is what makes that declaration safe — exactly one variant; the public
@@ -530,6 +539,37 @@ export function docsExposureProblems(hasPublish: boolean, hasBuild: boolean, rep
530
539
  return problems;
531
540
  }
532
541
 
542
+ /** Where a PUBLIC docs site may exist. The rule is negative on the repo name
543
+ * where publish-docs.yml's is positive, because sites live outside
544
+ * norsk-ctl-product-* (norsk-relay has one, and so do norsk-ctl and the
545
+ * Manager); what is certain is that a turnkey has none -- its documentation
546
+ * reaches one customer, inside the image they run (ADR-0012). And a site has
547
+ * to exist to be published: a repo without website/astro.config.mjs carrying
548
+ * the workflow would deploy nothing, or whatever a stale dist held. */
549
+ export function docsSiteExposureProblems(
550
+ hasWorkflow: boolean,
551
+ hasSite: boolean,
552
+ repoName: string | undefined,
553
+ ): string[] {
554
+ if (!hasWorkflow) return [];
555
+ const problems: string[] = [];
556
+ if (!hasSite) {
557
+ problems.push(
558
+ `${DOCS_SITE_WORKFLOW} publishes a docs site, but this repo has none (no ${DOCS_SITE_DIR}/astro.config.mjs). Add the site (conventions/docs-site.md) or delete the workflow.`,
559
+ );
560
+ }
561
+ if (repoName === undefined) {
562
+ problems.push(
563
+ `${DOCS_SITE_WORKFLOW} publishes this repo's docs site publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). A turnkey (norsk-ctl-turnkey-*) may not carry it.`,
564
+ );
565
+ } else if (TURNKEY_REPO.test(repoName)) {
566
+ problems.push(
567
+ `${DOCS_SITE_WORKFLOW} publishes a public docs site, but this repo is ${repoName}, a turnkey. A turnkey has no public site: its documentation reaches one customer, inside the image they run (ADR-0012). Delete the workflow.`,
568
+ );
569
+ }
570
+ return problems;
571
+ }
572
+
533
573
  export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: CheckDriftOptions = {}): DriftReport {
534
574
  const problems: string[] = [];
535
575
  const push = (problem: string | undefined) => {
@@ -808,6 +848,20 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
808
848
  // checked, and one clause covers both.
809
849
  for (const p of docsThemeProblems(repoRoot, opts.themeCanonical)) push(p);
810
850
 
851
+ // publish-docs-site.yml: the site's publish, verbatim (no per-repo line: the
852
+ // project name is a repo variable, the credentials are secrets), and only
853
+ // where docsSiteExposureProblems allows a public site at all.
854
+ const siteWorkflowPath = join(repoRoot, DOCS_SITE_WORKFLOW);
855
+ if (existsSync(siteWorkflowPath)) {
856
+ const actual = readFileSync(siteWorkflowPath, "utf8");
857
+ if (actual !== canonical.publishDocsSite) {
858
+ push(
859
+ `${DOCS_SITE_WORKFLOW} has drifted from @norskvideo/ctl-dev-kit conventions/publish-docs-site.yml (${firstDiffLine(actual, canonical.publishDocsSite)}). ${RESYNC}`,
860
+ );
861
+ }
862
+ }
863
+ for (const p of docsSiteExposureProblems(existsSync(siteWorkflowPath), hasDocsSite(repoRoot), opts.repoName)) push(p);
864
+
811
865
  for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
812
866
  for (const p of productLogsProblems(repoRoot)) push(p);
813
867
  for (const p of ctlPinProblems(repoRoot)) push(p);
@@ -827,6 +881,7 @@ if (import.meta.main) {
827
881
  botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"),
828
882
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
829
883
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
884
+ publishDocsSite: readFileSync(join(import.meta.dir, "publish-docs-site.yml"), "utf8"),
830
885
  withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"),
831
886
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
832
887
  biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
@@ -8,7 +8,7 @@ Like [`docs.md`](./docs.md) and [`personas.md`](./personas.md) this is a **shipp
8
8
  reference**, not a drift-gated copy: read it from
9
9
  `node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md`. What IS gated is
10
10
  the theme itself — see "The theme is a copy, and the gate is what makes that
11
- safe" below.
11
+ safe" below — and the publish workflow, see "Publishing".
12
12
 
13
13
  **`create-product` renders all of it.** A repo generated by
14
14
  `bunx @norskvideo/ctl-dev-kit create-product` comes out with a `website/` that
@@ -78,10 +78,24 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
78
78
 
79
79
  - **Start here** — the Evaluator. What this does, and where to go next.
80
80
  - **Guides** — the Builder. One task, start to finish, in the order it is done.
81
- These are the pages worth **capturing** rather than illustrating; see
82
- [`docs.md`](./docs.md) for the toolchain and the `capturedAgainst` front matter
83
- the theme renders at the foot of such a page.
81
+ **A Guides page is the recorded chapter where one exists**: a symlink from
82
+ `website/src/content/docs/guides/<slug>` into the `docs/generated/<slug>`
83
+ tree a doc-guide writes, with at most a prose introduction above it. That
84
+ is what the Manager's site does for every guide and what makes the page
85
+ unable to describe a screen that does not exist; see [`docs.md`](./docs.md)
86
+ for the toolchain, the `capturedAgainst` front matter the theme renders at
87
+ the foot of such a page, and the placeholder the dev-kit writes so the site
88
+ builds on a checkout with no captures. **When a product has more than one
89
+ surface** (a console and an API, or a CLI as well), show the same task on
90
+ each: `<task>`, `<task>-ui`, `<task>-api`, grouped under the task in the
91
+ sidebar, as the Manager does for launching a show format, draining a node
92
+ and API tokens. Not every task needs every surface; the first task on the
93
+ Builder's route should have them all.
84
94
  - **How-to** — the Administrator. Looked up, repeatedly, while running it.
95
+ Its first page, **"What this product adds to the host"** (images, ports,
96
+ volumes, GPU, state), is generated from the manifest and the product
97
+ template, where those facts already live, and never hand-written; the
98
+ rest of the section ends with a link into norsk-ctl's manual.
85
99
  - **Concepts** — the vocabulary the rest of the manual uses. Not architecture.
86
100
  - **Reference** — the Integrator. Exhaustive and generated from the source of
87
101
  truth where possible.
@@ -89,6 +103,16 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
89
103
  Add sections below these if a product genuinely needs them. Do not rename or
90
104
  reorder these five: the shape is the shared part.
91
105
 
106
+ **Start here and Guides are the light half**, written for readers who do not
107
+ know our engineering vocabulary: one command per step, no flags, the result
108
+ before the prerequisites, a single "Next" link at the foot of each page, and a
109
+ word budget. `docs/light-pages.ts` lints those pages (see `docs.md`, "Voice").
110
+ How-to and Reference may be as exhaustive as the Administrator and the
111
+ Integrator want. The install section of Start here is the theme's
112
+ `components/InstallProduct.astro`: the product's one-line install, one sentence
113
+ on what it leaves behind, and one link into norsk-ctl's manual. It is repeated
114
+ on every product site on purpose and generated so the wording cannot drift.
115
+
92
116
  ## The theme is a copy, and the gate is what makes that safe
93
117
 
94
118
  The theme reaches a site as **files copied into it**, not as an import, and
@@ -138,6 +162,27 @@ Astro derives a component's scoped-style class from its path, so moving
138
162
  Then `bun install --cwd website` (the repo-root install does NOT cover a
139
163
  standalone package) and `bun run docs:site`.
140
164
 
165
+ ### Adding a recorded guide
166
+
167
+ Two lines. A symlink from the site's Guides into the tree the doc-guide
168
+ writes, and a sidebar entry:
169
+
170
+ ```sh
171
+ ln -s ../../../../../docs/generated/<slug> website/src/content/docs/guides/<slug>
172
+ ```
173
+
174
+ ```js
175
+ { label: "<Title>", slug: "guides/<slug>" }
176
+ ```
177
+
178
+ `docs:site` runs the dev-kit's `docs/guide-stubs.ts stub` first, so the site
179
+ builds on a checkout that has never captured; `publish-docs-site.yml` runs
180
+ `guide-stubs.ts check` after the build and refuses to deploy while any guide
181
+ is still a placeholder. That refusal is deliberate: a product that declares
182
+ recorded guides must also restore (or regenerate) its captures in that
183
+ workflow before the build, and until it does, the publish stops rather than
184
+ shipping an empty page.
185
+
141
186
  ## The two builds
142
187
 
143
188
  One content collection, two outputs, selected by `ASTRO_BUILD`:
@@ -159,22 +204,56 @@ of compressed docs on a ~68 MB binary.
159
204
 
160
205
  ## Publishing
161
206
 
162
- Per site, and none of it is needed to build or review one locally:
207
+ The site publishes from **`.github/workflows/publish-docs-site.yml`**,
208
+ single-sourced in this package (`conventions/publish-docs-site.yml`) and
209
+ **drift-gated** the way the bundle's `publish-docs.yml` is: `bun run check:drift`
210
+ holds the copy byte-identical, and `bun run sync:drift` writes it — creating it
211
+ where the repo has a site and is not a turnkey. On every push to `main` (and on
212
+ demand) it installs `website/`, builds the public variant and deploys
213
+ `website/dist-public` to Cloudflare Pages with `wrangler`. It never cancels a
214
+ run in flight, because a cancelled deploy is a half-published site. It restores
215
+ no capture cache: a product site builds from the committed prose, so there is
216
+ nothing a nightly run would have that a push does not.
217
+
218
+ Where it may exist is the gate's business (ADR-0012, and the documentation
219
+ plan): a repo with `website/astro.config.mjs` may carry it; a repo without a
220
+ site may not, since there is nothing to publish; a turnkey
221
+ (`norsk-ctl-turnkey-*`) may not, since a turnkey has no public site. The rule
222
+ is negative on the repo name where `publish-docs.yml`'s is positive, because
223
+ sites exist outside `norsk-ctl-product-*` — Relay has one.
224
+
225
+ What the repo needs, per site, and none of it to build or review one locally:
163
226
 
164
227
  1. **A Cloudflare Pages project**, e.g. `norsk-acme-docs`.
165
228
  2. **A custom domain** on it — `<product>.norsk.video` — and the DNS record.
166
- 3. **API token scope.** The existing `CLOUDFLARE_API_TOKEN` is deliberately narrow
167
- (Pages:Edit). It must cover the new project: widen it to all Pages projects in
168
- the account, or issue a second token.
169
- 4. **A publish step** that runs the public build and `wrangler pages deploy`. Take
170
- the project name from a repo variable rather than a literal, and skip the
171
- deploy with a message when it is unset — so main is green before Cloudflare
172
- exists and publishes the day the variable is set.
173
-
174
- There is no shared `publish-docs-site.yml` in this package yet: the two existing
175
- sites publish from norsk-ctl's own monorepo workflow. The first product repo to
176
- publish one should bring the workflow back here and drift-gate it, the way
177
- `publish-docs.yml` (the bundle's) already is.
229
+ 3. **Two repo secrets.** `CLOUDFLARE_API_TOKEN`, a Pages:Edit token — the
230
+ existing one is deliberately narrow and must cover the new project: widen it
231
+ to all Pages projects in the account, or issue a second token — and
232
+ `CLOUDFLARE_ACCOUNT_ID`. The workflow verifies the token at the API (both the
233
+ user-owned and the account-owned endpoint), never with `wrangler whoami`,
234
+ which a Pages:Edit token cannot answer.
235
+ 4. **One repo variable.** `NORSK_DOCS_SITE_PROJECT`, the project name from
236
+ step 1. It has **no default**: unset, the workflow still builds the site,
237
+ prints exactly these steps and exits 0 — so `main` is green before
238
+ Cloudflare exists and publishes the day the variable is set, with no change
239
+ to the workflow.
240
+
241
+ A `workflow_dispatch` from a branch other than `main` deploys a **preview** of
242
+ that branch (`--branch` is the ref that ran), never over production.
243
+
244
+ ### Wiring it into a repo that has a site
245
+
246
+ Two steps, once a dev-kit carrying the workflow is published:
247
+
248
+ ```
249
+ bun update @norskvideo/ctl-dev-kit # bump the pin (or let sync-dev-kit.yml do it)
250
+ bun run sync:drift # writes .github/workflows/publish-docs-site.yml
251
+ ```
252
+
253
+ Commit the result; `bun run check:drift` passes. The nightly `sync-dev-kit.yml`
254
+ does both hands-free, so a product with a site acquires the workflow on its
255
+ next sync without anyone touching the repo. Then create the Pages project, set
256
+ the two secrets and the variable, and the next push to `main` publishes.
178
257
 
179
258
  ## Search does not cross products
180
259
 
@@ -52,6 +52,14 @@ product's manual in one voice:
52
52
  - **No internal/test vocabulary.** Never `engine`, `fixture`, `Playwright`,
53
53
  `assertion`, `harness`, `instance`, `SSR`, a slug, or a DOM id in reader-facing
54
54
  copy. The reader does not know or care how the shot was produced.
55
+ **Scoped and checked (October 2026):** the rule applies to the light half of
56
+ a manual, which is a site's Start here pages (`index`, `quickstart`) and its
57
+ `guides/`, plus a repo's `docs/quickstart.md`. `docs/light-pages.ts` in this
58
+ package lints exactly those pages for the deny list and a word budget, skipping
59
+ code, comments and imports, and runs from `bun run docs:check`. A repo whose
60
+ own vocabulary is on the list allows the word (`--allow`); `instance` is not
61
+ on it, because it is the label on norsk-ctl's own screens. How-to and
62
+ Reference may use any of it.
55
63
  - **Lead with the capability, not the UI state.** "A commentator joins from a
56
64
  browser with a link" — not "the connections page shows a slot in the pending
57
65
  state."
@@ -89,11 +97,44 @@ Per-step treatments (`CaptureTreatment`, from the doc-guide library):
89
97
  `live` on the first decoded frame, or `error` with the reason in
90
98
  `data-moq-error`. The player mounts at the moment the source connects, which
91
99
  is also when a guide's "it's running" assertion passes — so a capture taken
92
- then is of a spinner, and a sleep only hides a preview that is broken. Name
93
- the tile here, or call `awaitLivePicture(page, selector)` before the capture
94
- (the verify run skips captures, so an explicit wait is also the assertion);
95
- either way a tile that never shows a picture fails the guide, naming the
96
- phase it was stuck in and the player's own error.
100
+ then is of a spinner, and a sleep only hides a preview that is broken. The
101
+ phase alone is not enough, though: Commentary's standby slate and its "Enter
102
+ the booth" modal both passed it (2026-10-07), because the player underneath
103
+ was live. So the gate judges the visible composite as well: the element at
104
+ the tile's centre (`document.elementFromPoint`) must be the tile or inside
105
+ it, nothing matching the slate/modal selectors (`[data-slate]`,
106
+ `[role="dialog"]`, `.modal`, or visible text matching
107
+ `/temporarily unavailable|standby/i`) may be visible inside it, and two
108
+ 32x18 luma samples of its video or canvas taken 500 ms apart must differ —
109
+ a slate, however bright, is still. Name the tile here, or call
110
+ `awaitLivePicture(page, selector, gate?)` before the capture (the verify run
111
+ skips captures, so an explicit wait is also the assertion); either way a
112
+ tile that never shows a picture fails the guide, naming the check it never
113
+ passed. Add a product's own slate markers with `livePictureGate.
114
+ occluderSelectors`. A tile with no video or canvas in it (an audio-only
115
+ player) cannot pass, so do not name one.
116
+
117
+ **Captures are shot at 2x in a `DOCS_GENERATE` run.** `guidesConfig` defaults
118
+ `deviceScaleFactor` from `captureScaleFactor()`: 2 when `DOCS_GENERATE=1`, 1
119
+ otherwise, and `launchLiveBrowser` (the engine tier) reads the same default.
120
+ The video spike measured the manual's component crops at 383 to 778 px wide;
121
+ blown up to sit in a 1080p frame they were legible but soft, and no renderer
122
+ step recovers detail that was never captured. A plain run of a guide writes no
123
+ captures, so it stays at 1x and nothing else about it changes; pass
124
+ `deviceScaleFactor` explicitly to pin either. Expect the PNGs to grow with the
125
+ pixel count.
126
+
127
+ ### When a guide runs
128
+
129
+ A fixture-tier guide costs seconds and needs no licence, so it runs in the
130
+ **PR check** (`checks.yml`), where a broken capture is found by its author;
131
+ the engine tier runs **nightly** in the docs workflow, where a licence and a
132
+ real launch are affordable. A site is never hostage to a capture: on a
133
+ checkout with no `docs/generated`, the dev-kit's `docs/guide-stubs.ts stub`
134
+ writes a placeholder page for every guide the site's symlinks name, so
135
+ `docs:site` builds, and `docs/guide-stubs.ts check` **refuses to publish** a
136
+ placeholder, in every publish workflow the dev-kit ships. The placeholder
137
+ sentence is the marker the gate matches on; do not write it by hand.
97
138
 
98
139
  ## The toolchain
99
140
 
@@ -103,6 +144,12 @@ they do not copy it:
103
144
  - `@norskvideo/ctl-dev-kit/doc-guide` — `DocGuide` (a guide narrates itself into
104
145
  `docs/generated/<slug>/`), `screenshotFor`, `CaptureTreatment`. Writes under
105
146
  `NORSK_DOCS_ROOT` (the repo-level `docs/generated`); the config factory sets it.
147
+ Step text is also the video narration, so in a `DOCS_GENERATE` run `step()`
148
+ (heading plus description) and `text()` fail the guide over 50 spoken words,
149
+ on a raw URL (say "the address"), or on a slash between words (write "or" or
150
+ "and"); the failure names the guide, the step and the count. `captionWords`
151
+ on `guidesConfig` or on the `DocGuide` options tightens the budget. A plain
152
+ run is unchanged.
106
153
  - `@norskvideo/ctl-dev-kit/doc-guide/guides-config` — `guidesConfig({ port,
107
154
  docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
108
155
  the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
@@ -131,3 +131,28 @@ Reconcile them like this:
131
131
  Map each product's named audiences (from its `PRODUCT.md`) onto the four
132
132
  personas above; don't invent a parallel scheme. Keep the per-product mapping in
133
133
  the product's `docs/personas.md`.
134
+
135
+ ## The journey table
136
+
137
+ A persona is who you write for; a journey is where they go. Each product's
138
+ `docs/personas.md` carries one table, in this shape, and `docs/journeys.ts` in
139
+ this package checks it from `bun run docs:check`: every persona has one row,
140
+ every page it names exists, no route is longer than five pages, and every route
141
+ ends in a hand-off.
142
+
143
+ | Persona | Starts at | Then, in order | Hands off to |
144
+ | ------------- | ----------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
145
+ | Evaluator | `/docs/` | `/docs/quickstart/`, `/docs/guides/gallery/` | Guides (build with it) or ctl (run it for real) |
146
+ | Builder | `/docs/guides/first-…/` | the task pages, `/docs/guides/troubleshooting/` | Reference, for the settings they just used |
147
+ | Integrator | `/docs/reference/` | config, CLI subtree, API, recipes | norsk-ctl's CLI and API reference |
148
+ | Administrator | `/docs/how-to/` | what this product adds to the host, known limitations | norsk-ctl's manual, for everything about the machine |
149
+
150
+ The Integrator's route may start at a recorded API walkthrough (the `-api`
151
+ variant of the Builder's first task) rather than at Reference; the Manager's
152
+ "API tokens, HTTP API" is the pattern. Reference stays where it ends.
153
+
154
+ Site paths are `/docs/...` (the embedded base; the public build strips it) and
155
+ resolve to `website/src/content/docs`; a bare `name.md` resolves under `docs/`.
156
+ The hand-off is where "see norsk-ctl for details" lives, once per route, so no
157
+ page needs the caveat sprinkled through it. A repo with no site and no
158
+ `docs/quickstart.md` (a backend-only turnkey) is not asked for the table.
@@ -0,0 +1,247 @@
1
+ # Publish this product's DOCS SITE -- the public manual at <product>.norsk.video
2
+ # (website/, an Astro + Starlight site on the shared Norsk theme) -- to
3
+ # Cloudflare Pages on every push to main.
4
+ #
5
+ # This is the SITE channel, not the bundle: publish-docs.yml / build-docs.yml
6
+ # regenerate the illustrated manual the product image serves at /docs, nightly,
7
+ # from a licensed Docker run. The site builds from the committed prose alone,
8
+ # needs no licence and no Docker, and so rides every push (conventions/
9
+ # docs-site.md, "Two documentation channels"). A site that also symlinks
10
+ # RECORDED guides (website/src/content/docs/guides/<slug> -> docs/generated/<slug>)
11
+ # builds against a placeholder when the capture is absent, and the gate below
12
+ # refuses to deploy one.
13
+ #
14
+ # This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
15
+ # (conventions/publish-docs-site.yml) and copied verbatim: there is no per-repo
16
+ # line. Everything per-site lives in the repo -- website/astro.config.mjs, the
17
+ # root `docs:site:public` script -- or in the repo's settings:
18
+ #
19
+ # variable NORSK_DOCS_SITE_PROJECT the Cloudflare Pages project name.
20
+ # NO DEFAULT: unset means the project does
21
+ # not exist yet, and the deploy step says
22
+ # what to create and exits 0. So main is
23
+ # green before Cloudflare exists, and the
24
+ # site publishes the day the variable is
25
+ # set, with no change here.
26
+ # secret CLOUDFLARE_API_TOKEN a Pages:Edit token covering that project
27
+ # secret CLOUDFLARE_ACCOUNT_ID the account the project lives in
28
+ #
29
+ # The site is BUILT whether or not it will be deployed: if the build is only
30
+ # exercised on the day the Pages project appears, that day is when we find out
31
+ # it was broken.
32
+ #
33
+ # The drift gate (check-drift.ts) holds the copy byte-identical, allows it only
34
+ # in a repo that has a site (website/astro.config.mjs), and refuses it in a
35
+ # turnkey (norsk-ctl-turnkey-*): a turnkey has no public site, its documentation
36
+ # reaches one customer inside the image they run (ADR-0012).
37
+ name: publish-docs-site
38
+
39
+ on:
40
+ push:
41
+ branches: [main]
42
+ workflow_dispatch:
43
+
44
+ permissions:
45
+ contents: read
46
+
47
+ # Never cancel in flight: a cancelled `wrangler pages deploy` is a half-published
48
+ # site, and the next push rebuilds from the same inputs anyway. Queue instead.
49
+ concurrency:
50
+ group: publish-docs-site
51
+ cancel-in-progress: false
52
+
53
+ jobs:
54
+ publish:
55
+ runs-on: x64
56
+ steps:
57
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
58
+ # out of the workspace for every later step in this job, and clears whatever
59
+ # the old repo-local default left behind on this runner. The body carries the
60
+ # why. It fails the job only when legacy litter survives -- which would take
61
+ # the checkout down a step later anyway, but report nothing useful.
62
+ - name: Route test-temp out of the workspace (pre-checkout)
63
+ run: |
64
+ set -uo pipefail
65
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
66
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
67
+ # host -- even when the launch passes --container-user (reproduced
68
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
69
+ # inotify constraint that binds macOS dev and nothing here, so every run
70
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
71
+ # cannot remove. That took commentary's checks AND integration red on
72
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
73
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
74
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
75
+ mkdir -p "$base"
76
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
77
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
78
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
79
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
80
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
81
+ done
82
+ # LEGACY: a runner that ran the old default still carries a workspace
83
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
84
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
85
+ # RESTARTING leaked container re-creates the path as root seconds after
86
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
87
+ # by construction, because a killed run labels nothing.
88
+ tt="$GITHUB_WORKSPACE/test-temp"
89
+ [ -d "$tt" ] || exit 0
90
+ stuck=""
91
+ for c in $(docker ps -aq 2>/dev/null || true); do
92
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
93
+ stuck="$stuck $c"
94
+ fi
95
+ done
96
+ if [ -n "$stuck" ]; then
97
+ echo "reaping containers holding mounts under $tt:$stuck"
98
+ docker rm -f $stuck || true
99
+ fi
100
+ # The DIRECTORY, not just its contents: an empty but root-owned base
101
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
102
+ # container unlink the leaf.
103
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
104
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
105
+ # Fail HERE if it survived. The checkout fails on it either way, but
106
+ # reports only an EACCES rmdir with no clue what was holding the path.
107
+ if [ -e "$tt" ]; then
108
+ echo "::error::$tt survived the pre-checkout clean"
109
+ ls -lan "$tt" || true
110
+ exit 1
111
+ fi
112
+
113
+ - uses: actions/checkout@v5
114
+ with:
115
+ clean: true
116
+
117
+ # website/ is a STANDALONE package -- its own bun.lock, outside the root
118
+ # workspaces -- so the repo-root install never covers it, and the build
119
+ # needs both: the root for @norskvideo/ctl-dev-kit (docs:site:public stubs
120
+ # the recorded guides through its docs/guide-stubs.ts, and the gate below
121
+ # reads them the same way), website/ for Astro. Frozen, as every CI
122
+ # install is: a lockfile the build rewrites is drift nobody sees. The
123
+ # public variant (ASTRO_BUILD=public) is base /; the embedded one is base
124
+ # /docs, and every link in it is wrong on the public domain -- which is
125
+ # what the grep at the end catches.
126
+ - name: Build the public docs site
127
+ run: |
128
+ nix develop .#build --command bash -c '
129
+ set -euo pipefail
130
+ bun install --frozen-lockfile
131
+ bun install --frozen-lockfile --cwd website
132
+ bun run docs:site:public
133
+ dist="website/dist-public"
134
+ [ -s "$dist/index.html" ] || { echo "::error::$dist/index.html was not built"; exit 1; }
135
+ if grep -q "href=./docs/" "$dist/index.html"; then
136
+ echo "::error::$dist is the EMBEDDED variant (links are /docs-prefixed); docs:site:public must set ASTRO_BUILD=public"
137
+ exit 1
138
+ fi
139
+ echo "built $dist ($(find "$dist" -name "*.html" | wc -l) pages)"
140
+ '
141
+
142
+ # A recorded guide whose capture is not on this runner is a placeholder
143
+ # page after the build above (docs:site:public stubs it so the build
144
+ # works). Right for a local build, wrong for a deploy: the live page would
145
+ # be replaced with "run the doc-guide locally", and nothing else would
146
+ # notice -- the build is green, the links resolve. --content reads the
147
+ # guides the site declares by symlink, so a site that declares none has
148
+ # nothing to refuse; one that does needs its captures on the runner, and
149
+ # until the repo restores them this step says which pages are missing.
150
+ - name: Refuse to publish placeholder guides
151
+ run: |
152
+ nix develop .#build --command bash -c '
153
+ set -euo pipefail
154
+ bun run node_modules/@norskvideo/ctl-dev-kit/docs/guide-stubs.ts check --content website/src/content/docs/guides
155
+ '
156
+
157
+ # Skipped -- loudly, and successfully -- while NORSK_DOCS_SITE_PROJECT is
158
+ # unset: the Pages project and the DNS record are the account owner's to
159
+ # create, and main must not go red waiting for them. Setting the variable
160
+ # is the whole switch-on.
161
+ #
162
+ # The token is verified at the API, not with `wrangler whoami`: whoami
163
+ # needs User Details:Read, which a Pages:Edit token deliberately lacks. Two
164
+ # kinds of token verify at two endpoints: a USER-owned token (My Profile ->
165
+ # API Tokens) answers /user/tokens/verify; an ACCOUNT-owned one (Manage
166
+ # Account -> API Tokens) returns 1000 "Invalid API Token" there however
167
+ # valid it is, and answers /accounts/<id>/tokens/verify. Checking only the
168
+ # first once made a valid team-owned token read like a bad secret. curl is
169
+ # on the host PATH; wrangler is not in the shared dev shell, so bunx fetches
170
+ # it inside `nix develop`, where bun is.
171
+ #
172
+ # --branch is the ref that pushed: main is the production branch of the
173
+ # Pages project, so a push to main publishes; a workflow_dispatch from any
174
+ # other branch publishes a PREVIEW deployment of that branch, never over
175
+ # production.
176
+ - name: Deploy to Cloudflare Pages
177
+ env:
178
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
179
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
180
+ NORSK_DOCS_SITE_PROJECT: ${{ vars.NORSK_DOCS_SITE_PROJECT }}
181
+ PAGES_BRANCH: ${{ github.ref_name }}
182
+ run: |
183
+ set -euo pipefail
184
+ if [ -z "${NORSK_DOCS_SITE_PROJECT:-}" ]; then
185
+ origin="$(sed -nE 's/.*PUBLIC_SITE_ORIGIN = "([^"]+)".*/\1/p' website/src/lib/site.ts | head -1)"
186
+ echo "── Docs site → SKIPPED (no Pages project configured) ──"
187
+ echo " The site built (website/dist-public) but NORSK_DOCS_SITE_PROJECT is unset, so"
188
+ echo " nothing is deployed. To turn publishing on:"
189
+ echo " 1. Create a Cloudflare Pages project for this site (e.g. $(basename "$GITHUB_REPOSITORY")-docs)."
190
+ echo " 2. Add its custom domain${origin:+ (${origin#https://})} and the DNS record."
191
+ echo " 3. Make sure CLOUDFLARE_API_TOKEN (Pages:Edit) covers that project, and that"
192
+ echo " CLOUDFLARE_ACCOUNT_ID is set as a repo secret."
193
+ echo " 4. Set the repo variable NORSK_DOCS_SITE_PROJECT to the project name."
194
+ echo " Nothing in this workflow changes. See @norskvideo/ctl-dev-kit conventions/docs-site.md, Publishing."
195
+ exit 0
196
+ fi
197
+ if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
198
+ echo "::error::NORSK_DOCS_SITE_PROJECT=$NORSK_DOCS_SITE_PROJECT is set, but CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must both be repo secrets"
199
+ exit 1
200
+ fi
201
+ cf_verify() {
202
+ curl -fsS --retry 3 --retry-all-errors --retry-delay 2 \
203
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
204
+ "https://api.cloudflare.com/client/v4/$1" 2>&1
205
+ }
206
+ if verify_body=$(cf_verify "user/tokens/verify"); then
207
+ token_kind="user-owned"
208
+ elif verify_body=$(cf_verify "accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify"); then
209
+ token_kind="account-owned"
210
+ else
211
+ echo "::error::Cloudflare token verification failed at both /user/tokens/verify and /accounts/<id>/tokens/verify. Last reply: $verify_body"
212
+ exit 1
213
+ fi
214
+ if ! echo "$verify_body" | grep -q '"status":"active"'; then
215
+ echo "::error::Cloudflare token not active ($token_kind): $verify_body"
216
+ exit 1
217
+ fi
218
+ # The token id is an identifier, not a credential: it answers "which of
219
+ # our tokens is CI using?", which a write-only secret otherwise cannot.
220
+ token_id=$(echo "$verify_body" | sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p' | head -1)
221
+ echo "Cloudflare token: active ($token_kind${token_id:+, id ${token_id}})"
222
+ echo "── Docs site → Cloudflare Pages ($NORSK_DOCS_SITE_PROJECT, branch $PAGES_BRANCH) ──"
223
+ nix develop .#build --command bash -c '
224
+ set -euo pipefail
225
+ bunx wrangler@4 pages deploy website/dist-public \
226
+ --project-name "$NORSK_DOCS_SITE_PROJECT" \
227
+ --branch "$PAGES_BRANCH" \
228
+ --commit-dirty=true
229
+ '
230
+ - name: Reap the containers this job launched
231
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
232
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
233
+ # conventions/workflow-shell.ts); everything this job launched mounts its
234
+ # own test-temp, so that is what goes.
235
+ if: always()
236
+ run: |
237
+ base="${NORSK_CTL_TEST_TMP:-}"
238
+ tt="$GITHUB_WORKSPACE/test-temp"
239
+ s=""
240
+ for c in $(docker ps -aq); do
241
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
242
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
243
+ done
244
+ if [ -n "$s" ]; then
245
+ echo "::warning::removing containers this job left running:$s"
246
+ docker rm -f $s
247
+ fi