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..7ed3a585 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; @@ -45,6 +46,11 @@ 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; +const MAX_INTERNAL_DEPENDENCY_NODES = 128; export function backoffDelay({ attempt, @@ -216,19 +222,24 @@ 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, 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", @@ -241,7 +252,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`, ); } @@ -259,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 }) { @@ -275,6 +294,114 @@ async function packPackage({ packageDir, npm = run }) { ); } +async function waitForInternalDependencies({ + manifest, + packageDir, + npm, + sleep, + now, + attempts, + budgetMs, + queryTimeoutMs, +}) { + const startedAt = now(); + let consumedMs = 0; + 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. + const version = + typeof range === "string" ? range.replace(/^[=^~]/, "") : ""; + if (!parseStrictVersion(version)) { + throw new Error( + `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; + let lastFailure = "absent"; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const remainingMs = budgetMs - Math.max(consumedMs, now() - startedAt); + if (remainingMs <= 0) break; + const queryStartedAt = now(); + let result; + try { + result = await queryRegistry({ + name, + version, + cwd: packageDir, + npm, + 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. + } + 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; + } + 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( + 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}`, + ); + } +} + +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) { @@ -302,6 +429,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 +486,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..9ebbad8e 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -22,6 +22,270 @@ 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 }); + } + } +}); + +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( @@ -67,6 +331,416 @@ 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( + 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(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 () => {}, + }), + dependencyError.includes("E404") + ? /dependency.*not visible/ + : /registry queries.*stayed ambiguous/, + ); + assert.equal(state.publishes, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +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( + dependencyMetadata("@relayfile/core", "1.2.3"), + ), + 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(); + 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("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(dependencyMetadata("@relayfile/sdk", "1.2.3")), + 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("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( + 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( + dependencyMetadata("@relayfile/core", "1.2.3-rc.1+build.1"), + ), + 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 };