@norskvideo/ctl-dev-kit 0.2.54 → 0.2.56

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.
@@ -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
@@ -11,6 +11,8 @@
11
11
  // - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
12
12
  // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
13
13
  // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
14
+ // - publish-docs-site.yml -> verbatim; CREATED where the repo has a
15
+ // docs site and is not a turnkey
14
16
  // Structural per-repo files (root tsconfig.json, deployment/build-image.sh,
15
17
  // manifest.seed.json) have NO canonical bytes to apply — the gate checks their
16
18
  // shape, not their content — so they are left untouched.
@@ -30,6 +32,7 @@ import {
30
32
  CTL_VERSION_LINE,
31
33
  carriesIntegrationMarker,
32
34
  DOCS_SITE_DIR,
35
+ DOCS_SITE_WORKFLOW,
33
36
  END,
34
37
  GITIGNORE_BEGIN,
35
38
  GITIGNORE_END,
@@ -40,6 +43,7 @@ import {
40
43
  type ProductShape,
41
44
  PUBLISH_IMAGE_LINE,
42
45
  RUNNER_LINE,
46
+ resolveRepoName,
43
47
  resolveShape,
44
48
  splitAtMarkers,
45
49
  } from "./check-drift.ts";
@@ -49,6 +53,13 @@ export interface SyncReport {
49
53
  skipped: string[];
50
54
  }
51
55
 
56
+ export interface SyncDriftOptions {
57
+ /** The GitHub repo name (`norsk-ctl-product-probe`), which decides whether the
58
+ * docs site's publish may be CREATED here (never in a turnkey). The CLI
59
+ * resolves it; a fixture passes it. Unknown ⇒ nothing is created. */
60
+ repoName?: string;
61
+ }
62
+
52
63
  function writeIfChanged(path: string, content: string, rel: string, written: string[]): void {
53
64
  const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
54
65
  if (current !== content) {
@@ -267,6 +278,39 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
267
278
  chmodSync(path, 0o755);
268
279
  }
269
280
 
281
+ // publish-docs-site.yml: the docs SITE's publish. Verbatim (the project name is
282
+ // a repo variable, the credentials are secrets), so an existing copy is always
283
+ // re-derived whole -- the gate, not this writer, judges whether the repo may
284
+ // carry one. It is CREATED only where the gate would accept it: a repo with a
285
+ // site (there is something to publish), named, and not a turnkey (a turnkey has
286
+ // no public site, ADR-0012). That is how a product that grows a site acquires
287
+ // its publish: the next sync-dev-kit run installs it, green until the Pages
288
+ // project exists. A name this writer cannot determine creates nothing, since
289
+ // the gate could only refuse the result.
290
+ const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
291
+
292
+ function syncPublishDocsSite(repoRoot: string, canonical: string, repoName: string | undefined, r: SyncReport): void {
293
+ const path = join(repoRoot, DOCS_SITE_WORKFLOW);
294
+ if (existsSync(path)) {
295
+ writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written);
296
+ return;
297
+ }
298
+ if (!hasDocsSite(repoRoot)) {
299
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (no docs site to publish)`);
300
+ return;
301
+ }
302
+ if (repoName === undefined) {
303
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (cannot tell which repo this is; a turnkey may not carry it)`);
304
+ return;
305
+ }
306
+ if (TURNKEY_REPO.test(repoName)) {
307
+ r.skipped.push(`${DOCS_SITE_WORKFLOW} (${repoName} is a turnkey: no public site)`);
308
+ return;
309
+ }
310
+ mkdirSync(dirname(path), { recursive: true });
311
+ writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written);
312
+ }
313
+
270
314
  // The docs site's theme copies — the writer behind check-drift's docsThemeProblems.
271
315
  // Only for a repo that HAS a site: sync never creates one (a site is a nested
272
316
  // standalone package with its own install, not a file this writer can conjure),
@@ -286,7 +330,12 @@ function syncDocsTheme(repoRoot: string, r: SyncReport): void {
286
330
  for (const rel of adrift) r.written.push(`${DOCS_SITE_DIR}/${rel}`);
287
331
  }
288
332
 
289
- export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: ProductShape = "studio"): SyncReport {
333
+ export function syncDrift(
334
+ repoRoot: string,
335
+ canonical: CanonicalBytes,
336
+ shape: ProductShape = "studio",
337
+ opts: SyncDriftOptions = {},
338
+ ): SyncReport {
290
339
  const r: SyncReport = { written: [], skipped: [] };
291
340
  // Shape-selected canonicals: an sdk-app's flake + checks are its own variants
292
341
  // (the gate holds it to those). Everything else is shared across shapes.
@@ -349,6 +398,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
349
398
  syncDemoShim(repoRoot, canonical.demoShim, r);
350
399
  syncWithDisplay(repoRoot, canonical.withDisplay, r);
351
400
  syncDocsTheme(repoRoot, r);
401
+ syncPublishDocsSite(repoRoot, canonical.publishDocsSite, opts.repoName, r);
352
402
 
353
403
  return r;
354
404
  }
@@ -366,6 +416,7 @@ if (import.meta.main) {
366
416
  botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"),
367
417
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
368
418
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
419
+ publishDocsSite: readFileSync(join(dir, "publish-docs-site.yml"), "utf8"),
369
420
  withDisplay: readFileSync(join(dir, "..", "doc-guide", "with-display.sh"), "utf8"),
370
421
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
371
422
  biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
@@ -381,7 +432,7 @@ if (import.meta.main) {
381
432
  checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
382
433
  upgradeLatestSdkApp: readFileSync(join(dir, "upgrade-latest.sdk-app.yml"), "utf8"),
383
434
  };
384
- const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot));
435
+ const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) });
385
436
  if (report.written.length === 0) {
386
437
  console.log("sync-drift: all shared-convention copies already match @norskvideo/ctl-dev-kit.");
387
438
  } else {
@@ -307,10 +307,17 @@ ${SITE_DIR}/dist-public/
307
307
  ${SITE_DIR}/.astro/
308
308
  `;
309
309
 
310
+ /** Before either build: a placeholder page for every recorded guide the
311
+ * site symlinks from docs/generated whose capture has not run (dev-kit
312
+ * docs/guide-stubs.ts), so the site builds on a checkout with no captures --
313
+ * as ctl's and the Manager's do. A site that declares no recorded guides is
314
+ * a no-op. The publish workflow refuses to deploy a placeholder. */
315
+ const STUB_GUIDES = `bun run node_modules/@norskvideo/ctl-dev-kit/docs/guide-stubs.ts stub ${SITE_DIR}/src/content/docs/guides`;
316
+
310
317
  /** The root package.json scripts that drive it. Named the same in every repo
311
318
  * that has a site, including the two in the norsk-ctl monorepo. */
312
319
  export const DOCS_SITE_SCRIPTS: Record<string, string> = {
313
320
  "docs:dev": `bun run --cwd ${SITE_DIR} dev`,
314
- "docs:site": `bun run --cwd ${SITE_DIR} build`,
315
- "docs:site:public": `ASTRO_BUILD=public bun run --cwd ${SITE_DIR} build`,
321
+ "docs:site": `${STUB_GUIDES} && bun run --cwd ${SITE_DIR} build`,
322
+ "docs:site:public": `${STUB_GUIDES} && ASTRO_BUILD=public bun run --cwd ${SITE_DIR} build`,
316
323
  };
@@ -302,8 +302,9 @@ function rootPackageJson(ctx: ShapeContext): string {
302
302
  "test:unit": `bun test shared/src/ backend/src/ ${hasComponents ? "components/src/ " : ""}tests/unit/`,
303
303
  "test:image": "bun test tests/image/",
304
304
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
305
- "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts",
306
- "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/known-limitations.md`,
305
+ "docs:check":
306
+ "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/light-pages.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/journeys.ts",
307
+ "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/operating.md docs/known-limitations.md`,
307
308
  "docs:handover": "bash scripts/docs-handover.sh",
308
309
  "docs:handover:check": "bash scripts/docs-handover.sh --check",
309
310
  // The docs SITE (website/) — a standalone package, so its install is its
@@ -1896,8 +1897,8 @@ function exampleInputJson(_ctx: ShapeContext): string {
1896
1897
 
1897
1898
  // ---- The canonical doc set (fleet review 02 s9, backend-only turnkey) ------
1898
1899
  // README (developer), docs/README (the index), docs/handover (the customer's
1899
- // deployment document: prose around a block norsk-ctl generates), docs/known-
1900
- // limitations (operator). Prose only: a turnkey's bundle is built from these
1900
+ // deployment document: prose around a block norsk-ctl generates), docs/operating
1901
+ // and docs/known-limitations (operator). Prose only: a turnkey's bundle is built from these
1901
1902
  // files by docs:manual, which the image build runs, so the image serves them.
1902
1903
  // Bundle-internal file names are written without backticks on purpose: the
1903
1904
  // path-existence lint would look for them in the repo.
@@ -1961,7 +1962,8 @@ norsk-ctl and the pinned Norsk SDK:
1961
1962
  Two channels, deliberately:
1962
1963
 
1963
1964
  - **The bundle the image serves.** \`bun run docs:manual\` builds it from
1964
- \`docs/handover.md\` and \`docs/known-limitations.md\`; the image build runs
1965
+ \`docs/handover.md\`, \`docs/operating.md\` and \`docs/known-limitations.md\`;
1966
+ the image build runs
1965
1967
  it, so every image serves them at /docs at the version that is running
1966
1968
  (linked from the product's page in norsk-ctl). \`bun run docs:handover\`
1967
1969
  regenerates the handover's generated block from a daemon that has this
@@ -1985,7 +1987,8 @@ served at /docs at the version that is running.
1985
1987
 
1986
1988
  | Document | Audience | What it is |
1987
1989
  | ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
1988
- | \`docs/handover.md\` | the platform team deploying this product | Deploying from the exported bundle without norsk-ctl: every mount, environment and injected setting |
1990
+ | \`docs/handover.md\` | whoever gets this product running | How norsk-ctl arrives, the one command that adds this product, and the bundle route for platform teams |
1991
+ | \`docs/operating.md\` | operators | What a running deployment looks like from the operator's chair: the controls, the checks, the signs |
1989
1992
  | \`docs/known-limitations.md\` | operators | What the product does not do yet, with the consequence and the workaround |
1990
1993
 
1991
1994
  The **docs site** is a separate channel and lives in \`website/\`: the public
@@ -1995,18 +1998,84 @@ manual, on the shared Norsk theme, not built from these files. See
1995
1998
  \`bun run docs:manual\` builds the bundle from these files. The tables in the
1996
1999
  handover are generated by norsk-ctl (\`bun run docs:handover\`) — regenerate
1997
2000
  them, never edit them.
2001
+
2002
+ \`docs/personas.md\` is for writers, not readers, and is not in the bundle: it
2003
+ maps this product's audiences onto the four shared personas and carries the
2004
+ journey table \`bun run docs:check\` verifies.
2005
+ `;
2006
+ }
2007
+
2008
+ // The per-product persona mapping and the journey table the journeys lint
2009
+ // reads (conventions/personas.md). The routes name the pages the skeleton
2010
+ // ships; a product rewrites them as its pages arrive, and docs:check keeps
2011
+ // every named page real.
2012
+ function personasMd(ctx: ShapeContext): string {
2013
+ return `# Who ${ctx.name}'s documentation is for
2014
+
2015
+ The four personas are shared across every Norsk product and described in
2016
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/personas.md\`. This page maps
2017
+ ${ctx.name}'s own audiences onto them and says where each reader goes.
2018
+
2019
+ ## The mapping
2020
+
2021
+ <!-- ctl-docs-template: fill me -->
2022
+ _Name this product's audiences (the people who set it up, run it, and are
2023
+ invited into it) and say which of the four personas each one is. Runtime
2024
+ actors are not personas: an operator at the console is the Builder wearing
2025
+ the Administrator's hat._
2026
+
2027
+ ## The journey table
2028
+
2029
+ | Persona | Starts at | Then, in order | Hands off to |
2030
+ | ------------- | ------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
2031
+ | Evaluator | \`/docs/\` | \`/docs/guides/\` | [norsk-ctl's manual](https://ctl.norsk.video/), to run it for real |
2032
+ | Builder | \`/docs/guides/\` | \`/docs/how-to/\` | \`/docs/reference/\`, for the settings they just used |
2033
+ | Integrator | \`/docs/reference/\` | \`/docs/concepts/\` | [norsk-ctl's CLI and API reference](https://ctl.norsk.video/) |
2034
+ | Administrator | \`handover.md\` | \`operating.md\`, \`known-limitations.md\` | [norsk-ctl's manual](https://ctl.norsk.video/), for everything about the machine |
2035
+
2036
+ Site paths resolve under website/src/content/docs; a bare name resolves under
2037
+ docs/. Each route is at most five pages and ends in a hand-off, which is the
2038
+ one place "see norsk-ctl for details" lives.
1998
2039
  `;
1999
2040
  }
2000
2041
 
2001
2042
  function handoverMd(ctx: ShapeContext): string {
2002
- return `# Deploying ${ctx.name} without norsk-ctl
2043
+ return `# Deploying ${ctx.name}
2044
+
2045
+ How ${ctx.name} reaches you and how to get it running. Nearly every deployment
2046
+ goes through norsk-ctl, and that is the first two sections. The rest of this
2047
+ page is for a platform team running the exported bundle in its own
2048
+ orchestration instead.
2049
+
2050
+ ## How norsk-ctl arrives
2051
+
2052
+ \`\`\`sh
2053
+ curl -fsSL https://norsk.video/install/ctl | bash
2054
+ \`\`\`
2055
+
2056
+ When it finishes, norsk-ctl is running on the machine and its web page is where
2057
+ products are added, launched and watched. Everything about the machine itself
2058
+ is in [norsk-ctl's manual](https://ctl.norsk.video/): ports, TLS, cloud hosts,
2059
+ a box behind another proxy.
2060
+
2061
+ ## Add this product
2062
+
2063
+ \`\`\`sh
2064
+ norsk-ctl product add --image <the image you were given> --license-file <file>
2065
+ \`\`\`
2066
+
2067
+ One command. ${ctx.name} then appears on norsk-ctl's page, ready to launch;
2068
+ operating.md says what to do with it once it is running.
2069
+
2070
+ ## Deploying without norsk-ctl
2003
2071
 
2004
- What the platform team receives, and how to run it. The tables in the
2005
- generated block below come from norsk-ctl's own rendering of this product's
2006
- default template (\`bun run docs:handover\`), so they are the launch truth of
2007
- the version this document ships with. Regenerate them; never edit them.
2072
+ Some platform teams run the product from a bundle norsk-ctl exports, inside
2073
+ their own orchestration. The tables in the generated block below come from
2074
+ norsk-ctl's own rendering of this product's default template
2075
+ (\`bun run docs:handover\`), so they are the launch truth of the version this
2076
+ document ships with. Regenerate them; never edit them.
2008
2077
 
2009
- ## What you receive
2078
+ ### What you receive
2010
2079
 
2011
2080
  A portable bundle exported by norsk-ctl: compose.yml (the flat compose, every
2012
2081
  path relative), HANDOVER.md (the same tables as below), the working-directory
@@ -2017,13 +2086,13 @@ placeholder to replace with a production licence before the first start.
2017
2086
  _Not generated yet. Run \`bun run docs:handover\` against a daemon that has this product added._
2018
2087
  <!-- END generated -->
2019
2088
 
2020
- ## Verifying a deployment
2089
+ ### Verifying a deployment
2021
2090
 
2022
2091
  - The control plane answers on its port: GET /healthz returns ok.
2023
2092
  - The media engine is licensed: its log names the licence's product entry, not a placeholder.
2024
2093
  - The default template's outputs are reachable from where the audience is.
2025
2094
 
2026
- ## Not in this artefact
2095
+ ### Not in this artefact
2027
2096
 
2028
2097
  Anything norsk-ctl-specific: the daemon, product registration, the instance
2029
2098
  proxy and its authentication, templates-as-storage. The bundle is the launch
@@ -2031,6 +2100,42 @@ truth of one product template and needs none of them.
2031
2100
  `;
2032
2101
  }
2033
2102
 
2103
+ // The operator's page, in the shape of the plan's exemplar (reuters'
2104
+ // operating.md). Each section opens with a marker so a half-filled page is
2105
+ // grep-able, and the fill-me lines say what the exemplar put there.
2106
+ function operatingMd(ctx: ShapeContext): string {
2107
+ return `# Operating ${ctx.name}
2108
+
2109
+ What an operator does with a running ${ctx.name}, and where. For getting one
2110
+ running, see handover.md; this page assumes it is up.
2111
+
2112
+ ## What it is
2113
+
2114
+ <!-- ctl-docs-template: fill me -->
2115
+ _One paragraph: what the deployment takes in, what it puts out, and the one
2116
+ thing an operator adjusts while it runs._
2117
+
2118
+ ## Where the controls are
2119
+
2120
+ <!-- ctl-docs-template: fill me -->
2121
+ _Which screen the operator works in, and what the product's own page at /docs
2122
+ and its health check at /healthz are for._
2123
+
2124
+ ## Checking it is doing its job
2125
+
2126
+ <!-- ctl-docs-template: fill me -->
2127
+ 1. _The health check answers._
2128
+ 2. _Each input is arriving. A listening input with nothing connected is idle, not failed; the sender is the thing to check._
2129
+ 3. _Each output is connected to its destination._
2130
+
2131
+ ## When something looks wrong
2132
+
2133
+ <!-- ctl-docs-template: fill me -->
2134
+ - _The picture is absent: which input or output to look at first._
2135
+ - _The picture is wrong rather than absent: what is a runtime control and what needs a rebuilt template._
2136
+ `;
2137
+ }
2138
+
2034
2139
  const KNOWN_LIMITATIONS_MD = `# Known limitations
2035
2140
 
2036
2141
  What this product does not do yet, or does with a caveat an operator must know
@@ -2105,7 +2210,9 @@ export const turnkey: ShapeModule = {
2105
2210
  ...docsSiteFiles(ctx),
2106
2211
  { path: "docs/README.md", content: docsReadmeMd(ctx) },
2107
2212
  { path: "docs/handover.md", content: handoverMd(ctx) },
2213
+ { path: "docs/operating.md", content: operatingMd(ctx) },
2108
2214
  { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD },
2215
+ { path: "docs/personas.md", content: personasMd(ctx) },
2109
2216
  { path: ".github/workflows/build-docs.yml", content: ctx.canon.buildDocs },
2110
2217
  { path: "scripts/doc-guide/regen-manual.sh", content: REGEN_MANUAL_SH, executable: true },
2111
2218
  { path: "scripts/with-display.sh", content: ctx.canon.withDisplay, executable: true },