@tikoci/rosetta 0.9.3 → 0.11.0-alpha.87

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.
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { db, initDb } from "./db.ts";
17
+ import { fetchGitHub, githubApiHeaders } from "./github.ts";
17
18
 
18
19
  // ── Configuration ──
19
20
 
@@ -22,7 +23,6 @@ const CACHE_DIR = join(PROJECT_ROOT, "skills");
22
23
 
23
24
  const GITHUB_REPO = "tikoci/routeros-skills";
24
25
  const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_REPO}`;
25
- const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO}`;
26
26
 
27
27
  const FROM_CACHE = process.argv.includes("--from-cache");
28
28
 
@@ -70,15 +70,8 @@ function countWords(text: string): number {
70
70
 
71
71
  // ── GitHub API fetching ──
72
72
 
73
- export function githubApiHeaders(): Record<string, string> {
74
- const headers: Record<string, string> = { Accept: "application/vnd.github.v3+json" };
75
- const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
76
- if (token) headers.Authorization = `Bearer ${token}`;
77
- return headers;
78
- }
79
-
80
73
  async function getDefaultBranchSha(): Promise<string> {
81
- const res = await fetch(`${GITHUB_API_BASE}/commits/HEAD`, {
74
+ const res = await fetchGitHub(`${GITHUB_API_BASE}/commits/HEAD`, {
82
75
  headers: githubApiHeaders(),
83
76
  });
84
77
  if (!res.ok) throw new Error(`Failed to get HEAD SHA: HTTP ${res.status}`);
@@ -87,7 +80,7 @@ async function getDefaultBranchSha(): Promise<string> {
87
80
  }
88
81
 
89
82
  async function listSkillDirs(sha: string): Promise<string[]> {
90
- const res = await fetch(`${GITHUB_API_BASE}/contents/?ref=${sha}`, {
83
+ const res = await fetchGitHub(`${GITHUB_API_BASE}/contents/?ref=${sha}`, {
91
84
  headers: githubApiHeaders(),
92
85
  });
93
86
  if (!res.ok) throw new Error(`Failed to list repo contents: HTTP ${res.status}`);
@@ -98,8 +91,11 @@ async function listSkillDirs(sha: string): Promise<string[]> {
98
91
  }
99
92
 
100
93
  async function fetchRawFile(sha: string, path: string): Promise<string | null> {
101
- const url = `${GITHUB_RAW_BASE}/${sha}/${path}`;
102
- const res = await fetch(url);
94
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
95
+ const url = `${GITHUB_API_BASE}/contents/${encodedPath}?ref=${sha}`;
96
+ const res = await fetchGitHub(url, {
97
+ headers: githubApiHeaders("application/vnd.github.v3.raw"),
98
+ });
103
99
  if (!res.ok) {
104
100
  if (res.status === 404) return null;
105
101
  throw new Error(`Failed to fetch ${path}: HTTP ${res.status}`);
@@ -108,7 +104,7 @@ async function fetchRawFile(sha: string, path: string): Promise<string | null> {
108
104
  }
109
105
 
110
106
  async function listReferences(sha: string, skillName: string): Promise<string[]> {
111
- const res = await fetch(`${GITHUB_API_BASE}/contents/${skillName}/references?ref=${sha}`, {
107
+ const res = await fetchGitHub(`${GITHUB_API_BASE}/contents/${skillName}/references?ref=${sha}`, {
112
108
  headers: githubApiHeaders(),
113
109
  });
114
110
  if (!res.ok) {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultRetryDelayMs, shouldRetry } from "./github.ts";
3
+
4
+ function fakeResponse(status: number, headers: Record<string, string> = {}): Response {
5
+ return new Response(null, { status, headers });
6
+ }
7
+
8
+ describe("shouldRetry", () => {
9
+ test("retries plain transient statuses", () => {
10
+ expect(shouldRetry(fakeResponse(429))).toBe(true);
11
+ expect(shouldRetry(fakeResponse(500))).toBe(true);
12
+ expect(shouldRetry(fakeResponse(502))).toBe(true);
13
+ expect(shouldRetry(fakeResponse(503))).toBe(true);
14
+ expect(shouldRetry(fakeResponse(504))).toBe(true);
15
+ });
16
+
17
+ test("retries 403 primary rate limit exhaustion", () => {
18
+ expect(shouldRetry(fakeResponse(403, { "x-ratelimit-remaining": "0" }))).toBe(true);
19
+ });
20
+
21
+ test("retries 403 secondary/abuse-detection limit even when quota remains", () => {
22
+ // GitHub secondary rate limits return 403 + Retry-After without touching
23
+ // x-ratelimit-remaining, since they're not tied to the primary quota.
24
+ expect(
25
+ shouldRetry(fakeResponse(403, { "retry-after": "30", "x-ratelimit-remaining": "42" })),
26
+ ).toBe(true);
27
+ });
28
+
29
+ test("does not retry plain permission-denied 403s", () => {
30
+ expect(shouldRetry(fakeResponse(403))).toBe(false);
31
+ expect(shouldRetry(fakeResponse(403, { "x-ratelimit-remaining": "42" }))).toBe(false);
32
+ });
33
+
34
+ test("does not retry success or not-found responses", () => {
35
+ expect(shouldRetry(fakeResponse(200))).toBe(false);
36
+ expect(shouldRetry(fakeResponse(404))).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("defaultRetryDelayMs", () => {
41
+ test("falls back to exponential backoff when x-ratelimit-reset header is missing", () => {
42
+ // Regression: Number(null) is 0 (finite), which previously made the reset
43
+ // branch return an immediate 0ms delay instead of falling through.
44
+ const delay = defaultRetryDelayMs(fakeResponse(403, { "x-ratelimit-remaining": "0" }), 2);
45
+ expect(delay).toBe(1000 * 2 ** 2);
46
+ });
47
+
48
+ test("uses x-ratelimit-reset when present and remaining is exhausted", () => {
49
+ const resetAt = Math.floor(Date.now() / 1000) + 5;
50
+ const delay = defaultRetryDelayMs(
51
+ fakeResponse(403, { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(resetAt) }),
52
+ 0,
53
+ );
54
+ expect(delay).toBeGreaterThan(3000);
55
+ expect(delay).toBeLessThanOrEqual(5000);
56
+ });
57
+
58
+ test("prefers retry-after over rate-limit reset", () => {
59
+ const delay = defaultRetryDelayMs(
60
+ fakeResponse(403, { "retry-after": "10", "x-ratelimit-remaining": "0", "x-ratelimit-reset": "0" }),
61
+ 0,
62
+ );
63
+ expect(delay).toBe(10_000);
64
+ });
65
+
66
+ test("falls back to exponential backoff with no relevant headers", () => {
67
+ expect(defaultRetryDelayMs(fakeResponse(500), 0)).toBe(1000);
68
+ expect(defaultRetryDelayMs(fakeResponse(500), 3)).toBe(8000);
69
+ });
70
+ });
package/src/github.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * github.ts — Small helpers for authenticated GitHub HTTP calls.
3
+ *
4
+ * Release extraction runs from GitHub Actions shared runner IPs, so every
5
+ * GitHub API request should use GITHUB_TOKEN/GH_TOKEN when available and retry
6
+ * transient throttling responses.
7
+ */
8
+
9
+ const DEFAULT_ACCEPT = "application/vnd.github.v3+json";
10
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
11
+
12
+ export function githubApiHeaders(accept = DEFAULT_ACCEPT): Record<string, string> {
13
+ const headers: Record<string, string> = { Accept: accept };
14
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
15
+ if (token) headers.Authorization = `Bearer ${token}`;
16
+ return headers;
17
+ }
18
+
19
+ function retryAfterMs(value: string | null): number | null {
20
+ if (!value) return null;
21
+ const seconds = Number(value);
22
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
23
+ const dateMs = Date.parse(value);
24
+ if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
25
+ return null;
26
+ }
27
+
28
+ export function defaultRetryDelayMs(response: Response, attempt: number): number {
29
+ const retryAfter = retryAfterMs(response.headers.get("retry-after"));
30
+ if (retryAfter !== null) return retryAfter;
31
+
32
+ const resetSeconds = Number(response.headers.get("x-ratelimit-reset") ?? NaN);
33
+ const remaining = response.headers.get("x-ratelimit-remaining");
34
+ if (remaining === "0" && Number.isFinite(resetSeconds)) {
35
+ return Math.max(0, resetSeconds * 1000 - Date.now());
36
+ }
37
+
38
+ return 1000 * 2 ** attempt;
39
+ }
40
+
41
+ export function shouldRetry(response: Response): boolean {
42
+ if (RETRYABLE_STATUSES.has(response.status)) return true;
43
+ if (response.status !== 403) return false;
44
+ // Primary rate limit (remaining=0) or secondary/abuse-detection limit
45
+ // (Retry-After present even with quota remaining) are both retryable.
46
+ return response.headers.get("x-ratelimit-remaining") === "0" || response.headers.get("retry-after") !== null;
47
+ }
48
+
49
+ export async function fetchGitHub(
50
+ url: string,
51
+ options: RequestInit = {},
52
+ retries = 3,
53
+ ): Promise<Response> {
54
+ for (let attempt = 0; attempt <= retries; attempt++) {
55
+ const response = await fetch(url, options);
56
+ if (!shouldRetry(response) || attempt === retries) return response;
57
+
58
+ const delayMs = Math.min(defaultRetryDelayMs(response, attempt), 30_000);
59
+ console.warn(`GitHub request throttled (HTTP ${response.status}); retrying in ${Math.round(delayMs / 1000)}s`);
60
+ await Bun.sleep(delayMs);
61
+ }
62
+
63
+ // Unreachable in practice: every caller uses the default `retries`, and the
64
+ // loop always returns on its `attempt === retries` iteration. `fetch` is
65
+ // called once more here only so TypeScript sees every path return a Response.
66
+ return fetch(url, options);
67
+ }
@@ -11,13 +11,42 @@ import { existsSync } from "node:fs";
11
11
  import path from "node:path";
12
12
  import { Client } from "@modelcontextprotocol/sdk/client";
13
13
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
14
+ import { SCHEMA_VERSION } from "./paths.ts";
15
+ import { probeDb } from "./setup.ts";
14
16
 
15
17
  const ROOT = path.resolve(import.meta.dirname, "..");
16
18
  const configuredDbPath = process.env.TEST_DB_PATH?.trim();
17
19
  const DB_PATH = configuredDbPath ? path.resolve(ROOT, configuredDbPath) : path.join(ROOT, "ros-help.db");
18
20
  const hasTestDb = existsSync(DB_PATH);
19
21
  const dbWasExplicitlyConfigured = Boolean(configuredDbPath);
20
- const skipReason = `No populated test database at ${DB_PATH}; set TEST_DB_PATH or place ros-help.db at repo root to run this integration test.`;
22
+
23
+ // A schema-bumping change (e.g. adding pages.rosetta_id in schema v6) lands
24
+ // in this repo before a matching DB is published to GitHub Releases — see
25
+ // BACKLOG.md's "no release ships until the Docusaurus migration is solid"
26
+ // sequencing gate. CI's "download latest published DB" step will keep
27
+ // fetching a stale, lower-schema DB until that gate clears, and running the
28
+ // real stdio server against it always fails the same way: ensureDbReady()
29
+ // (correctly) detects the mismatch, tries to re-download, gets the same
30
+ // stale DB back, and exits — which the client sees as "Connection closed"
31
+ // with no useful diagnostic. Detect that specific, provable condition here
32
+ // and skip with a clear reason instead of hard-failing CI on an artifact
33
+ // that cannot exist yet.
34
+ const schemaProbe = hasTestDb ? probeDb(DB_PATH) : null;
35
+ const schemaMismatch = schemaProbe !== null && schemaProbe.schemaVersion !== SCHEMA_VERSION;
36
+ // A file at DB_PATH that probeDb() can't read (empty, partial, or not SQLite) yields a
37
+ // null probe. existsSync alone would let the test spawn the server against a broken DB
38
+ // and fail with an opaque "Connection closed" — treat an unprobeable file as "no usable
39
+ // DB" and skip with a clear reason instead (CodeRabbit, PR #13).
40
+ const invalidTestDb = hasTestDb && schemaProbe === null;
41
+
42
+ const skipReason = schemaMismatch
43
+ ? `Test database at ${DB_PATH} has schema_version=${schemaProbe?.schemaVersion}, but this build expects ` +
44
+ `${SCHEMA_VERSION}. This is expected when a schema-bumping change hasn't been published as a release yet ` +
45
+ `(see BACKLOG.md sequencing gate) — skipping rather than failing on a DB that cannot exist until then.`
46
+ : invalidTestDb
47
+ ? `Test database at ${DB_PATH} exists but is not a readable SQLite DB (empty, partial, or corrupt); ` +
48
+ `replace it or point TEST_DB_PATH at a valid ros-help.db to run this integration test.`
49
+ : `No populated test database at ${DB_PATH}; set TEST_DB_PATH or place ros-help.db at repo root to run this integration test.`;
21
50
 
22
51
  const EXPECTED_TOOLS = [
23
52
  "routeros_search",
@@ -76,10 +105,10 @@ function buildDiagnostics(errors: Error[], stderr: string[]): string {
76
105
  return sections.length > 0 ? `\n\n${sections.join("\n\n")}` : "";
77
106
  }
78
107
 
79
- describe.skipIf(!hasTestDb && !dbWasExplicitlyConfigured)(
80
- hasTestDb || dbWasExplicitlyConfigured
81
- ? "stdio transport: real MCP client"
82
- : `stdio transport: real MCP client [skipped: ${skipReason}]`,
108
+ const shouldSkip = (!hasTestDb && !dbWasExplicitlyConfigured) || schemaMismatch || invalidTestDb;
109
+
110
+ describe.skipIf(shouldSkip)(
111
+ shouldSkip ? `stdio transport: real MCP client [skipped: ${skipReason}]` : "stdio transport: real MCP client",
83
112
  () => {
84
113
  let client: Client | undefined;
85
114
 
package/src/paths.ts CHANGED
@@ -89,8 +89,11 @@ export function detectMode(srcDir: string): InvocationMode {
89
89
  * Bump history:
90
90
  * v5 — added `db_meta` key/value table for release-tag provenance and
91
91
  * atomic-download / version-pinned-URL update flow (2026-04-21).
92
+ * v6 — added `pages.rosetta_id` (TEXT, unique-when-not-null) for
93
+ * Docusaurus-sourced pages extracted by extract-docusaurus.ts; legacy
94
+ * Confluence-sourced rows keep NULL (2026-07-07, T-0035).
92
95
  */
93
- export const SCHEMA_VERSION = 5;
96
+ export const SCHEMA_VERSION = 6;
94
97
 
95
98
  /**
96
99
  * Resolve the version string.
@@ -28,8 +28,11 @@ function mustIndex(haystack: string, needle: string): number {
28
28
  describe("package.json", () => {
29
29
  const pkg = JSON.parse(readText("package.json"));
30
30
 
31
- test("version is valid semver", () => {
32
- expect(pkg.version).toMatch(/^\d+\.\d+\.\d+$/);
31
+ test("version is valid semver, optionally with an alpha/beta/rc prerelease channel suffix", () => {
32
+ // release.yml's "Determine npm release channel" step reads this committed
33
+ // value as the single source of truth: a bare MAJOR.MINOR.PATCH means
34
+ // latest, a -<stage> or -<stage>.N suffix means a prerelease dist-tag.
35
+ expect(pkg.version).toMatch(/^\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?$/);
33
36
  });
34
37
 
35
38
  test("name is @tikoci/rosetta", () => {
@@ -362,6 +365,32 @@ describe("Makefile", () => {
362
365
  expect(makefile).toMatch(/^extract-full:.*extract-dude-from-cache/m);
363
366
  });
364
367
 
368
+ test("has extract-docusaurus and extract-docusaurus-from-cache targets", () => {
369
+ expect(makefile).toContain("extract-docusaurus:");
370
+ expect(makefile).toContain("extract-docusaurus-from-cache:");
371
+ });
372
+
373
+ test("extract-docusaurus is in PHONY", () => {
374
+ const phonyStart = makefile.indexOf(".PHONY:");
375
+ const phonyEnd = makefile.indexOf("\n\n", phonyStart);
376
+ const phonyBlock = makefile.slice(phonyStart, phonyEnd);
377
+ expect(phonyBlock).toContain("extract-docusaurus");
378
+ });
379
+
380
+ test("extract/extract-full use extract-docusaurus, not the legacy Confluence pipeline", () => {
381
+ // T-0035: extract-docusaurus.ts replaces extract-html.ts's role in the default
382
+ // pipeline. extract-html.ts survives only via extract-legacy-confluence, for
383
+ // rebuilding historical pre-migration release DBs (DESIGN.md).
384
+ expect(makefile).toMatch(/^extract: extract-docusaurus\b/m);
385
+ expect(makefile).toMatch(/^extract-full: extract-docusaurus\b/m);
386
+ expect(makefile).not.toMatch(/^extract:.*extract-html\b/m);
387
+ expect(makefile).not.toMatch(/^extract-full:.*extract-html\b/m);
388
+ });
389
+
390
+ test("has extract-legacy-confluence target wrapping extract-html + extract-properties", () => {
391
+ expect(makefile).toMatch(/^extract-legacy-confluence:.*extract-html.*extract-properties/m);
392
+ });
393
+
365
394
  test("preflight checks dirty tree", () => {
366
395
  expect(makefile).toContain("git diff --quiet");
367
396
  });
@@ -380,22 +409,181 @@ describe("release.yml", () => {
380
409
 
381
410
  test("has required inputs", () => {
382
411
  const src = readText(".github/workflows/release.yml");
383
- expect(src).toContain("html_url:");
412
+ expect(src).not.toContain("html_url:");
413
+ expect(src).not.toContain("extract-html.ts");
414
+ expect(src).not.toContain("extract-properties.ts");
384
415
  expect(src).toContain("version:");
385
416
  expect(src).toContain("republish_assets:");
386
417
  expect(src).not.toContain("inputs.force");
387
418
  });
388
419
 
389
- test("tolerates Confluence zip with absolute path root entry", () => {
420
+ describe("npm prerelease channel", () => {
421
+ const src = readText(".github/workflows/release.yml");
422
+ const channelIdx = mustIndex(src, "Determine npm release channel");
423
+ const changelogGateIdx = mustIndex(
424
+ src,
425
+ "Verify CHANGELOG promotion for latest-channel release",
426
+ );
427
+ const preflightIdx = mustIndex(src, "Verify npm publish access");
428
+ const resolveVersionIdx = mustIndex(src, "Resolve release version");
429
+ const installIdx = mustIndex(src, "bun install");
430
+
431
+ test("channel detection runs before any preflight/publish step reads package.json's version", () => {
432
+ expect(installIdx).toBeLessThan(channelIdx);
433
+ expect(channelIdx).toBeLessThan(changelogGateIdx);
434
+ expect(channelIdx).toBeLessThan(preflightIdx);
435
+ expect(channelIdx).toBeLessThan(resolveVersionIdx);
436
+ });
437
+
438
+ test("parses a MAJOR.MINOR.PATCH-<stage> or -<stage>.N package.json version into channel/stage outputs", () => {
439
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
440
+ expect(channelBlock).toContain(
441
+ "^([0-9]+\\.[0-9]+\\.[0-9]+)-([A-Za-z]+)(\\.[0-9]+)?$",
442
+ );
443
+ expect(channelBlock).toContain("channel=prerelease");
444
+ expect(channelBlock).toContain("channel=latest");
445
+ expect(channelBlock).toContain("stage=$STAGE");
446
+ });
447
+
448
+ test("validates the parsed stage against the alpha/beta/rc allowlist, not arbitrary strings", () => {
449
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
450
+ expect(channelBlock).toContain("alpha|beta|rc) ;;");
451
+ expect(channelBlock).toMatch(/::error::Unrecognized prerelease stage/);
452
+ expect(channelBlock).toContain("exit 1");
453
+ });
454
+
455
+ test("a version matching neither the prerelease nor the bare-semver shape fails loudly instead of silently falling through to latest", () => {
456
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
457
+ // Bare-latest branch must be gated on a strict semver regex, not a bare `else`
458
+ // catch-all — otherwise a typo'd prerelease shape (e.g. "0.11.0-alpha1" with
459
+ // no separator, or a 4-part version) would silently publish as `latest`.
460
+ expect(channelBlock).toMatch(
461
+ /elif \[\[ "\$PKG_VERSION" =~ \^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$ \]\]; then/,
462
+ );
463
+ expect(channelBlock).toMatch(
464
+ /::error::package\.json version '\$PKG_VERSION' doesn't match a recognized shape/,
465
+ );
466
+ });
467
+
468
+ test("rewrites package.json's version in-place with a run-number suffix for prerelease, workspace-only (not committed)", () => {
469
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
470
+ expect(channelBlock).toContain(`\${BASE}-\${STAGE}.\${GITHUB_RUN_NUMBER}`);
471
+ expect(channelBlock).toContain("fs.writeFileSync('package.json'");
472
+ expect(channelBlock).not.toContain("git add package.json");
473
+ expect(channelBlock).not.toContain("git commit");
474
+ });
475
+
476
+ test("republish_assets: true skips the package.json rewrite and requires an exact inputs.version for prerelease republishes", () => {
477
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
478
+ expect(channelBlock).toMatch(/if \[ "\$REPUBLISH_ASSETS" = "true" \]/);
479
+ expect(channelBlock).toMatch(
480
+ /republish_assets=true with a prerelease package\.json version.*requires inputs\.version/,
481
+ );
482
+ expect(channelBlock).toContain(
483
+ "package.json version left as committed",
484
+ );
485
+ });
486
+
487
+ test("republish_assets and version inputs are read via env:, not interpolated directly into the shell script (template-injection guard)", () => {
488
+ const channelBlock = src.slice(channelIdx, changelogGateIdx);
489
+ expect(channelBlock).toContain(`REPUBLISH_ASSETS: \${{ inputs.republish_assets }}`);
490
+ expect(channelBlock).toContain(`INPUT_VERSION: \${{ inputs.version }}`);
491
+ expect(channelBlock).not.toMatch(/\[ .*"\$\{\{ inputs\./);
492
+ });
493
+
494
+ test("latest-channel CHANGELOG gate fails without a matching [<version>] heading, skips for prerelease and republish", () => {
495
+ const gateIdx2 = mustIndex(src, "Verify npm publish access");
496
+ const gateBlock = src.slice(changelogGateIdx, gateIdx2);
497
+ expect(gateBlock).toContain('if: inputs.republish_assets != true');
498
+ expect(gateBlock).toContain('steps.channel.outputs.channel');
499
+ expect(gateBlock).toContain('grep -qF "## [$PKG_VERSION]" CHANGELOG.md');
500
+ expect(gateBlock).toMatch(/::error::CHANGELOG\.md has no/);
501
+ });
502
+
503
+ test("npm publish uses --tag <stage> for prerelease and adds a next dist-tag; latest is unchanged bare publish", () => {
504
+ const publishIdx = mustIndex(src, "Publish to npm");
505
+ const bunxSmokeIdx = mustIndex(src, "bunx-smoke:");
506
+ const publishBlock = src.slice(publishIdx, bunxSmokeIdx);
507
+ expect(publishBlock).toContain(
508
+ `npm publish --access public --tag "\${{ steps.channel.outputs.stage }}"`,
509
+ );
510
+ // Reads PKG_NAME from package.json dynamically rather than hardcoding
511
+ // the package name, so a rename can't silently drift out of sync.
512
+ expect(publishBlock).toContain(
513
+ "PKG_NAME=$(node -p \"require('./package.json').name\")",
514
+ );
515
+ expect(publishBlock).toContain(
516
+ `npm dist-tag add "\${PKG_NAME}@\${NPM_VERSION}" next`,
517
+ );
518
+ expect(publishBlock).toContain(
519
+ "npm publish --access public --registry https://registry.npmjs.org/",
520
+ );
521
+ });
522
+
523
+ test("OCI tags align with npm scheme: version+sha always, floating stage/next for prerelease, latest only for latest channel, republish never moves floating tags", () => {
524
+ const ociIdx = mustIndex(src, "Build and push OCI images");
525
+ const smokeIdx = mustIndex(src, "Smoke test published OCI images");
526
+ const ociBlock = src.slice(ociIdx, smokeIdx);
527
+ expect(ociBlock).toContain(`tags+=(--tag "\${registry}:\${VERSION}" --tag "\${registry}:sha-\${SHORT_SHA}")`);
528
+ expect(ociBlock).toContain(`tags+=(--tag "\${registry}:\${STAGE}" --tag "\${registry}:next")`);
529
+ expect(ociBlock).toContain(`tags+=(--tag "\${registry}:latest")`);
530
+ // republish_assets is read via env: (template-injection guard), not
531
+ // interpolated directly into the `if [ ... ]` shell test.
532
+ expect(ociBlock).toContain(`REPUBLISH_ASSETS: \${{ inputs.republish_assets }}`);
533
+ expect(ociBlock).toMatch(/if \[ "\$REPUBLISH_ASSETS" != "true" \]/);
534
+ });
535
+
536
+ test("GitHub Release is created with --prerelease for prerelease channel runs", () => {
537
+ const releaseIdx = mustIndex(src, "Create or update GitHub Release");
538
+ const publishIdx = mustIndex(src, "Publish to npm");
539
+ const releaseBlock = src.slice(releaseIdx, publishIdx);
540
+ expect(releaseBlock).toContain("PRERELEASE_FLAGS=(--prerelease)");
541
+ expect(releaseBlock).toContain('steps.channel.outputs.channel');
542
+ expect(releaseBlock).toContain(`"\${PRERELEASE_FLAGS[@]}"`);
543
+ });
544
+
545
+ test("docs_date and republish_assets inputs are read via env: in the GitHub Release step (template-injection guard)", () => {
546
+ const releaseIdx = mustIndex(src, "Create or update GitHub Release");
547
+ const publishIdx = mustIndex(src, "Publish to npm");
548
+ const releaseBlock = src.slice(releaseIdx, publishIdx);
549
+ expect(releaseBlock).toContain(`DOCS_DATE: \${{ inputs.docs_date }}`);
550
+ expect(releaseBlock).toContain(`REPUBLISH_ASSETS: \${{ inputs.republish_assets }}`);
551
+ expect(releaseBlock).toMatch(/if \[ "\$REPUBLISH_ASSETS" = "true" \]/);
552
+ expect(releaseBlock).not.toContain(`DOCS_DATE="\${{ inputs.docs_date }}"`);
553
+ });
554
+ });
555
+
556
+ test("runs test coverage in release CI and uploads it as a workflow artifact", () => {
557
+ const src = readText(".github/workflows/release.yml");
558
+ const fastFailIdx = mustIndex(src, "Run tests (fast-fail)");
559
+ const buildxIdx = mustIndex(src, "Set up Docker Buildx");
560
+ const coverageBlock = src.slice(fastFailIdx, buildxIdx);
561
+
562
+ expect(coverageBlock).toContain("bun test --coverage");
563
+ expect(coverageBlock).toContain("--coverage-reporter=lcov");
564
+ expect(coverageBlock).toContain("## Test coverage");
565
+ expect(coverageBlock).toContain("Upload coverage artifact");
566
+ expect(coverageBlock).toContain("coverage/lcov.info");
567
+ });
568
+
569
+ test("coverage step always closes its summary code fence, even when bun test fails (PIPESTATUS, not implicit pipefail)", () => {
390
570
  const src = readText(".github/workflows/release.yml");
391
- // Confluence exports include a bare "/" entry → unzip exits 2
392
- expect(src).toMatch(/unzip.*\|\|.*\$\?.*-eq.*2/);
571
+ const fastFailIdx = mustIndex(src, "Run tests (fast-fail)");
572
+ const uploadIdx = mustIndex(src, "Upload coverage artifact");
573
+ const coverageBlock = src.slice(fastFailIdx, uploadIdx);
574
+
575
+ expect(coverageBlock).toContain("set +e");
576
+ const statusIdx = mustIndex(coverageBlock, `status=\${PIPESTATUS[0]}`);
577
+ const closeFenceIdx = coverageBlock.lastIndexOf("echo '```'");
578
+ const exitIdx = mustIndex(coverageBlock, 'exit "$status"');
579
+
580
+ expect(statusIdx).toBeLessThan(closeFenceIdx);
581
+ expect(closeFenceIdx).toBeLessThan(exitIdx);
393
582
  });
394
583
 
395
584
  test("runs extraction pipeline", () => {
396
585
  const src = readText(".github/workflows/release.yml");
397
- expect(src).toContain("extract-html.ts");
398
- expect(src).toContain("extract-properties.ts");
586
+ expect(src).toContain("extract-docusaurus.ts");
399
587
  expect(src).toContain("extract-commands.ts");
400
588
  expect(src).toContain("extract-devices.ts");
401
589
  expect(src).toContain("extract-test-results.ts");
@@ -404,6 +592,15 @@ describe("release.yml", () => {
404
592
  expect(src).toContain("link-commands.ts");
405
593
  });
406
594
 
595
+ test("Docusaurus extraction step proves the docs-count invariant, not just a manual/local run", () => {
596
+ const src = readText(".github/workflows/release.yml");
597
+ const extractIdx = mustIndex(src, "Extract Docusaurus pages, properties, callouts");
598
+ const commandTreeIdx = mustIndex(src, "Extract command tree");
599
+ const extractBlock = src.slice(extractIdx, commandTreeIdx);
600
+
601
+ expect(extractBlock).toContain("extract-docusaurus.ts --check-counts --strict");
602
+ });
603
+
407
604
  test("imports Dude wiki from cache", () => {
408
605
  const src = readText(".github/workflows/release.yml");
409
606
  expect(src).toContain("extract-dude-from-cache");
@@ -428,30 +625,30 @@ describe("release.yml", () => {
428
625
  expect(src).toContain("bun run lint");
429
626
  });
430
627
 
431
- test("runs early quality gate before downloading HTML export", () => {
628
+ test("runs early quality gate before Docusaurus extraction", () => {
432
629
  const src = readText(".github/workflows/release.yml");
433
630
  const installIdx = mustIndex(src, "bun install");
434
631
  const typecheckIdx = mustIndex(src, "Type check (fast-fail)");
435
632
  const lintIdx = mustIndex(src, "Lint (fast-fail)");
436
633
  const earlyTestIdx = mustIndex(src, "Run tests (fast-fail)");
437
- const downloadIdx = mustIndex(src, "Download HTML export");
634
+ const extractIdx = mustIndex(src, "Extract Docusaurus pages, properties, callouts");
438
635
 
439
636
  expect(installIdx).toBeLessThan(typecheckIdx);
440
637
  expect(typecheckIdx).toBeLessThan(lintIdx);
441
638
  expect(lintIdx).toBeLessThan(earlyTestIdx);
442
- expect(earlyTestIdx).toBeLessThan(downloadIdx);
639
+ expect(earlyTestIdx).toBeLessThan(extractIdx);
443
640
  });
444
641
 
445
642
  test("preflights npm publish access before release side effects", () => {
446
643
  const src = readText(".github/workflows/release.yml");
447
644
  const preflightIdx = mustIndex(src, "Verify npm publish access");
448
- const downloadIdx = mustIndex(src, "Download HTML export");
645
+ const extractIdx = mustIndex(src, "Extract Docusaurus pages, properties, callouts");
449
646
  const ociIdx = mustIndex(src, "Build and push OCI images");
450
647
  const releaseIdx = mustIndex(src, "Create or update GitHub Release");
451
648
  const publishIdx = mustIndex(src, "Publish to npm");
452
- const preflightBlock = src.slice(preflightIdx, downloadIdx);
649
+ const preflightBlock = src.slice(preflightIdx, extractIdx);
453
650
 
454
- expect(preflightIdx).toBeLessThan(downloadIdx);
651
+ expect(preflightIdx).toBeLessThan(extractIdx);
455
652
  expect(preflightIdx).toBeLessThan(ociIdx);
456
653
  expect(preflightIdx).toBeLessThan(releaseIdx);
457
654
  expect(preflightIdx).toBeLessThan(publishIdx);
@@ -529,8 +726,10 @@ describe("release.yml", () => {
529
726
  expect(src).not.toContain("inputs.force");
530
727
  expect(src).not.toContain("force=true");
531
728
 
729
+ // republish_assets is read via an env-mapped $REPUBLISH_ASSETS, not
730
+ // interpolated directly into the shell script (template-injection guard).
532
731
  const republishBranchIdx = src.search(
533
- /if \[ "\$\{\{ inputs\.republish_assets \}\}" = "true" \]; then/,
732
+ /if \[ "\$REPUBLISH_ASSETS" = "true" \]; then/,
534
733
  );
535
734
  expect(republishBranchIdx).toBeGreaterThanOrEqual(0);
536
735
  const clobberIdx = mustIndex(src, "gh release upload");
@@ -547,25 +746,14 @@ describe("release.yml", () => {
547
746
  expect(src).toMatch(
548
747
  /bunx-smoke:[\s\S]{0,120}if: inputs\.republish_assets != true/,
549
748
  );
550
- expect(src).toMatch(
551
- /bump-version:[\s\S]{0,120}if: inputs\.republish_assets != true/,
552
- );
553
749
  });
554
750
 
555
- test("bump-version fetches and rebases before retrying push", () => {
751
+ test("bump-version job and its auto-commit are gone entirely — version bumps are a manual step for every channel", () => {
556
752
  const src = readText(".github/workflows/release.yml");
557
- const bumpIdx = mustIndex(src, "bump-version:");
558
- const bumpBlock = src.slice(bumpIdx);
559
- const loopIdx = mustIndex(bumpBlock, "for attempt in 1 2 3; do");
560
- const fetchIdx = mustIndex(bumpBlock, "git fetch origin main");
561
- const rebaseIdx = mustIndex(bumpBlock, "git rebase origin/main");
562
- const pushIdx = mustIndex(bumpBlock, "git push origin HEAD:main");
563
- const retryIdx = mustIndex(bumpBlock, "Push rejected on attempt");
564
-
565
- expect(loopIdx).toBeLessThan(fetchIdx);
566
- expect(fetchIdx).toBeLessThan(rebaseIdx);
567
- expect(rebaseIdx).toBeLessThan(pushIdx);
568
- expect(pushIdx).toBeLessThan(retryIdx);
753
+ expect(src).not.toContain("bump-version:");
754
+ expect(src).not.toContain("git push origin HEAD:main");
755
+ expect(src).not.toContain("Bumped version:");
756
+ expect(src).not.toContain("Promoted [Unreleased]");
569
757
  });
570
758
 
571
759
  test("publishes to npm", () => {
@@ -577,8 +765,7 @@ describe("release.yml", () => {
577
765
  test("bunx-smoke covers windows with bash steps and a runner temp log", () => {
578
766
  const src = readText(".github/workflows/release.yml");
579
767
  const bunxIdx = mustIndex(src, "bunx-smoke:");
580
- const bumpIdx = mustIndex(src, "bump-version:");
581
- const bunxBlock = src.slice(bunxIdx, bumpIdx);
768
+ const bunxBlock = src.slice(bunxIdx);
582
769
 
583
770
  expect(bunxBlock).toContain("windows-latest");
584
771
  expect(bunxBlock).toContain("shell: bash");
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const ROOT = join(import.meta.dirname, "..");
6
+
7
+ function readText(relPath: string): string {
8
+ return readFileSync(join(ROOT, relPath), "utf-8");
9
+ }
10
+
11
+ describe("restraml GitHub version discovery", () => {
12
+ test("uses authenticated GitHub helper for API version discovery", () => {
13
+ const src = readText("src/restraml.ts");
14
+
15
+ expect(src).toContain('from "./github.ts"');
16
+ expect(src).toContain("fetchGitHub(RESTRAML_API_CONTENTS_URL");
17
+ expect(src).toContain("headers: githubApiHeaders()");
18
+ });
19
+
20
+ test("release.yml passes GITHUB_TOKEN to extract-all-versions", () => {
21
+ const yml = readText(".github/workflows/release.yml");
22
+ const commandIdx = yml.indexOf("Extract command tree");
23
+ const devicesIdx = yml.indexOf("Extract devices");
24
+ expect(commandIdx).toBeGreaterThanOrEqual(0);
25
+ expect(devicesIdx).toBeGreaterThan(commandIdx);
26
+
27
+ const block = yml.slice(commandIdx, devicesIdx);
28
+ expect(block).toContain("GITHUB_TOKEN: $" + "{{ github.token }}");
29
+ expect(block).toContain("bun run src/extract-all-versions.ts");
30
+ });
31
+ });