From 2709fc02b96da73d1b23e496685e9df5fe4ddda7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:10:09 -0700 Subject: [PATCH 1/6] fix(release): wait for required packages before publishing consumers --- .github/workflows/publish.yml | 4 +- scripts/release/reconcile-package.mjs | 75 +++++++++ scripts/release/reconcile-package.test.mjs | 171 +++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a72d3bda..93f67b6d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -727,7 +727,9 @@ jobs: --run-id "$RELEASE_RUN_ID" \ --run-attempt "$RELEASE_RUN_ATTEMPT" - # Publish all packages in parallel after the read-only barrier. + # Publish in parallel after the read-only barrier. Reconciliation waits for + # required internal dependencies to become registry-visible before exposing + # their consumers (e.g. core -> sdk -> cli), avoiding public ETARGET windows. publish-packages: name: Publish ${{ matrix.package }} needs: [build, build-mount-binaries, preflight-packages] diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index 0ded1cfc..e17c1489 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -45,6 +45,10 @@ export const REGISTRY_FETCH_RETRY_MIN_TIMEOUT_MS = 1000; export const REGISTRY_FETCH_RETRY_MAX_TIMEOUT_MS = 5000; // Keep post-publish registry verification bounded when npm stays unavailable. export const MAX_TOTAL_RETRY_DELAY_MS = 5 * 60 * 1000; +// Parallel publish jobs can finish several minutes apart. Do not expose an +// installable consumer until the registry can resolve its lockstep dependencies. +export const DEPENDENCY_ATTEMPTS = 30; +export const DEPENDENCY_WAIT_BUDGET_MS = 10 * 60 * 1000; export function backoffDelay({ attempt, @@ -275,6 +279,65 @@ async function packPackage({ packageDir, npm = run }) { ); } +async function waitForInternalDependencies({ + manifest, + packageDir, + npm, + sleep, + now, + attempts, + budgetMs, + queryTimeoutMs, +}) { + const startedAt = now(); + let consumedMs = 0; + // Optional platform packages may be absent by design, and dev dependencies + // are not part of the published install contract. Required internal packages + // are versioned together by the release workflow, so demand exact pins. + for (const [name, version] of Object.entries(manifest.dependencies ?? {})) { + if (!name.startsWith("@relayfile/")) continue; + if ( + typeof version !== "string" || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) + ) { + throw new Error( + `internal dependency ${name} must be exactly pinned; refusing to release`, + ); + } + let ready = false; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const remainingMs = budgetMs - Math.max(consumedMs, now() - startedAt); + if (remainingMs <= 0) break; + const queryStartedAt = now(); + const result = await queryRegistry({ + name, + version, + cwd: packageDir, + npm, + timeoutMs: Math.min(queryTimeoutMs, remainingMs), + }); + consumedMs += Math.max(0, now() - queryStartedAt); + if (result.kind === "present") { + ready = true; + break; + } + if (attempt === attempts) break; + const delayMs = Math.min( + backoffDelay({ attempt }), + budgetMs - Math.max(consumedMs, now() - startedAt), + ); + if (delayMs <= 0) break; + const sleepStartedAt = now(); + await sleep(delayMs); + consumedMs += Math.max(delayMs, now() - sleepStartedAt); + } + if (!ready) + throw new Error( + `internal dependency ${name}@${version} not visible in registry; refusing to publish ${manifest.name}@${manifest.version}`, + ); + } +} + function parseArgs(argv) { const values = {}; for (let i = 0; i < argv.length; i += 1) { @@ -302,6 +365,8 @@ export async function reconcilePackage({ maxDelayMs = MAX_DELAY_MS, maxTotalRetryDelayMs = MAX_TOTAL_RETRY_DELAY_MS, registryQueryTimeoutMs = REGISTRY_QUERY_TIMEOUT_MS, + dependencyAttempts = DEPENDENCY_ATTEMPTS, + dependencyWaitBudgetMs = DEPENDENCY_WAIT_BUDGET_MS, now = () => performance.now(), }) { const manifest = JSON.parse( @@ -357,6 +422,16 @@ export async function reconcilePackage({ // publish, retaining the race-safe check after the barrier. status = "absent"; } else { + await waitForInternalDependencies({ + manifest, + packageDir, + npm, + sleep, + now, + attempts: dependencyAttempts, + budgetMs: dependencyWaitBudgetMs, + queryTimeoutMs: registryQueryTimeoutMs, + }); const published = await npm( "npm", [ diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 2ff5df30..896217c1 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -67,6 +67,177 @@ function fakeNpm({ state, registry, viewError, onView }) { }; } +test("consumer publication waits until its required internal dependency is visible", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3", external: "^1.0.0" }, + optionalDependencies: { "@relayfile/mount-linux-arm64": "1.2.3" }, + devDependencies: { "@relayfile/sdk": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let dependencyViews = 0; + const waits = []; + const npm = async (command, args, options) => { + if (args[0] === "view" && args[1] === "@relayfile/core@1.2.3") { + dependencyViews += 1; + return dependencyViews === 1 + ? { code: 1, stdout: "", stderr: "npm error code E404" } + : { + code: 0, + stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stderr: "", + }; + } + if (args[0] === "view" && state.publishes === 0) { + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + if (args[0] === "publish") assert.equal(dependencyViews, 2); + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + await reconcilePackage({ + packageDir: dir, + tag: "latest", + sourceSha: "a".repeat(40), + npm, + sleep: async (ms) => waits.push(ms), + }); + assert.equal(state.publishes, 1); + assert.equal(dependencyViews, 2); + assert.deepEqual(waits, [5000]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("missing or ambiguous internal dependencies cannot publish a consumer", async () => { + for (const dependencyError of [ + "npm error code E404", + "npm error code E503", + ]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + const npm = async (command, args, options) => { + if (args[0] === "view") + return { + code: 1, + stdout: "", + stderr: + args[1] === "@relayfile/core@1.2.3" + ? dependencyError + : "npm error code E404", + }; + return fakeNpm({ state })(command, args, options); + }; + try { + await assert.rejects( + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + dependencyAttempts: 2, + sleep: async () => {}, + }), + /registry query|dependency.*not visible/, + ); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("read-only preflight and dry run do not wait on unpublished release dependencies", async () => { + for (const flags of [{ preflight: true }, { dryRun: true }]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + const npm = fakeNpm({ + state, + viewError: "npm error code E404", + onView: (args) => assert.equal(args[1], "@relayfile/test@1.2.3"), + }); + try { + await reconcilePackage({ packageDir: dir, tag: "latest", npm, ...flags }); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("dependency queries and retry delays share one bounded wait budget", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let elapsedMs = 0; + const queryCaps = []; + const waits = []; + const npm = async (command, args, options) => { + if (args[0] === "view") { + if (args[1] === "@relayfile/core@1.2.3") { + queryCaps.push(options.timeout); + elapsedMs += 3000; + } + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + return fakeNpm({ state })(command, args, options); + }; + try { + await assert.rejects( + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + dependencyWaitBudgetMs: 12000, + now: () => elapsedMs, + sleep: async (ms) => { + waits.push(ms); + elapsedMs += ms; + }, + }), + /dependency.*not visible/, + ); + assert.deepEqual(queryCaps, [12000, 4000]); + assert.deepEqual(waits, [5000, 1000]); + assert.equal(elapsedMs, 12000); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("reconciliation publishes an absent version and verifies it afterwards", async () => { const dir = sandbox(); const state = { views: 0, publishes: 0 }; From 4e1122adc745bf4ab3e2523b82db72c112b5ea47 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:14:45 -0700 Subject: [PATCH 2/6] fix(release): include required SDK peers and shared semver grammar --- scripts/release/reconcile-package.mjs | 25 +++--- scripts/release/reconcile-package.test.mjs | 92 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index e17c1489..ffccbfe8 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -32,6 +32,7 @@ import { isOptionalSha1Shasum, isOptionalSha512Integrity, } from "./create-release-attestation.mjs"; +import { parseStrictVersion } from "./resolve-release-baseline.mjs"; export const DEFAULT_ATTEMPTS = 10; export const DEFAULT_DELAY_MS = 5000; @@ -291,17 +292,23 @@ async function waitForInternalDependencies({ }) { const startedAt = now(); let consumedMs = 0; - // Optional platform packages may be absent by design, and dev dependencies - // are not part of the published install contract. Required internal packages - // are versioned together by the release workflow, so demand exact pins. - for (const [name, version] of Object.entries(manifest.dependencies ?? {})) { + // npm auto-installs required peers too (agents -> sdk). Optional platform + // packages/peers and dev deps are not part of this required install contract. + const required = [ + ...Object.entries(manifest.dependencies ?? {}), + ...Object.entries(manifest.peerDependencies ?? {}).filter( + ([name]) => manifest.peerDependenciesMeta?.[name]?.optional !== true, + ), + ]; + for (const [name, range] of required) { if (!name.startsWith("@relayfile/")) continue; - if ( - typeof version !== "string" || - !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) - ) { + // The workflow writes bare dependency pins and ^version peer floors. + // Waiting on the floor itself avoids accidentally accepting an older SDK. + const version = + typeof range === "string" ? range.replace(/^[=^~]/, "") : ""; + if (!parseStrictVersion(version)) { throw new Error( - `internal dependency ${name} must be exactly pinned; refusing to release`, + `internal dependency ${name} must name an exact version or version floor; refusing to release`, ); } let ready = false; diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 896217c1..e0eb6e32 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -238,6 +238,98 @@ test("dependency queries and retry delays share one bounded wait budget", async } }); +test("required SDK peers wait on their version floor, optional peers do not", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + peerDependencies: { + "@relayfile/sdk": "^1.2.3", + "@relayfile/optional": "^1.2.3", + }, + peerDependenciesMeta: { "@relayfile/optional": { optional: true } }, + }), + ); + const state = { views: 0, publishes: 0 }; + let sdkViews = 0; + const npm = async (command, args, options) => { + if (args[0] === "view") { + assert.notEqual(args[1], "@relayfile/optional@1.2.3"); + if (args[1] === "@relayfile/sdk@1.2.3") { + sdkViews += 1; + if (sdkViews === 1) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + return { + code: 0, + stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stderr: "", + }; + } + if (state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + if (args[0] === "publish") assert.equal(sdkViews, 2); + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + await reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + sleep: async () => {}, + }); + assert.equal(state.publishes, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("dependency pins preserve valid prerelease and build metadata", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3-rc.1+build.1" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let dependencySeen = false; + const npm = async (command, args, options) => { + if (args[0] === "view") { + if (args[1] === "@relayfile/core@1.2.3-rc.1+build.1") { + dependencySeen = true; + return { + code: 0, + stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stderr: "", + }; + } + if (state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + if (args[0] === "publish") assert.equal(dependencySeen, true); + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + await reconcilePackage({ packageDir: dir, tag: "latest", npm }); + assert.equal(state.publishes, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("reconciliation publishes an absent version and verifies it afterwards", async () => { const dir = sandbox(); const state = { views: 0, publishes: 0 }; From 746b72938e655e7580eac4f6738c13c43f64caec Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:21:55 -0700 Subject: [PATCH 3/6] test(release): guard oversleeps and invalid dependency targets --- scripts/release/reconcile-package.test.mjs | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index e0eb6e32..50fa8c3c 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -290,6 +290,79 @@ test("required SDK peers wait on their version floor, optional peers do not", as } }); +test("an overscheduled dependency sleep never starts another query or publish", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let elapsedMs = 0; + let dependencyQueries = 0; + const waits = []; + const npm = async (command, args, options) => { + if (args[0] === "view") { + if (args[1] === "@relayfile/core@1.2.3") dependencyQueries += 1; + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + return fakeNpm({ state })(command, args, options); + }; + try { + await assert.rejects( + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + dependencyWaitBudgetMs: 12000, + now: () => elapsedMs, + sleep: async (ms) => { + waits.push(ms); + elapsedMs += 20000; + }, + }), + /dependency.*not visible/, + ); + assert.deepEqual(waits, [5000]); + assert.equal(dependencyQueries, 1); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("unpinnable or non-string required internal specs cannot publish", async () => { + for (const range of [">=1.2.0", "1.x", "latest", 123, null]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": range }, + }), + ); + const state = { views: 0, publishes: 0 }; + const npm = fakeNpm({ + state, + viewError: "npm error code E404", + onView: (args) => assert.equal(args[1], "@relayfile/test@1.2.3"), + }); + try { + await assert.rejects( + reconcilePackage({ packageDir: dir, tag: "latest", npm }), + /must name an exact version or version floor/, + ); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + test("dependency pins preserve valid prerelease and build metadata", async () => { const dir = sandbox(); writeFileSync( From 72388d55af827d68b929c91b4eeaae2effdc6edb Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:27:22 -0700 Subject: [PATCH 4/6] fix(release): retry transient dependency registry failures --- scripts/release/reconcile-package.mjs | 29 +++++++--- scripts/release/reconcile-package.test.mjs | 66 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index ffccbfe8..6a284b46 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -221,6 +221,10 @@ export function comparePackageContent(local, registry) { return { kind: "identical" }; } +// Command failures can be transient; a successful but malformed metadata +// response is a separate trust failure and must never be retried into a pass. +class RegistryQueryError extends Error {} + async function queryRegistry({ name, version, @@ -246,7 +250,7 @@ async function queryRegistry({ if (result.code !== 0) { const kind = registryErrorKind(result); if (kind === "absent") return { kind: "absent" }; - throw new Error( + throw new RegistryQueryError( `registry query for ${name}@${version} was ambiguous; refusing to release`, ); } @@ -316,15 +320,22 @@ async function waitForInternalDependencies({ const remainingMs = budgetMs - Math.max(consumedMs, now() - startedAt); if (remainingMs <= 0) break; const queryStartedAt = now(); - const result = await queryRegistry({ - name, - version, - cwd: packageDir, - npm, - timeoutMs: Math.min(queryTimeoutMs, remainingMs), - }); + let result; + try { + result = await queryRegistry({ + name, + version, + cwd: packageDir, + npm, + timeoutMs: Math.min(queryTimeoutMs, remainingMs), + }); + } catch (error) { + if (!(error instanceof RegistryQueryError)) throw error; + // Still closed to publication. Retry command failures only; parsing + // errors and missing/invalid integrity remain immediately fatal. + } consumedMs += Math.max(0, now() - queryStartedAt); - if (result.kind === "present") { + if (result?.kind === "present") { ready = true; break; } diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 50fa8c3c..07f634b8 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -164,6 +164,72 @@ test("missing or ambiguous internal dependencies cannot publish a consumer", asy } }); +test("transient dependency query failures retry but malformed metadata aborts", async () => { + for (const mode of ["timeout", "bad-metadata"]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let dependencyQueries = 0; + const waits = []; + const npm = async (command, args, options) => { + if (args[0] === "view") { + if (args[1] === "@relayfile/core@1.2.3") { + dependencyQueries += 1; + if (mode === "bad-metadata") + return { + code: 0, + stdout: JSON.stringify({ integrity: "invalid" }), + stderr: "", + }; + if (dependencyQueries === 1) + return { code: 1, stdout: "", stderr: "npm error code ETIMEDOUT" }; + return { + code: 0, + stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stderr: "", + }; + } + if (state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + if (args[0] === "publish") assert.equal(dependencyQueries, 2); + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + const invoke = () => + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + sleep: async (ms) => waits.push(ms), + }); + if (mode === "timeout") { + await invoke(); + assert.equal(state.publishes, 1); + assert.deepEqual(waits, [5000]); + } else { + await assert.rejects(invoke(), /no usable digest/); + assert.equal(dependencyQueries, 1); + assert.equal(state.publishes, 0); + assert.deepEqual(waits, []); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + test("read-only preflight and dry run do not wait on unpublished release dependencies", async () => { for (const flags of [{ preflight: true }, { dryRun: true }]) { const dir = sandbox(); From caa77908404780133695bc1fdd528cecc3f50704 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:30:42 -0700 Subject: [PATCH 5/6] fix(release): verify transitive internal dependency closure --- scripts/release/reconcile-package.mjs | 63 ++++++-- scripts/release/reconcile-package.test.mjs | 163 ++++++++++++++++++++- 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index 6a284b46..5ee8baba 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -50,6 +50,7 @@ export const MAX_TOTAL_RETRY_DELAY_MS = 5 * 60 * 1000; // installable consumer until the registry can resolve its lockstep dependencies. export const DEPENDENCY_ATTEMPTS = 30; export const DEPENDENCY_WAIT_BUDGET_MS = 10 * 60 * 1000; +const MAX_INTERNAL_DEPENDENCY_NODES = 128; export function backoffDelay({ attempt, @@ -231,13 +232,14 @@ async function queryRegistry({ cwd, npm = run, timeoutMs = REGISTRY_QUERY_TIMEOUT_MS, + includeManifest = false, }) { const result = await npm( "npm", [ "view", `${name}@${version}`, - "dist", + ...(includeManifest ? [] : ["dist"]), "--json", "--prefer-online", "--no-fund", @@ -268,7 +270,15 @@ async function queryRegistry({ throw new Error( `registry metadata for ${name}@${version} has no usable digest; refusing to release`, ); - return { kind: "present", record }; + if (includeManifest && (raw?.name !== name || raw?.version !== version)) + throw new Error( + `registry manifest identity mismatch for ${name}@${version}; refusing to release`, + ); + return { + kind: "present", + record, + manifest: includeManifest ? raw : undefined, + }; } async function packPackage({ packageDir, npm = run }) { @@ -296,15 +306,10 @@ async function waitForInternalDependencies({ }) { const startedAt = now(); let consumedMs = 0; - // npm auto-installs required peers too (agents -> sdk). Optional platform - // packages/peers and dev deps are not part of this required install contract. - const required = [ - ...Object.entries(manifest.dependencies ?? {}), - ...Object.entries(manifest.peerDependencies ?? {}).filter( - ([name]) => manifest.peerDependenciesMeta?.[name]?.optional !== true, - ), - ]; - for (const [name, range] of required) { + const required = requiredInternalDependencies(manifest); + const visited = new Set(); + for (let index = 0; index < required.length; index += 1) { + const [name, range] = required[index]; if (!name.startsWith("@relayfile/")) continue; // The workflow writes bare dependency pins and ^version peer floors. // Waiting on the floor itself avoids accidentally accepting an older SDK. @@ -315,6 +320,13 @@ async function waitForInternalDependencies({ `internal dependency ${name} must name an exact version or version floor; refusing to release`, ); } + const key = `${name}@${version}`; + if (visited.has(key)) continue; + visited.add(key); + if (visited.size > MAX_INTERNAL_DEPENDENCY_NODES) + throw new Error( + "internal dependency closure exceeds safety limit; refusing to release", + ); let ready = false; for (let attempt = 1; attempt <= attempts; attempt += 1) { const remainingMs = budgetMs - Math.max(consumedMs, now() - startedAt); @@ -328,6 +340,7 @@ async function waitForInternalDependencies({ cwd: packageDir, npm, timeoutMs: Math.min(queryTimeoutMs, remainingMs), + includeManifest: true, }); } catch (error) { if (!(error instanceof RegistryQueryError)) throw error; @@ -336,6 +349,9 @@ async function waitForInternalDependencies({ } consumedMs += Math.max(0, now() - queryStartedAt); if (result?.kind === "present") { + // A prior partial release may already expose sdk while core is absent. + // Verify the registry-owned transitive contract, not just sdk's tarball. + required.push(...requiredInternalDependencies(result.manifest)); ready = true; break; } @@ -356,6 +372,31 @@ async function waitForInternalDependencies({ } } +function requiredInternalDependencies(manifest) { + for (const field of [ + "dependencies", + "peerDependencies", + "peerDependenciesMeta", + ]) { + const value = manifest[field]; + if ( + value !== undefined && + (!value || typeof value !== "object" || Array.isArray(value)) + ) + throw new Error( + `invalid ${field} in registry manifest; refusing to release`, + ); + } + // npm auto-installs required peers. Optional platform packages/peers and dev + // deps are not part of this required install contract. + return [ + ...Object.entries(manifest.dependencies ?? {}), + ...Object.entries(manifest.peerDependencies ?? {}).filter( + ([name]) => manifest.peerDependenciesMeta?.[name]?.optional !== true, + ), + ].filter(([name]) => name.startsWith("@relayfile/")); +} + function parseArgs(argv) { const values = {}; for (let i = 0; i < argv.length; i += 1) { diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 07f634b8..5f10c7ad 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -22,6 +22,155 @@ const OTHER_INTEGRITY = `sha512-${"A".repeat(85)}Q==`; const VALID_SHASUM = "a".repeat(40); const OTHER_SHASUM = "b".repeat(40); +function dependencyMetadata(name, version, fields = {}) { + return { name, version, dist: { integrity: VALID_INTEGRITY }, ...fields }; +} + +test("an already-published SDK cannot expose CLI until its transitive core is ready", async () => { + for (const becomesReady of [true, false]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/sdk": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let coreQueries = 0; + const npm = async (command, args, options) => { + if (args[0] === "view" && args[1] === "@relayfile/sdk@1.2.3") { + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata("@relayfile/sdk", "1.2.3", { + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ), + stderr: "", + }; + } + if (args[0] === "view" && args[1] === "@relayfile/core@1.2.3") { + coreQueries++; + if (!becomesReady || coreQueries === 1) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata("@relayfile/core", "1.2.3"), + ), + stderr: "", + }; + } + if (args[0] === "view" && state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + if (args[0] === "publish") assert.equal(coreQueries, 2); + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + const invoke = () => + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + dependencyAttempts: 2, + sleep: async () => {}, + }); + if (becomesReady) { + await invoke(); + assert.equal(state.publishes, 1); + } else { + await assert.rejects(invoke(), /core.*not visible/); + assert.equal(state.publishes, 0); + } + assert.equal(coreQueries, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("transitive required peers are checked once and malformed contracts fail closed", async () => { + for (const mode of ["cycle", "invalid-map", "wrong-identity"]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/sdk": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + const seen = []; + const npm = async (command, args, options) => { + if (args[0] === "view" && args[1] !== "@relayfile/test@1.2.3") { + assert.equal(args.includes("dist"), false); + seen.push(args[1]); + const sdk = args[1] === "@relayfile/sdk@1.2.3"; + const fields = sdk + ? { + peerDependencies: + mode === "invalid-map" + ? [] + : { + "@relayfile/core": "^1.2.3", + "@relayfile/optional": "^1.2.3", + }, + peerDependenciesMeta: { + "@relayfile/optional": { optional: true }, + }, + } + : { dependencies: { "@relayfile/sdk": "1.2.3" } }; + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata( + sdk ? "@relayfile/sdk" : "@relayfile/core", + mode === "wrong-identity" ? "1.2.4" : "1.2.3", + fields, + ), + ), + stderr: "", + }; + } + if (args[0] === "view" && state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + const invoke = () => + reconcilePackage({ packageDir: dir, tag: "latest", npm }); + if (mode === "cycle") { + await invoke(); + assert.equal(state.publishes, 1); + assert.deepEqual(seen, [ + "@relayfile/sdk@1.2.3", + "@relayfile/core@1.2.3", + ]); + } else { + await assert.rejects( + invoke(), + /invalid peerDependencies|identity mismatch/, + ); + assert.equal(state.publishes, 0); + assert.equal(seen.length, 1); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + function sandbox() { const dir = mkdtempSync(join(tmpdir(), "relayfile-reconcile-")); writeFileSync( @@ -89,7 +238,9 @@ test("consumer publication waits until its required internal dependency is visib ? { code: 1, stdout: "", stderr: "npm error code E404" } : { code: 0, - stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stdout: JSON.stringify( + dependencyMetadata("@relayfile/core", "1.2.3"), + ), stderr: "", }; } @@ -192,7 +343,9 @@ test("transient dependency query failures retry but malformed metadata aborts", return { code: 1, stdout: "", stderr: "npm error code ETIMEDOUT" }; return { code: 0, - stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stdout: JSON.stringify( + dependencyMetadata("@relayfile/core", "1.2.3"), + ), stderr: "", }; } @@ -329,7 +482,7 @@ test("required SDK peers wait on their version floor, optional peers do not", as return { code: 1, stdout: "", stderr: "npm error code E404" }; return { code: 0, - stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stdout: JSON.stringify(dependencyMetadata("@relayfile/sdk", "1.2.3")), stderr: "", }; } @@ -447,7 +600,9 @@ test("dependency pins preserve valid prerelease and build metadata", async () => dependencySeen = true; return { code: 0, - stdout: JSON.stringify({ integrity: VALID_INTEGRITY }), + stdout: JSON.stringify( + dependencyMetadata("@relayfile/core", "1.2.3-rc.1+build.1"), + ), stderr: "", }; } From d4a67a94d767339f4cd5c7e18b7481cd3bf27c7a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 6 Oct 2026 06:40:45 -0700 Subject: [PATCH 6/6] test(release): pin closure bounds and outage diagnostics --- scripts/release/reconcile-package.mjs | 7 +- scripts/release/reconcile-package.test.mjs | 119 ++++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index 5ee8baba..7ed3a585 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -328,6 +328,7 @@ async function waitForInternalDependencies({ "internal dependency closure exceeds safety limit; refusing to release", ); let ready = false; + let lastFailure = "absent"; for (let attempt = 1; attempt <= attempts; attempt += 1) { const remainingMs = budgetMs - Math.max(consumedMs, now() - startedAt); if (remainingMs <= 0) break; @@ -342,8 +343,10 @@ async function waitForInternalDependencies({ timeoutMs: Math.min(queryTimeoutMs, remainingMs), includeManifest: true, }); + lastFailure = "absent"; } catch (error) { if (!(error instanceof RegistryQueryError)) throw error; + lastFailure = "ambiguous"; // Still closed to publication. Retry command failures only; parsing // errors and missing/invalid integrity remain immediately fatal. } @@ -367,7 +370,9 @@ async function waitForInternalDependencies({ } if (!ready) throw new Error( - `internal dependency ${name}@${version} not visible in registry; refusing to publish ${manifest.name}@${manifest.version}`, + lastFailure === "ambiguous" + ? `registry queries for ${key} stayed ambiguous within the wait budget; refusing to publish ${manifest.name}@${manifest.version}` + : `internal dependency ${key} not visible in registry; refusing to publish ${manifest.name}@${manifest.version}`, ); } } diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 5f10c7ad..9ebbad8e 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -171,6 +171,121 @@ test("transitive required peers are checked once and malformed contracts fail cl } }); +test("transitive dependency waits consume the original shared budget", async () => { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/sdk": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let elapsed = 0; + const caps = []; + const waits = []; + const npm = async (command, args, options) => { + if (args[0] === "view" && args[1] === "@relayfile/sdk@1.2.3") { + assert.equal(options.timeout, 12000); + elapsed += 8000; + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata("@relayfile/sdk", "1.2.3", { + dependencies: { "@relayfile/core": "1.2.3" }, + }), + ), + stderr: "", + }; + } + if (args[0] === "view" && args[1] === "@relayfile/core@1.2.3") { + caps.push(options.timeout); + elapsed += 3000; + return { code: 1, stdout: "", stderr: "npm error code E404" }; + } + if (args[0] === "view") + return { code: 1, stdout: "", stderr: "npm error code E404" }; + return fakeNpm({ state })(command, args, options); + }; + try { + await assert.rejects( + reconcilePackage({ + packageDir: dir, + tag: "latest", + npm, + dependencyWaitBudgetMs: 12000, + now: () => elapsed, + sleep: async (ms) => { + waits.push(ms); + elapsed += ms; + }, + }), + /core.*not visible/, + ); + assert.deepEqual(caps, [4000]); + assert.deepEqual(waits, [1000]); + assert.equal(elapsed, 12000); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("registry dependency expansion is bounded at the node limit", async () => { + for (const count of [128, 129]) { + const dir = sandbox(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "@relayfile/test", + version: "1.2.3", + dependencies: { "@relayfile/node-0": "1.2.3" }, + }), + ); + const state = { views: 0, publishes: 0 }; + let queries = 0; + const npm = async (command, args, options) => { + if (args[0] === "view" && args[1].startsWith("@relayfile/node-")) { + queries++; + const name = args[1].split("@")[1]; + const index = Number(name.split("-").at(-1)); + const next = index + 1 < count ? index + 1 : 0; + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata(`@${name}`, "1.2.3", { + dependencies: { [`@relayfile/node-${next}`]: "1.2.3" }, + }), + ), + stderr: "", + }; + } + if (args[0] === "view" && state.publishes === 0) + return { code: 1, stdout: "", stderr: "npm error code E404" }; + return fakeNpm({ state, registry: { integrity: VALID_INTEGRITY } })( + command, + args, + options, + ); + }; + try { + const invoke = () => + reconcilePackage({ packageDir: dir, tag: "latest", npm }); + if (count === 128) { + await invoke(); + assert.equal(state.publishes, 1); + } else { + await assert.rejects(invoke(), /closure exceeds safety limit/); + assert.equal(state.publishes, 0); + } + assert.equal(queries, 128); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + function sandbox() { const dir = mkdtempSync(join(tmpdir(), "relayfile-reconcile-")); writeFileSync( @@ -306,7 +421,9 @@ test("missing or ambiguous internal dependencies cannot publish a consumer", asy dependencyAttempts: 2, sleep: async () => {}, }), - /registry query|dependency.*not visible/, + dependencyError.includes("E404") + ? /dependency.*not visible/ + : /registry queries.*stayed ambiguous/, ); assert.equal(state.publishes, 0); } finally {