diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a72d3bda..1e6334cc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -750,30 +750,6 @@ jobs: path: packages/core mount_binary: "" cli_binary: "" - - package: sdk - path: packages/sdk/typescript - mount_binary: "" - cli_binary: "" - - package: client - path: packages/client - mount_binary: "" - cli_binary: "" - - package: agents - path: packages/agents - mount_binary: "" - cli_binary: "" - - package: cli - path: packages/cli - mount_binary: "" - cli_binary: "" - - package: file-observer - path: packages/file-observer - mount_binary: "" - cli_binary: "" - - package: local-mount - path: packages/local-mount - mount_binary: "" - cli_binary: "" - package: mount-darwin-arm64 path: packages/mount-darwin-arm64 mount_binary: relayfile-mount-darwin-arm64 @@ -814,6 +790,30 @@ jobs: path: packages/cli-win32-x64 mount_binary: "" cli_binary: relayfile-cli-windows-amd64.exe + - package: sdk + path: packages/sdk/typescript + mount_binary: "" + cli_binary: "" + - package: client + path: packages/client + mount_binary: "" + cli_binary: "" + - package: agents + path: packages/agents + mount_binary: "" + cli_binary: "" + - package: cli + path: packages/cli + mount_binary: "" + cli_binary: "" + - package: file-observer + path: packages/file-observer + mount_binary: "" + cli_binary: "" + - package: local-mount + path: packages/local-mount + mount_binary: "" + cli_binary: "" steps: - name: Checkout code @@ -879,6 +879,19 @@ 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 }} @@ -1014,6 +1027,19 @@ 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 new file mode 100644 index 00000000..7ab3fbc7 --- /dev/null +++ b/scripts/release/assert-internal-deps.mjs @@ -0,0 +1,163 @@ +#!/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 new file mode 100644 index 00000000..2f7585f2 --- /dev/null +++ b/scripts/release/assert-internal-deps.test.mjs @@ -0,0 +1,161 @@ +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}`); + } + }); +});