@tikoci/rosetta 0.10.0 → 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.
- package/README.md +15 -0
- package/package.json +1 -1
- package/src/db.ts +10 -0
- package/src/eval/retrieval.ts +16 -12
- package/src/extract-docusaurus.test.ts +251 -0
- package/src/extract-docusaurus.ts +643 -0
- package/src/extract-skills.test.ts +41 -2
- package/src/extract-skills.ts +9 -13
- package/src/github.test.ts +70 -0
- package/src/github.ts +67 -0
- package/src/mcp-stdio-client.test.ts +34 -5
- package/src/paths.ts +4 -1
- package/src/release.test.ts +220 -33
- package/src/restraml.test.ts +31 -0
- package/src/restraml.ts +7 -5
- package/src/rosetta-id.test.ts +95 -0
- package/src/rosetta-id.ts +151 -0
package/src/extract-skills.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
102
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 =
|
|
96
|
+
export const SCHEMA_VERSION = 6;
|
|
94
97
|
|
|
95
98
|
/**
|
|
96
99
|
* Resolve the version string.
|
package/src/release.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
649
|
+
const preflightBlock = src.slice(preflightIdx, extractIdx);
|
|
453
650
|
|
|
454
|
-
expect(preflightIdx).toBeLessThan(
|
|
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 \[ "
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
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
|
+
});
|