@norskvideo/ctl-dev-kit 0.1.81 → 0.1.83

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.
@@ -82,6 +82,17 @@ Per-step treatments (`CaptureTreatment`, from the doc-guide library):
82
82
  component renders, not on incidental structure.
83
83
  - **`spotlight`** — the whole screen with one region lit and everything else
84
84
  dimmed, for "look here, in context."
85
+ - **`livePicture`** — a live video tile the shot must not be taken without.
86
+ Studio's shared MoQ player (`norsk-studio-built-ins/lib/shared/moq-player`)
87
+ stamps `data-moq-phase` on the container it mounts into: `connecting`, then
88
+ `live` on the first decoded frame, or `error` with the reason in
89
+ `data-moq-error`. The player mounts at the moment the source connects, which
90
+ is also when a guide's "it's running" assertion passes — so a capture taken
91
+ then is of a spinner, and a sleep only hides a preview that is broken. Name
92
+ the tile here, or call `awaitLivePicture(page, selector)` before the capture
93
+ (the verify run skips captures, so an explicit wait is also the assertion);
94
+ either way a tile that never shows a picture fails the guide, naming the
95
+ phase it was stuck in and the player's own error.
85
96
 
86
97
  ## The toolchain
87
98
 
@@ -138,13 +149,24 @@ single-sourced here and drift-gated:
138
149
  image as the proof. No upload, no write permission on the repo, no token. A
139
150
  turnkey (`norsk-ctl-turnkey-*`) carries this and nothing else: its
140
151
  documentation reaches exactly one customer, inside the image they already run.
141
- - **`publish-docs.yml` — public.** The same build, then `manual.html` is
142
- published as the asset of the moving `docs-latest` GitHub Release
143
- (`https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`)
144
- for norsk-ctl's docs site to fetch and mount at `/products/<slug>/`. Versioned
145
- bundle assets behind a `latest` pointer, and ingestion of `pages/*.md` into the
146
- site, come with the public-plane work. **Only a repo GitHub calls
147
- `norsk-ctl-product-*` may carry it.** `check:drift` refuses it anywhere else,
152
+ - **`publish-docs.yml` — public.** The same build, then three things on one
153
+ moving `docs-latest` GitHub Release:
154
+ - `manual.html`, clobbered each night — what norsk-ctl's docs site fetches and
155
+ mounts at `/products/<slug>/`
156
+ (`https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`).
157
+ - `bundle-<version>.tar.gz`, the whole bundle, named by the product commit it
158
+ was built from, plus `latest.json` naming the current one. Versioned assets
159
+ on ONE release rather than a release per commit: a release per commit would
160
+ leave hundreds of prereleases on the repo, and a single clobbered asset would
161
+ leave nothing to roll back to. Only the pointer moves, so a rollback is a
162
+ pointer edit and the site never reads an asset being overwritten underneath
163
+ it. `BUNDLE_KEEP` older bundles are pruned, never the one the pointer names.
164
+ - a `product-docs-published` `repository_dispatch` to `id3as/norsk-ctl`, so the
165
+ public site rebuilds rather than waiting for its own cron. The payload
166
+ carries the repo, not a product key — norsk-ctl resolves the key through
167
+ ci-workflows' registry, so a new product needs no new mapping.
168
+
169
+ **Only a repo GitHub calls `norsk-ctl-product-*` may carry it.** `check:drift` refuses it anywhere else,
148
170
  refuses a repo carrying both, and refuses the public variant when it cannot
149
171
  tell the repo's name (`GITHUB_REPOSITORY` in CI, the origin remote locally).
150
172
 
@@ -177,3 +177,64 @@ jobs:
177
177
  gh release upload docs-latest "$RUNNER_TEMP/manual.html" --clobber
178
178
  echo "published -> docs-latest/manual.html"
179
179
  '
180
+
181
+ # The docs BUNDLE, versioned, behind a moving pointer on the SAME release
182
+ # (fleet review 03 s3.5 slice 6). `manual.html` above is unchanged -- it is
183
+ # what today's ctl docs site mounts at /products/<key>/ -- and this is what
184
+ # the public plane ingests as pages.
185
+ #
186
+ # Versioned assets on one release, not a release per commit: a release per
187
+ # commit would leave hundreds of prereleases on each product repo, and a
188
+ # single clobbered asset would leave nothing to roll back to. Only
189
+ # latest.json moves, so a rollback is a pointer edit and the ctl site never
190
+ # reads an asset being overwritten underneath it.
191
+ #
192
+ # Bun.resolveSync rather than a node_modules path: it honours the package's
193
+ # exports map, so this works whether ctl-dev-kit is the published package or
194
+ # a workspace link (scripts/set-ctl-source.ts local mode).
195
+ - name: Publish the versioned docs bundle and move the pointer
196
+ env:
197
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
198
+ # How many bundles to keep on the release, the current one included.
199
+ BUNDLE_KEEP: "5"
200
+ run: |
201
+ nix develop .#build --command bash -c '
202
+ set -euo pipefail
203
+ bundle="docs/generated/bundle"
204
+ [ -s "$bundle/bundle.json" ] || { echo "::error::no docs bundle at $bundle/bundle.json"; exit 1; }
205
+ rel="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/doc-guide/docs-release\", process.cwd()))")"
206
+ version="$(bun "$rel" version "$GITHUB_SHA")"
207
+ asset="$(bun "$rel" asset "$version")"
208
+ tar -czf "$RUNNER_TEMP/$asset" -C "$bundle" .
209
+ gh release upload docs-latest "$RUNNER_TEMP/$asset" --clobber
210
+ bun "$rel" pointer --version "$version" --sha "$GITHUB_SHA" \
211
+ --run-id "$GITHUB_RUN_ID" --built-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$RUNNER_TEMP/latest.json"
212
+ gh release upload docs-latest "$RUNNER_TEMP/latest.json" --clobber
213
+ gh release view docs-latest --json assets \
214
+ --jq "[.assets[] | {name: .name, createdAt: .createdAt}]" \
215
+ | bun "$rel" prune "$BUNDLE_KEEP" "$asset" \
216
+ | while read -r old; do
217
+ echo "pruning $old"
218
+ gh release delete-asset docs-latest "$old" --yes
219
+ done
220
+ echo "published -> docs-latest/$asset, pointer moved"
221
+ '
222
+
223
+ # Wake the public docs site rather than making it wait for its own cron.
224
+ # CI_DISPATCH_TOKEN, not GITHUB_TOKEN: repository_dispatch to ANOTHER repo
225
+ # needs Contents: write there, and the built-in token is scoped to this one.
226
+ # The payload carries the repo, not a product key: norsk-ctl resolves the
227
+ # key through ci-workflows' registry, so a new product needs no new mapping
228
+ # here. Not wrapped in nix -- plain curl, and the quoting stays readable.
229
+ - name: Tell norsk-ctl a new bundle is published
230
+ env:
231
+ DISPATCH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
232
+ run: |
233
+ set -euo pipefail
234
+ body="$(printf '{"event_type":"product-docs-published","client_payload":{"repo":"%s"}}' "$GITHUB_REPOSITORY")"
235
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
236
+ -H "Authorization: Bearer $DISPATCH_TOKEN" \
237
+ -H "Accept: application/vnd.github+json" \
238
+ "https://api.github.com/repos/id3as/norsk-ctl/dispatches" -d "$body")"
239
+ [ "$code" = "204" ] || { echo "::error::docs-published dispatch to norsk-ctl failed (HTTP $code)"; exit 1; }
240
+ echo "dispatched product-docs-published -> id3as/norsk-ctl"
@@ -26,13 +26,42 @@ export interface UiFixture {
26
26
  export declare function docsRoot(): string;
27
27
  /** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
28
28
  * `spotlight` keeps the whole screen for context but dims everything except the
29
- * named region and outlines it. Omit both for a plain full-page capture. */
29
+ * named region and outlines it. Omit both for a plain full-page capture.
30
+ * `livePicture` names a MoQ video tile the shot must not be taken without —
31
+ * see `awaitLivePicture`. */
30
32
  export interface CaptureTreatment {
31
33
  crop?: string;
32
34
  spotlight?: string;
33
35
  /** Slack in px around a spotlight region before the dimming starts. */
34
36
  pad?: number;
37
+ /** A live video tile (Studio's shared MoQ player's container) that has to be
38
+ * showing a picture before the capture is taken. */
39
+ livePicture?: string;
40
+ /** How long `livePicture` may take to arrive. Default 60s: a WebTransport
41
+ * session plus a catalog plus a keyframe, on a cold encoder. */
42
+ livePictureTimeoutMs?: number;
35
43
  }
44
+ /** The attributes Studio's shared MoQ player (norsk-studio-built-ins
45
+ * `lib/shared/moq-player`) stamps on the container it mounts into. `live`
46
+ * means the first video frame has decoded — not that the transport is up or
47
+ * the catalog arrived, both of which can hold for seconds with nothing on
48
+ * screen, which is exactly what a capture taken on a clock shows. */
49
+ export declare const MOQ_PHASE_ATTR = "data-moq-phase";
50
+ export declare const MOQ_ERROR_ATTR = "data-moq-error";
51
+ export type MoqPhase = "connecting" | "live" | "error";
52
+ /** The failure a guide sees when a tile never shows a picture: the tile, the
53
+ * phase it was stuck in and, for an error, the player's own reason — so a
54
+ * broken preview reads as what it is rather than as a timeout. Pure, so the
55
+ * wording is pinned without a browser. */
56
+ export declare function livePictureFailure(selector: string, phase: string | null, error: string | null, timeoutMs: number): string;
57
+ /** Block until the tile at `selector` reports a decoded frame, or fail saying
58
+ * why it did not. This is the wait a guide puts between "the source connected"
59
+ * and the capture: the player mounts at that moment, and a screenshot taken
60
+ * then is of a spinner. A wall-clock sleep would hide a broken preview; this
61
+ * turns it into a named failure. */
62
+ export declare function awaitLivePicture(page: Page, selector: string, opts?: {
63
+ timeoutMs?: number;
64
+ }): Promise<void>;
36
65
  /** Produce the screenshot bytes for a capture, applying its treatment. Split out
37
66
  * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
38
67
  * so the treatment geometry is testable on its own. */
@@ -10,6 +10,44 @@ export function docsRoot() {
10
10
  return isAbsolute(override) ? override : resolve(process.cwd(), override);
11
11
  return resolve(process.cwd(), "docs/generated");
12
12
  }
13
+ /** The attributes Studio's shared MoQ player (norsk-studio-built-ins
14
+ * `lib/shared/moq-player`) stamps on the container it mounts into. `live`
15
+ * means the first video frame has decoded — not that the transport is up or
16
+ * the catalog arrived, both of which can hold for seconds with nothing on
17
+ * screen, which is exactly what a capture taken on a clock shows. */
18
+ export const MOQ_PHASE_ATTR = "data-moq-phase";
19
+ export const MOQ_ERROR_ATTR = "data-moq-error";
20
+ const LIVE_PICTURE_TIMEOUT_MS = 60_000;
21
+ /** The failure a guide sees when a tile never shows a picture: the tile, the
22
+ * phase it was stuck in and, for an error, the player's own reason — so a
23
+ * broken preview reads as what it is rather than as a timeout. Pure, so the
24
+ * wording is pinned without a browser. */
25
+ export function livePictureFailure(selector, phase, error, timeoutMs) {
26
+ const state = phase === null
27
+ ? "not stamped — is a player mounted there?"
28
+ : phase === "error" && error
29
+ ? `error: ${error}`
30
+ : phase;
31
+ return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${state})`;
32
+ }
33
+ /** Block until the tile at `selector` reports a decoded frame, or fail saying
34
+ * why it did not. This is the wait a guide puts between "the source connected"
35
+ * and the capture: the player mounts at that moment, and a screenshot taken
36
+ * then is of a spinner. A wall-clock sleep would hide a broken preview; this
37
+ * turns it into a named failure. */
38
+ export async function awaitLivePicture(page, selector, opts) {
39
+ const timeoutMs = opts?.timeoutMs ?? LIVE_PICTURE_TIMEOUT_MS;
40
+ try {
41
+ await page.waitForFunction(({ selector, attr }) => document.querySelector(selector)?.getAttribute(attr) === "live", { selector, attr: MOQ_PHASE_ATTR }, { timeout: timeoutMs });
42
+ }
43
+ catch {
44
+ const [phase, error] = await page.evaluate(({ selector, phaseAttr, errorAttr }) => {
45
+ const el = document.querySelector(selector);
46
+ return [el?.getAttribute(phaseAttr) ?? null, el?.getAttribute(errorAttr) ?? null];
47
+ }, { selector, phaseAttr: MOQ_PHASE_ATTR, errorAttr: MOQ_ERROR_ATTR });
48
+ throw new Error(livePictureFailure(selector, phase, error, timeoutMs));
49
+ }
50
+ }
13
51
  const SPOTLIGHT_ID = "docguide-spotlight";
14
52
  /** Dim the page except the target region, drawn as one fixed box whose oversized
15
53
  * box-shadow does the dimming — appended to <body> so no ancestor `overflow`
@@ -45,6 +83,8 @@ async function spotlightOff(page) {
45
83
  * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
46
84
  * so the treatment geometry is testable on its own. */
47
85
  export async function screenshotFor(page, treat) {
86
+ if (treat?.livePicture)
87
+ await awaitLivePicture(page, treat.livePicture, { timeoutMs: treat.livePictureTimeoutMs });
48
88
  // Wait for web fonts, else the snapshot catches a fallback font mid-swap.
49
89
  await page.evaluate(() => document.fonts.ready);
50
90
  if (treat?.crop)
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * How a product publishes its docs bundle for the public plane to fetch
4
+ * (fleet review 03 s3.5 slice 6, product half). Used by
5
+ * conventions/publish-docs.yml, which every public product repo copies
6
+ * verbatim.
7
+ *
8
+ * Versioned assets behind a moving pointer, all on the ONE `docs-latest`
9
+ * release. A release per commit would leave hundreds of prereleases on each
10
+ * product repo; a single clobbered asset would leave nothing to roll back to.
11
+ * Only the pointer moves, so a rollback is a pointer edit and the ctl site
12
+ * never reads an asset being overwritten underneath it.
13
+ *
14
+ * `manual.html` stays on the same release, clobbered as before: it is what the
15
+ * current ctl fetcher mounts at /products/<key>/, and it keeps working
16
+ * unchanged while the bundle ingestion is built.
17
+ */
18
+ export interface BundlePointer {
19
+ /** Short product commit — the bundle's identity. */
20
+ version: string;
21
+ /** The release asset holding that bundle. */
22
+ asset: string;
23
+ /** Full product commit the bundle was built from. */
24
+ sha: string;
25
+ runId?: string;
26
+ builtAt: string;
27
+ }
28
+ export declare const DOCS_RELEASE_TAG = "docs-latest";
29
+ export declare const POINTER_ASSET = "latest.json";
30
+ export declare function bundleVersion(sha: string): string;
31
+ export declare function bundleAssetName(version: string): string;
32
+ export declare function pointerJson(p: BundlePointer): string;
33
+ export declare function parsePointer(json: string): BundlePointer;
34
+ /** Which versioned bundles to delete, newest `keep` kept. Only ever names
35
+ * `bundle-<version>.tar.gz` assets — `manual.html` and the pointer are the
36
+ * release's fixtures — and never the one the pointer currently names, which
37
+ * would leave the site fetching a 404. */
38
+ export declare function assetsToPrune(assets: readonly {
39
+ name: string;
40
+ createdAt: string;
41
+ }[], keep: number, current: string): string[];
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * How a product publishes its docs bundle for the public plane to fetch
4
+ * (fleet review 03 s3.5 slice 6, product half). Used by
5
+ * conventions/publish-docs.yml, which every public product repo copies
6
+ * verbatim.
7
+ *
8
+ * Versioned assets behind a moving pointer, all on the ONE `docs-latest`
9
+ * release. A release per commit would leave hundreds of prereleases on each
10
+ * product repo; a single clobbered asset would leave nothing to roll back to.
11
+ * Only the pointer moves, so a rollback is a pointer edit and the ctl site
12
+ * never reads an asset being overwritten underneath it.
13
+ *
14
+ * `manual.html` stays on the same release, clobbered as before: it is what the
15
+ * current ctl fetcher mounts at /products/<key>/, and it keeps working
16
+ * unchanged while the bundle ingestion is built.
17
+ */
18
+ export const DOCS_RELEASE_TAG = "docs-latest";
19
+ export const POINTER_ASSET = "latest.json";
20
+ const VERSION_LENGTH = 12;
21
+ const BUNDLE_ASSET_RE = /^bundle-[0-9a-f]{12}\.tar\.gz$/;
22
+ export function bundleVersion(sha) {
23
+ return sha.slice(0, VERSION_LENGTH);
24
+ }
25
+ export function bundleAssetName(version) {
26
+ return `bundle-${version}.tar.gz`;
27
+ }
28
+ export function pointerJson(p) {
29
+ return `${JSON.stringify(p, null, 2)}\n`;
30
+ }
31
+ export function parsePointer(json) {
32
+ const parsed = JSON.parse(json);
33
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
34
+ throw new Error("docs pointer: expected an object");
35
+ const p = parsed;
36
+ for (const field of ["version", "asset", "sha", "builtAt"])
37
+ if (typeof p[field] !== "string")
38
+ throw new Error(`docs pointer: missing ${field}`);
39
+ return {
40
+ version: p.version,
41
+ asset: p.asset,
42
+ sha: p.sha,
43
+ builtAt: p.builtAt,
44
+ ...(p.runId === undefined ? {} : { runId: p.runId }),
45
+ };
46
+ }
47
+ /** Which versioned bundles to delete, newest `keep` kept. Only ever names
48
+ * `bundle-<version>.tar.gz` assets — `manual.html` and the pointer are the
49
+ * release's fixtures — and never the one the pointer currently names, which
50
+ * would leave the site fetching a 404. */
51
+ export function assetsToPrune(assets, keep, current) {
52
+ const bundles = assets
53
+ .filter((a) => BUNDLE_ASSET_RE.test(a.name) && a.name !== current)
54
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
55
+ // The current bundle is always kept and counts towards `keep`, so only
56
+ // `keep - 1` of the older ones survive.
57
+ return bundles
58
+ .slice(Math.max(0, keep - 1))
59
+ .map((a) => a.name)
60
+ .sort();
61
+ }
62
+ if (import.meta.main) {
63
+ const argv = process.argv.slice(2);
64
+ const [cmd, a, b] = argv;
65
+ const flag = (name) => {
66
+ const i = argv.indexOf(`--${name}`);
67
+ return i === -1 ? undefined : argv[i + 1];
68
+ };
69
+ if (cmd === "version" && a) {
70
+ console.log(bundleVersion(a));
71
+ }
72
+ else if (cmd === "asset" && a) {
73
+ console.log(bundleAssetName(a));
74
+ }
75
+ else if (cmd === "pointer") {
76
+ const version = flag("version");
77
+ const sha = flag("sha");
78
+ const builtAt = flag("built-at");
79
+ if (!version || !sha || !builtAt) {
80
+ console.error("docs-release pointer: --version, --sha and --built-at are required");
81
+ process.exit(2);
82
+ }
83
+ const runId = flag("run-id");
84
+ process.stdout.write(pointerJson({
85
+ version,
86
+ asset: bundleAssetName(version),
87
+ sha,
88
+ builtAt,
89
+ ...(runId ? { runId } : {}),
90
+ }));
91
+ }
92
+ else if (cmd === "prune" && a && b) {
93
+ const chunks = [];
94
+ for await (const c of process.stdin)
95
+ chunks.push(c);
96
+ const assets = JSON.parse(Buffer.concat(chunks).toString("utf8"));
97
+ for (const name of assetsToPrune(assets, Number(a), b))
98
+ console.log(name);
99
+ }
100
+ else {
101
+ console.error("usage: docs-release.ts version <sha> | asset <version> | pointer --version <v> --sha <sha> [--run-id <id>] --built-at <iso> | prune <keep> <currentAsset>");
102
+ process.exit(2);
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",
7
7
  "./doc-guide": "./doc-guide/doc-guide.js",
8
8
  "./doc-guide/build-manual": "./doc-guide/build-manual.js",
9
9
  "./doc-guide/bundle": "./doc-guide/bundle.js",
10
+ "./doc-guide/docs-release": "./doc-guide/docs-release.js",
10
11
  "./doc-guide/guides-config": "./doc-guide/guides-config.js",
11
12
  "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
12
13
  "./doc-guide/live-browser": "./doc-guide/live-browser.js",