diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 61bd7457..a3a51bd4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -881,19 +881,6 @@ jobs: - name: Update npm for OIDC support run: npm install -g npm@11 - # Fail closed: never publish a dependent before every internal - # @relayfile/* dependency it pins resolves on the registry. The matrix is - # parallel, so this is what orders sdk/client/cli-* after core. - - name: Assert internal dependencies are published - env: - RELEASE_DRY_RUN: ${{ needs.build.outputs.release_dry_run }} - run: | - set -euo pipefail - node "$GITHUB_WORKSPACE/scripts/release/assert-internal-deps.mjs" \ - --package-dir "$GITHUB_WORKSPACE/${{ matrix.path }}" \ - --repo-root "$GITHUB_WORKSPACE" \ - --dry-run "$RELEASE_DRY_RUN" - - name: Reconcile package with NPM env: NPM_TAG: ${{ github.event.inputs.tag }} @@ -1029,19 +1016,6 @@ jobs: - name: Update npm for OIDC support run: npm install -g npm@11 - # Fail closed: never publish a dependent before every internal - # @relayfile/* dependency it pins resolves on the registry. The matrix is - # parallel, so this is what orders sdk/client/cli-* after core. - - name: Assert internal dependencies are published - env: - RELEASE_DRY_RUN: ${{ needs.build.outputs.release_dry_run }} - run: | - set -euo pipefail - node "$GITHUB_WORKSPACE/scripts/release/assert-internal-deps.mjs" \ - --package-dir "$GITHUB_WORKSPACE/${{ steps.resolve-package.outputs.path }}" \ - --repo-root "$GITHUB_WORKSPACE" \ - --dry-run "$RELEASE_DRY_RUN" - - name: Reconcile package with NPM env: NPM_TAG: ${{ github.event.inputs.tag }} diff --git a/scripts/release/assert-internal-deps.mjs b/scripts/release/assert-internal-deps.mjs deleted file mode 100644 index 7ab3fbc7..00000000 --- a/scripts/release/assert-internal-deps.mjs +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env node -/** - * Fail-closed publish gate for internal @relayfile/* dependencies. - * - * The publish matrix runs in parallel, so nothing orders a dependent after the - * package it pins. On 2026-10-06 (run 37396747701, attempt 1) @relayfile/sdk - * and every other dependent published while @relayfile/core@0.10.73 failed its - * post-publish verification, leaving `npm install` of sdk@latest unresolvable - * (ETARGET) until core was re-run. - * - * Before a package may be published, every internal dependency it pins - * (dependencies, optionalDependencies, peerDependencies) must either - * - * - already resolve on the registry (`npm view name@spec version`), or - * - be produced by this same release (a pin on a release-set package in - * bare, ^, ~ or = form of the release version, per rangeTargetsVersion), - * in which case we WAIT for it to appear on the registry, because - * publishing first would reopen the gap. - * - * Anything else, or a wait that times out, is a hard failure: a dependent is - * never published unless its dependencies are verifiably installable. - */ - -import { execFile } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { RELEASE_PACKAGE_PATHS } from "./resolve-release-baseline.mjs"; -import { rangeTargetsVersion } from "./regenerate-release-lockfiles.mjs"; - -export const INTERNAL_SCOPE = "@relayfile/"; -export const DEP_FIELDS = [ - "dependencies", - "optionalDependencies", - "peerDependencies", -]; -export const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000; -export const DEFAULT_POLL_MS = 15 * 1000; - -/** @returns {{name:string, spec:string, field:string}[]} */ -export function internalDeps(pkg) { - const out = []; - for (const field of DEP_FIELDS) { - for (const [name, spec] of Object.entries(pkg[field] ?? {})) { - if (name.startsWith(INTERNAL_SCOPE)) out.push({ name, spec, field }); - } - } - return out; -} - -/** name -> version for every package this release publishes. */ -export function loadReleaseSet(repoRoot) { - const set = new Map(); - for (const rel of RELEASE_PACKAGE_PATHS) { - if (rel === "package.json") continue; - const file = join(repoRoot, rel); - if (!existsSync(file)) continue; - const pkg = JSON.parse(readFileSync(file, "utf8")); - if (pkg.name) set.set(pkg.name, pkg.version); - } - return set; -} - -export function npmViewResolves(name, spec) { - return new Promise((done) => { - execFile( - "npm", - ["view", `${name}@${spec}`, "version", "--json"], - { timeout: 30000, env: { ...process.env, NPM_CONFIG_FETCH_RETRIES: "1" } }, - (error, stdout) => done(!error && stdout.trim().length > 0), - ); - }); -} - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -/** - * @returns {Promise<{ok:boolean, problems:string[]}>} - */ -export async function assertInternalDeps({ - pkg, - releaseSet, - dryRun = false, - resolves = npmViewResolves, - timeoutMs = DEFAULT_TIMEOUT_MS, - pollMs = DEFAULT_POLL_MS, - now = () => Date.now(), - wait = sleep, - log = () => {}, -}) { - const problems = []; - for (const { name, spec, field } of internalDeps(pkg)) { - const inSet = rangeTargetsVersion(spec, releaseSet.get(name) ?? ""); - if (await resolves(name, spec)) { - log(`ok ${name}@${spec} (${field}) is on the registry`); - continue; - } - if (!inSet) { - problems.push( - `${pkg.name}@${pkg.version} pins ${name}@${spec} (${field}), which is not on the registry and is not published by this release`, - ); - continue; - } - if (dryRun) { - log(`ok ${name}@${spec} (${field}) is in this release set (dry run, not waiting)`); - continue; - } - const deadline = now() + timeoutMs; - let found = false; - while (now() < deadline) { - log(`waiting for ${name}@${spec} to appear on the registry`); - await wait(pollMs); - if (await resolves(name, spec)) { - found = true; - break; - } - } - if (found) log(`ok ${name}@${spec} (${field}) appeared on the registry`); - else - problems.push( - `${pkg.name}@${pkg.version} must not publish: ${name}@${spec} (${field}) did not appear on the registry within ${Math.round(timeoutMs / 1000)}s`, - ); - } - return { ok: problems.length === 0, problems }; -} - -function parseArgs(argv) { - const values = {}; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`); - values[arg.slice(2)] = argv[++i]; - } - return values; -} - -let entrypoint = ""; -try { - entrypoint = process.argv[1] ? realpathSync(resolve(process.argv[1])) : ""; -} catch { - entrypoint = ""; -} -if (entrypoint && entrypoint === realpathSync(fileURLToPath(import.meta.url))) { - const args = parseArgs(process.argv.slice(2)); - if (!args["package-dir"]) throw new Error("--package-dir is required"); - const repoRoot = resolve(args["repo-root"] ?? process.cwd()); - const pkg = JSON.parse( - readFileSync(join(resolve(args["package-dir"]), "package.json"), "utf8"), - ); - const result = await assertInternalDeps({ - pkg, - releaseSet: loadReleaseSet(repoRoot), - dryRun: args["dry-run"] === "true", - timeoutMs: args["timeout-ms"] - ? Number(args["timeout-ms"]) - : DEFAULT_TIMEOUT_MS, - log: (m) => console.log(m), - }); - if (!result.ok) { - for (const p of result.problems) console.error(`::error::${p}`); - process.exit(1); - } -} diff --git a/scripts/release/assert-internal-deps.test.mjs b/scripts/release/assert-internal-deps.test.mjs deleted file mode 100644 index 2f7585f2..00000000 --- a/scripts/release/assert-internal-deps.test.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - assertInternalDeps, - internalDeps, - loadReleaseSet, -} from "./assert-internal-deps.mjs"; - -const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); -const WORKFLOW = readFileSync(join(REPO, ".github/workflows/publish.yml"), "utf8"); - -const sdk = { - name: "@relayfile/sdk", - version: "0.10.73", - dependencies: { "@relayfile/core": "0.10.73", zod: "^3" }, -}; -const releaseSet = new Map([ - ["@relayfile/core", "0.10.73"], - ["@relayfile/sdk", "0.10.73"], -]); - -function clock() { - let t = 0; - return { now: () => t, wait: async (ms) => { t += ms; } }; -} - -test("a dependent is refused when its in-release dependency never reaches the registry", async () => { - const result = await assertInternalDeps({ - pkg: sdk, - releaseSet, - resolves: async () => false, // core@0.10.73 failed to publish - timeoutMs: 60_000, - pollMs: 10_000, - ...clock(), - }); - assert.equal(result.ok, false); - assert.match(result.problems[0], /@relayfile\/core@0\.10\.73.*did not appear/); -}); - -test("a dependent waits for its in-release dependency and then proceeds", async () => { - let calls = 0; - const c = clock(); - const result = await assertInternalDeps({ - pkg: sdk, - releaseSet, - resolves: async () => ++calls > 3, - timeoutMs: 600_000, - pollMs: 10_000, - ...c, - }); - assert.equal(result.ok, true); - // 1 initial probe + 2 polls that miss + 1 that hits: the fake clock must - // have advanced by exactly three poll intervals, not spun in a tight loop. - assert.equal(calls, 4); - assert.equal(c.now(), 30_000); -}); - -const agents = { - name: "@relayfile/agents", - version: "0.10.73", - peerDependencies: { "@relayfile/sdk": "^0.10.73" }, -}; - -test("a caret peer pin on an in-release package waits, then passes when it appears", async () => { - let calls = 0; - const c = clock(); - const result = await assertInternalDeps({ - pkg: agents, - releaseSet, - resolves: async () => ++calls > 2, - timeoutMs: 600_000, - pollMs: 10_000, - ...c, - }); - assert.equal(result.ok, true, result.problems.join("; ")); - assert.equal(calls, 3); - assert.equal(c.now(), 20_000); -}); - -test("a caret peer pin on an in-release package that never appears fails after the bound", async () => { - const c = clock(); - const result = await assertInternalDeps({ - pkg: agents, - releaseSet, - resolves: async () => false, - timeoutMs: 60_000, - pollMs: 10_000, - ...c, - }); - assert.equal(result.ok, false); - assert.match(result.problems[0], /did not appear on the registry within 60s/); - assert.equal(c.now(), 60_000); -}); - -test("a dependency outside the release that is not on the registry fails immediately", async () => { - const result = await assertInternalDeps({ - pkg: { ...sdk, dependencies: { "@relayfile/ghost": "9.9.9" } }, - releaseSet, - resolves: async () => false, - ...clock(), - }); - assert.equal(result.ok, false); - assert.match(result.problems[0], /not published by this release/); -}); - -test("dry runs accept an in-release dependency without waiting", async () => { - const result = await assertInternalDeps({ - pkg: sdk, - releaseSet, - dryRun: true, - resolves: async () => false, - ...clock(), - }); - assert.equal(result.ok, true); -}); - -test("every internal dependency in the repo pins a release-set package", () => { - const set = loadReleaseSet(REPO); - assert.ok(set.has("@relayfile/core")); - const sdkPkg = JSON.parse( - readFileSync(join(REPO, "packages/sdk/typescript/package.json"), "utf8"), - ); - const deps = internalDeps(sdkPkg).map((d) => d.name); - assert.ok(deps.includes("@relayfile/core")); - for (const name of deps) assert.ok(set.has(name), `${name} not in release set`); -}); - -test("both publish jobs run the dependency gate before reconciling/publishing", () => { - const publishStart = WORKFLOW.indexOf("\n publish-packages:"); - const createRelease = WORKFLOW.indexOf("\n create-release:"); - const singleStart = WORKFLOW.indexOf("\n publish-single:"); - for (const [start, end] of [ - [publishStart, singleStart], - [singleStart, createRelease], - ]) { - const job = WORKFLOW.slice(start, end); - const gate = job.indexOf("scripts/release/assert-internal-deps.mjs"); - const reconcile = job.indexOf("scripts/release/reconcile-package.mjs"); - assert.ok(gate > 0, "dependency gate missing from publish job"); - assert.ok(reconcile > gate, "gate must run before reconcile-package"); - } -}); - -test("publish matrix lists every internal dependency before its dependents", () => { - const start = WORKFLOW.indexOf("\n publish-packages:"); - const end = WORKFLOW.indexOf("\n publish-single:"); - const paths = [...WORKFLOW.slice(start, end).matchAll(/path: (packages\/[^\n]+)/g)].map((m) => m[1]); - const names = paths.map((p) => - JSON.parse(readFileSync(join(REPO, p, "package.json"), "utf8")).name, - ); - names.forEach((name, i) => { - const pkg = JSON.parse(readFileSync(join(REPO, paths[i], "package.json"), "utf8")); - for (const dep of internalDeps(pkg)) { - const j = names.indexOf(dep.name); - if (j >= 0) assert.ok(j < i, `${name} is scheduled before its dependency ${dep.name}`); - } - }); -}); diff --git a/scripts/release/publish-workflow.test.mjs b/scripts/release/publish-workflow.test.mjs index ebc69f7f..7f3aa696 100644 --- a/scripts/release/publish-workflow.test.mjs +++ b/scripts/release/publish-workflow.test.mjs @@ -898,6 +898,43 @@ test("package publication goes through reconciliation and post-publish attestati ); }); +test("reconcile-package is the only internal-dependency wait on the publish path", () => { + assert.equal( + existsSync(join(REPO, "scripts/release/assert-internal-deps.mjs")), + false, + "a second dependency gate would poll and time out independently of reconcile-package", + ); + assert.doesNotMatch(WORKFLOW, /assert-internal-deps/); + for (const [job, next] of [ + ["publish-packages", "publish-single"], + ["publish-single", "create-release"], + ]) { + const start = WORKFLOW.indexOf(`\n ${job}:`); + assert.ok(start > 0, `${job} missing`); + const body = WORKFLOW.slice(start, WORKFLOW.indexOf(`\n ${next}:`)); + assert.match(body, /scripts\/release\/reconcile-package\.mjs/); + } +}); + +test("publish matrix lists every internal dependency before its dependents", () => { + // max-parallel is smaller than the matrix, so a dependent scheduled ahead of + // its dependency can hold a slot while waiting for a package that has not + // started. + const start = WORKFLOW.indexOf("\n publish-packages:"); + const end = WORKFLOW.indexOf("\n publish-single:"); + const paths = [...WORKFLOW.slice(start, end).matchAll(/path: (packages\/[^\n]+)/g)].map((m) => m[1]); + const manifests = paths.map((p) => JSON.parse(readFileSync(join(REPO, p, "package.json"), "utf8"))); + const names = manifests.map((m) => m.name); + manifests.forEach((pkg, i) => { + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + for (const dep of Object.keys(pkg[field] ?? {})) { + const j = names.indexOf(dep); + if (j >= 0) assert.ok(j < i, `${pkg.name} is scheduled before its dependency ${dep}`); + } + } + }); +}); + test("all package publication is behind a successful read-only reconciliation barrier", () => { assert.match(WORKFLOW, /preflight-packages:/); assert.match(WORKFLOW, /--preflight true/); diff --git a/scripts/release/reconcile-package.mjs b/scripts/release/reconcile-package.mjs index 7ed3a585..53a4faec 100644 --- a/scripts/release/reconcile-package.mjs +++ b/scripts/release/reconcile-package.mjs @@ -380,6 +380,7 @@ async function waitForInternalDependencies({ function requiredInternalDependencies(manifest) { for (const field of [ "dependencies", + "optionalDependencies", "peerDependencies", "peerDependenciesMeta", ]) { @@ -392,10 +393,13 @@ function requiredInternalDependencies(manifest) { `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. + // npm auto-installs required peers. Optional peers and dev deps are not part + // of the install contract. optionalDependencies are: npm silently skips an + // unresolvable optional dependency, so a consumer published ahead of its + // platform binaries installs without them (sdk -> cli-*/mount-*). return [ ...Object.entries(manifest.dependencies ?? {}), + ...Object.entries(manifest.optionalDependencies ?? {}), ...Object.entries(manifest.peerDependencies ?? {}).filter( ([name]) => manifest.peerDependenciesMeta?.[name]?.optional !== true, ), diff --git a/scripts/release/reconcile-package.test.mjs b/scripts/release/reconcile-package.test.mjs index 9ebbad8e..ff3d5dfe 100644 --- a/scripts/release/reconcile-package.test.mjs +++ b/scripts/release/reconcile-package.test.mjs @@ -346,7 +346,19 @@ test("consumer publication waits until its required internal dependency is visib const state = { views: 0, publishes: 0 }; let dependencyViews = 0; const waits = []; + let optionalViews = 0; const npm = async (command, args, options) => { + if (args[0] === "view" && args[1] === "@relayfile/mount-linux-arm64@1.2.3") { + optionalViews += 1; + return { + code: 0, + stdout: JSON.stringify( + dependencyMetadata("@relayfile/mount-linux-arm64", "1.2.3"), + ), + stderr: "", + }; + } + assert.notEqual(args[1], "@relayfile/sdk@1.2.3", "devDependencies are not an install contract"); if (args[0] === "view" && args[1] === "@relayfile/core@1.2.3") { dependencyViews += 1; return dependencyViews === 1 @@ -379,6 +391,7 @@ test("consumer publication waits until its required internal dependency is visib }); assert.equal(state.publishes, 1); assert.equal(dependencyViews, 2); + assert.equal(optionalViews, 1); assert.deepEqual(waits, [5000]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -574,6 +587,53 @@ test("dependency queries and retry delays share one bounded wait budget", async } }); +test("optionalDependencies on release packages gate the consumer, directly and via the registry manifest", async () => { + for (const direct of [true, false]) { + const dir = sandbox(); + const optional = { "@relayfile/cli-linux-x64": "1.2.3" }; + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + direct + ? { name: "@relayfile/test", version: "1.2.3", optionalDependencies: optional } + : { name: "@relayfile/test", version: "1.2.3", dependencies: { "@relayfile/sdk": "1.2.3" } }, + ), + ); + const state = { views: 0, publishes: 0 }; + let platformViews = 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", { optionalDependencies: optional }), + ), + stderr: "", + }; + if (args[0] === "view" && args[1] === "@relayfile/cli-linux-x64@1.2.3") { + platformViews += 1; + if (platformViews === 1) return { code: 1, stdout: "", stderr: "npm error code E404" }; + return { + code: 0, + stdout: JSON.stringify(dependencyMetadata("@relayfile/cli-linux-x64", "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(platformViews, 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); + assert.equal(platformViews, 2); + } 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(