From 1d7a69dd6d7610a4d31e4342308a40841963ebf9 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:04:19 -0700 Subject: [PATCH 1/3] ci: shorten checks and release critical path --- .github/workflows/ci.yml | 40 +++- .github/workflows/publish.yml | 268 +++++++++++++++------- scripts/verify-published-plugins.mjs | 105 ++++++--- scripts/verify-published-plugins.test.mjs | 21 +- scripts/workflow-performance.test.mjs | 57 +++++ 5 files changed, 368 insertions(+), 123 deletions(-) create mode 100644 scripts/workflow-performance.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d69cf93..0d95b810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,15 @@ on: push: branches: - main + # A marker-only commit immediately dispatches the release workflow, whose + # platform matrices otherwise compete with this entire suite for macOS and + # Windows runners. The code at that commit was already checked by its PR. + paths-ignore: + - '.release/dispatch-patch-*' + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read @@ -18,11 +27,19 @@ jobs: - uses: dtolnay/rust-toolchain@stable + # This is the critical-path job. Keep its cache separate from the much + # larger release matrix so a PR does not evict every release target. + - uses: Swatinem/rust-cache@v2 + with: + key: ci-verify + - uses: actions/setup-node@v4 with: node-version: "20" cache: npm - cache-dependency-path: sdk-ts/package-lock.json + cache-dependency-path: | + crates/ai-hist-napi/package-lock.json + sdk-ts/package-lock.json - name: Physically remove optional cloud and provider packages run: rm -rf plugins @@ -95,8 +112,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + - uses: actions/cache@v4 + id: cargo-public-api-cache + with: + path: ~/.cargo/bin/cargo-public-api + key: cargo-public-api-${{ runner.os }}-0.52.0 - name: Install cargo-public-api - run: cargo install cargo-public-api --locked + if: steps.cargo-public-api-cache.outputs.cache-hit != 'true' + run: cargo install cargo-public-api --version 0.52.0 --locked - name: Public API matches the checked-in snapshot run: node scripts/check-public-api.mjs @@ -178,6 +201,19 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + cache: npm + cache-dependency-path: | + crates/ai-hist-napi/package-lock.json + sdk-ts/package-lock.json + plugins/relayhistory/sdk/package-lock.json + plugins/provider-sources/sdk/package-lock.json + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + plugins/relayhistory/rust -> target + plugins/provider-sources/rust -> target + key: ci-optional-history-plugins - name: Optional helper packaging contracts run: node --test scripts/history-helper-packaging.composed.mjs - name: Build composed distribution diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 25252673..c010afd2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -759,23 +759,22 @@ jobs: set -euo pipefail if [ "$DRY_RUN" != "true" ]; then npm publish --access public; fi - - name: Registry clean-install smoke test + # Wait only for registry visibility on the publication critical path. + # The slower clean installs and runtime probes run in verify-core while + # plugin publication proceeds in parallel. + - name: Registry visibility gate if: ${{ !inputs.dry_run }} + working-directory: . env: REGISTRY_VISIBILITY_ATTEMPTS: 36 REGISTRY_VISIBILITY_DELAY_MS: 10000 - NPM_REGISTRY_RETRY_ATTEMPTS: 36 - NPM_REGISTRY_RETRY_DELAY_MS: 10000 - run: node "$GITHUB_WORKSPACE/scripts/registry-clean-install-smoke.mjs" - - - name: Registry CLI smoke test on older glibc - if: ${{ !inputs.dry_run }} - working-directory: . run: | - set -euo pipefail - for image in node:22-bookworm-slim ubuntu:22.04; do - bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" - done + node --input-type=module - "$VERSION" <<'NODE' + import { waitForRegistryPackages } from "./scripts/registry-clean-install-smoke.mjs"; + const attempts = Number(process.env.REGISTRY_VISIBILITY_ATTEMPTS); + const delayMs = Number(process.env.REGISTRY_VISIBILITY_DELAY_MS); + await waitForRegistryPackages(process.argv[2], { attempts, delayMs }); + NODE # Tag the tree that was published, then create the GitHub Release, # before pushing the version commit. skip_core rebuilds from this tag, @@ -808,6 +807,46 @@ jobs: --target "${{ steps.tag-published-tree.outputs.sha }}" \ --title "ai-hist@$VERSION" \ --generate-notes + + verify-core: + name: Verify published core + needs: [version, publish] + # Publishing and the visibility gate have completed. These slower clean + # installs remain a release check but no longer serialize plugins/crate. + if: ${{ !cancelled() && !inputs.dry_run && !inputs.skip_core && needs.publish.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: . + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.publish.outputs.release_sha }} + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + registry-url: 'https://registry.npmjs.org' + - name: Match release npm version + run: npm install -g npm@11.19.1 + - name: Registry clean-install smoke test + env: + REGISTRY_VISIBILITY_ATTEMPTS: 36 + REGISTRY_VISIBILITY_DELAY_MS: 10000 + NPM_REGISTRY_RETRY_ATTEMPTS: 36 + NPM_REGISTRY_RETRY_DELAY_MS: 10000 + run: node scripts/registry-clean-install-smoke.mjs + - name: Registry CLI smoke test on older glibc + run: | + set -euo pipefail + for image in node:22-bookworm-slim ubuntu:22.04; do + bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" + done + persist-version: name: Persist release version needs: [version, publish] @@ -919,16 +958,13 @@ jobs: # crates.io credential minted from GitHub OIDC above, not a repo secret. cargo publish -p ai-hist --allow-dirty --locked --no-verify - plugins: - name: Publish optional history plugins - needs: [version, publish, helpers] - # `publish` is deliberately skipped by a skip_core re-run, so it is only - # required when this run is actually releasing the core. - if: ${{ !cancelled() && inputs.plugins && needs.version.result == 'success' && needs.helpers.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + package-plugins: + name: Package optional history plugins + needs: [version, helpers] + if: ${{ !cancelled() && inputs.plugins && needs.version.result == 'success' && needs.helpers.result == 'success' }} runs-on: ubuntu-latest permissions: contents: read - id-token: write defaults: run: working-directory: . @@ -937,39 +973,24 @@ jobs: steps: - uses: actions/checkout@v4 with: - # The tagged published tree (or that same SHA via release_sha on a - # normal run). A dry run applies the version below instead. A - # skip_core re-run packages the code the named release shipped, from - # its tag, never the newer dispatch commit (`format` is never empty, - # so the release sha and the dispatch fallback only apply when - # skip_core is off). - ref: ${{ inputs.skip_core && format('refs/tags/sdk-ts-v{0}', needs.version.outputs.version) || needs.publish.outputs.release_sha || github.sha }} + # Normal releases package the immutable dispatch SHA while core npm + # publication runs. A retry must use the already-published tag. + ref: ${{ inputs.skip_core && format('refs/tags/sdk-ts-v{0}', needs.version.outputs.version) || github.sha }} persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '22' - registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: | + crates/ai-hist-napi/package-lock.json + sdk-ts/package-lock.json + plugins/relayhistory/sdk/package-lock.json + plugins/provider-sources/sdk/package-lock.json - uses: dtolnay/rust-toolchain@stable - uses: actions/download-artifact@v4 with: pattern: helper-* path: helpers - # A skip_core retry takes its whole tree from the release tag, including - # the plugin manifests and the package contract that every later step - # derives coordinates from. Tags cut before the @relayhistory rename - # still name their plugins @agent-relay/*, and those names still produce - # valid tarballs — so such a retry would publish the obsolete packages - # and report success, leaving @relayhistory/* absent. - # - # This step is the one place the scope must NOT come from the checkout: - # a workflow_dispatch run reads this file from the dispatch ref, not - # from `ref:` above, so this literal is the only thing in the job that a - # stale tag cannot rewrite. - # - # There is no correct way to publish @relayhistory/* from a tag that does - # not contain those names — doing so would mix post-rename code into an - # older release, which is exactly what skip_core exists to avoid. So this - # refuses rather than repairs: cut a normal release instead. - name: Refuse a plugin-only retry from a pre-rename release if: ${{ inputs.skip_core }} run: | @@ -989,10 +1010,7 @@ jobs: run: | set -euo pipefail test -n "$VERSION" - # Idempotent, and identical to what the version commit already holds. node scripts/set-release-version.mjs "$VERSION" - # The staged smoke test installs this checkout's core next to the - # plugins under --strict-peer-deps, so it must satisfy ^VERSION. (cd crates/ai-hist-napi && npm version "$VERSION" --no-git-tag-version --allow-same-version) (cd sdk-ts && npm version "$VERSION" --no-git-tag-version --allow-same-version) node -e ' @@ -1000,13 +1018,10 @@ jobs: const path = "sdk-ts/package-lock.json"; const lock = JSON.parse(fs.readFileSync(path, "utf8")); const native = lock.packages["../crates/ai-hist-napi"]; - if (!native) { - throw new Error("SDK lockfile is missing its local ai-hist-native package"); - } + if (!native) throw new Error("SDK lockfile is missing its local ai-hist-native package"); native.version = process.env.VERSION; fs.writeFileSync(path, JSON.stringify(lock, null, 2) + "\n"); ' - git --no-pager diff --stat - name: Build the local core and both plugin SDKs run: | set -euo pipefail @@ -1023,7 +1038,6 @@ jobs: run: | set -euo pipefail mkdir -p artifacts staged - # The package contract owns the plugin, platform and binary names. node -e ' import("./scripts/history-package-contract.mjs").then(({ plugins, platforms }) => { for (const [plugin, info] of Object.entries(plugins)) { @@ -1042,12 +1056,81 @@ jobs: done < "$RUNNER_TEMP/helpers.txt" - name: Verify installed core and optional artifacts run: node scripts/smoke-history-packages.mjs --helpers artifacts - # Publication checks the oldest core admitted by each peer range, so an - # already-published older core cannot be hidden by our staged source SDK. - # The core's own registry smoke tests ran first, in the publish job. - - name: Verify actual published core compatibility before any publication + - uses: actions/upload-artifact@v4 + with: + name: plugin-packages + path: artifacts/*.tgz + if-no-files-found: error + retention-days: 1 + + plugins: + name: Publish optional plugin ${{ matrix.plugin }} + needs: [version, publish, package-plugins] + # `publish` is deliberately skipped by a skip_core re-run, so it is only + # required when this run is actually releasing the core. + if: ${{ !cancelled() && inputs.plugins && needs.version.result == 'success' && needs.package-plugins.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: . + env: + VERSION: ${{ needs.version.outputs.version }} + PLUGIN: ${{ matrix.plugin }} + strategy: + fail-fast: false + matrix: + plugin: [relayhistory, provider-sources] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.skip_core && format('refs/tags/sdk-ts-v{0}', needs.version.outputs.version) || needs.publish.outputs.release_sha || github.sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v4 + with: + name: plugin-packages + path: artifacts + # A skip_core retry takes its whole tree from the release tag, including + # the plugin manifests and the package contract that every later step + # derives coordinates from. Tags cut before the @relayhistory rename + # still name their plugins @agent-relay/*, and those names still produce + # valid tarballs — so such a retry would publish the obsolete packages + # and report success, leaving @relayhistory/* absent. + # + # Repeat the packaging-stage guard at the mutation boundary. A + # workflow_dispatch run reads this file from the dispatch ref, not from + # `ref:` above, so this literal cannot be rewritten by a stale tag. + # + # There is no correct way to publish @relayhistory/* from a tag that does + # not contain those names — doing so would mix post-rename code into an + # older release, which is exactly what skip_core exists to avoid. So this + # refuses rather than repairs: cut a normal release instead. + - name: Refuse a plugin-only retry from a pre-rename release + if: ${{ inputs.skip_core }} + run: | + set -euo pipefail + for plugin in relayhistory provider-sources; do + name=$(node -p "require('./plugins/$plugin/sdk/package.json').name") + case "$name" in + @relayhistory/*) ;; + *) + echo "release tag sdk-ts-v${VERSION} predates the @relayhistory rename ($plugin is still $name)." >&2 + echo "A plugin-only retry cannot publish @relayhistory/* from it; cut a normal release instead." >&2 + exit 1 + ;; + esac + done + # Each row checks the oldest core admitted by the plugin it is about to + # publish. The new core has already passed the visibility gate. + - name: Verify actual published core compatibility before publication if: ${{ !inputs.dry_run }} - run: node scripts/verify-published-history-core.mjs all + run: node scripts/verify-published-history-core.mjs "$PLUGIN" # Same requirement, same pin as the core publish job: npm >= 11.5 mints # the OIDC token, and the runner's bundled npm 10.x cannot, so without # this the tokenless publish below fails ENEEDAUTH exactly as it did with @@ -1085,40 +1168,53 @@ jobs: # rather than the literal pattern, and the counter below fails the # release instead of silently publishing a partial platform set. shopt -s nullglob - for plugin in relayhistory provider-sources; do - # Derive the tarball basename from the package name rather than - # repeating the scope: npm packs `@scope/name` as `scope-name-...`. - # The previous form stripped a hardcoded `@agent-relay/`, which - # silently stopped matching the moment the scope changed. - prefix=$(node -p "require('./plugins/$plugin/sdk/package.json').name.replace('@', '').replace('/', '-')") - helpers=( - ./artifacts/"$prefix"-darwin-*-"$VERSION".tgz - ./artifacts/"$prefix"-linux-*-"$VERSION".tgz - ./artifacts/"$prefix"-win32-*-"$VERSION".tgz - ) - if [ "${#helpers[@]}" -eq 0 ]; then - echo "no platform helpers staged for $plugin at $VERSION" >&2 - exit 1 - fi - for package in "${helpers[@]}"; do - npm publish "$package" --access public --provenance - done - npm publish "./artifacts/$prefix-$VERSION.tgz" --access public --provenance + prefix=$(node -p "require('./plugins/$PLUGIN/sdk/package.json').name.replace('@', '').replace('/', '-')") + helpers=( + ./artifacts/"$prefix"-darwin-*-"$VERSION".tgz + ./artifacts/"$prefix"-linux-*-"$VERSION".tgz + ./artifacts/"$prefix"-win32-*-"$VERSION".tgz + ) + if [ "${#helpers[@]}" -eq 0 ]; then + echo "no platform helpers staged for $PLUGIN at $VERSION" >&2 + exit 1 + fi + for package in "${helpers[@]}"; do + npm publish "$package" --access public --provenance done - # Nothing used to run after the publish above, so a publish that failed — - # or succeeded into names nobody checked — ended this job quietly. Six - # separate faults shipped that way, each only discoverable by cutting - # another release. This installs what was just published, from the - # registry, and imports it, so a broken publish fails the release that - # caused it. + npm publish "./artifacts/$prefix-$VERSION.tgz" --access public --provenance + - name: List this plugin's staged tarballs + if: ${{ inputs.dry_run }} + run: | + prefix=$(node -p "require('./plugins/$PLUGIN/sdk/package.json').name.replace('@', '').replace('/', '-')") + ls -1 artifacts/"$prefix"-*.tgz + + # Both plugin families publish in parallel above. Keep the registry proof as + # a required release job, but do not make one plugin wait for the other's + # propagation before it can begin publishing. + verify-plugins: + name: Verify published plugins + needs: [version, publish, plugins] + if: ${{ !cancelled() && !inputs.dry_run && inputs.plugins && needs.plugins.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: . + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.skip_core && format('refs/tags/sdk-ts-v{0}', needs.version.outputs.version) || needs.publish.outputs.release_sha || github.sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: https://registry.npmjs.org - name: Verify the published plugins install from the registry - if: ${{ !inputs.dry_run }} run: node scripts/verify-published-plugins.mjs "$VERSION" - - name: List the staged tarballs - if: ${{ inputs.dry_run }} - run: ls -1 artifacts - probe: name: Attach agent-relay-probe binaries needs: [version, publish, helpers] diff --git a/scripts/verify-published-plugins.mjs b/scripts/verify-published-plugins.mjs index da48ae44..1b20ab5b 100755 --- a/scripts/verify-published-plugins.mjs +++ b/scripts/verify-published-plugins.mjs @@ -29,7 +29,8 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; import { packageName, platforms, plugins } from "./history-package-contract.mjs"; import { installWithRegistryRetry, isRegistryVisibilityFailure } from "./npm-install-with-registry-retry.mjs"; @@ -40,6 +41,8 @@ import { publicRegistryEnv, } from "./npm-host-install.mjs"; +const execFileAsync = promisify(execFile); + export { currentPlatform, hostLibc, publicRegistryEnv }; export const pluginInstallArgs = hostInstallArgs; @@ -100,16 +103,39 @@ function expectedNames() { } /** Read one exact manifest using a fresh cache on every attempt. */ -function viewed(name, version) { - const cache = mkdtempSync(join(tmpdir(), "relayhistory-view-cache-")); +async function viewed(name, version) { + const cache = await mkdtemp(join(tmpdir(), "relayhistory-view-cache-")); try { - return spawnSync( - "npm", - ["view", "--prefer-online", "--json", `${name}@${version}`], - { encoding: "utf8", env: { ...process.env, npm_config_cache: cache } }, - ); + try { + const { stdout, stderr } = await execFileAsync( + "npm", + ["view", "--prefer-online", "--json", `${name}@${version}`], + { + encoding: "utf8", + env: { ...process.env, npm_config_cache: cache }, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return { status: 0, signal: null, stdout, stderr }; + } catch (error) { + if (typeof error.code === "number") { + return { + status: error.code, + signal: error.signal ?? null, + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + }; + } + return { + status: null, + signal: error.signal ?? null, + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + error, + }; + } } finally { - rmSync(cache, { recursive: true, force: true }); + await rm(cache, { recursive: true, force: true }); } } @@ -117,39 +143,50 @@ function viewed(name, version) { export async function waitForPublishedPackages(version, { attempts = 60, delayMs = 5_000, + concurrency = 4, runView = viewed, sleep = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)), log = (message) => console.error(message), } = {}) { + assert.ok( + Number.isSafeInteger(concurrency) && concurrency > 0, + "concurrency must be positive", + ); const pending = new Set(expectedNames()); for (let attempt = 1; attempt <= attempts; attempt += 1) { const missing = []; - for (const name of pending) { - const result = runView(name, version); - const context = `npm view ${name}@${version}`; - if (result.error) throw new Error(`${context}: ${result.error.message}`, { cause: result.error }); - if (result.status !== 0) { - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - const diagnostic = `${context} failed (exit ${result.status}, signal ${result.signal ?? "none"}):\n${output}`; - if (!isRegistryVisibilityFailure(output)) throw new Error(diagnostic); - missing.push(diagnostic); - continue; - } - let metadata; - try { - metadata = JSON.parse(result.stdout); - } catch (error) { - throw new Error(`${context}: invalid JSON: ${error.message}`, { cause: error }); + const names = [...pending]; + for (let offset = 0; offset < names.length; offset += concurrency) { + const batch = names.slice(offset, offset + concurrency); + const results = await Promise.all( + batch.map(async (name) => [name, await runView(name, version)]), + ); + for (const [name, result] of results) { + const context = `npm view ${name}@${version}`; + if (result.error) throw new Error(`${context}: ${result.error.message}`, { cause: result.error }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const diagnostic = `${context} failed (exit ${result.status}, signal ${result.signal ?? "none"}):\n${output}`; + if (!isRegistryVisibilityFailure(output)) throw new Error(diagnostic); + missing.push(diagnostic); + continue; + } + let metadata; + try { + metadata = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`${context}: invalid JSON: ${error.message}`, { cause: error }); + } + // npm versions differ: an exact-version view can return an object or a + // singleton array. Never accept multiple versions from an exact lookup. + const manifests = Array.isArray(metadata) ? metadata : [metadata]; + assert.equal(manifests.length, 1, `${context}: expected exactly one manifest`); + const [manifest] = manifests; + assert.ok(manifest && typeof manifest === "object", `${context}: invalid manifest`); + assert.equal(manifest.version, version, `${name}: registry returned the wrong version`); + assert.ok(manifest.repository?.url, `${name}@${version}: published without repository.url`); + pending.delete(name); } - // npm versions differ: an exact-version view can return an object or a - // singleton array. Never accept multiple versions from an exact lookup. - const manifests = Array.isArray(metadata) ? metadata : [metadata]; - assert.equal(manifests.length, 1, `${context}: expected exactly one manifest`); - const [manifest] = manifests; - assert.ok(manifest && typeof manifest === "object", `${context}: invalid manifest`); - assert.equal(manifest.version, version, `${name}: registry returned the wrong version`); - assert.ok(manifest.repository?.url, `${name}@${version}: published without repository.url`); - pending.delete(name); } if (pending.size === 0) return; if (attempt === attempts) { diff --git a/scripts/verify-published-plugins.test.mjs b/scripts/verify-published-plugins.test.mjs index e6afb883..db247a4e 100644 --- a/scripts/verify-published-plugins.test.mjs +++ b/scripts/verify-published-plugins.test.mjs @@ -58,6 +58,23 @@ test("checks every published name at the exact release version", async () => { assert.deepEqual(calls, expected.map((name) => [name, version])); }); +test("bounds concurrent registry lookups", async () => { + let inFlight = 0; + let maximum = 0; + await waitForPublishedPackages(version, { + ...quiet, + concurrency: 3, + runView: async () => { + inFlight += 1; + maximum = Math.max(maximum, inFlight); + await new Promise((resolve) => setImmediate(resolve)); + inFlight -= 1; + return available; + }, + }); + assert.equal(maximum, 3); +}); + test("accepts npm's singleton-array metadata format", async () => { await waitForPublishedPackages(version, { ...quiet, @@ -128,7 +145,9 @@ test("authentication and network errors fail immediately with npm diagnostics", return { status: 1, stdout: "", stderr: `npm error code ${code}` }; }, }), new RegExp(`npm view @relayhistory/capture@0\\.19\\.0 failed.*\\n.*${code}`)); - assert.equal(calls, 1); + // The first bounded batch is already in flight when its first fatal + // result is inspected; no later batch is started. + assert.equal(calls, 4); } }); diff --git a/scripts/workflow-performance.test.mjs b/scripts/workflow-performance.test.mjs new file mode 100644 index 00000000..f4578405 --- /dev/null +++ b/scripts/workflow-performance.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const ci = await readFile(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); +const publish = await readFile(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); + +function jobBlock(workflow, name, nextName) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `missing ${name} job`); + const end = nextName ? workflow.indexOf(`\n ${nextName}:\n`, start + 1) : workflow.length; + assert.notEqual(end, -1, `missing ${nextName} job after ${name}`); + return workflow.slice(start, end); +} + +test("CI cancels stale runs and release markers do not launch the full suite", () => { + assert.match(ci, /paths-ignore:\n\s+- '\.release\/dispatch-patch-\*'/); + assert.match(ci, /group: ci-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}/); + assert.match(ci, /cancel-in-progress: true/); +}); + +test("critical CI Rust jobs restore explicit caches", () => { + const verify = jobBlock(ci, "verify", "public-api"); + const optional = jobBlock(ci, "optional-history-plugins", "windows-helper-cancellation"); + assert.match(verify, /Swatinem\/rust-cache@v2[\s\S]*key: ci-verify/); + assert.match(optional, /Swatinem\/rust-cache@v2[\s\S]*key: ci-optional-history-plugins/); +}); + +test("plugin packaging overlaps core publication", () => { + const packaging = jobBlock(publish, "package-plugins", "plugins"); + assert.match(packaging, /needs: \[version, helpers\]/); + assert.doesNotMatch(packaging, /needs: \[[^\]]*publish/); + assert.match(packaging, /name: plugin-packages/); +}); + +test("plugin families publish in parallel and verify afterwards", () => { + const plugins = jobBlock(publish, "plugins", "verify-plugins"); + assert.match(plugins, /plugin: \[relayhistory, provider-sources\]/); + assert.match(plugins, /name: plugin-packages/); + assert.match(plugins, /verify-published-history-core\.mjs "\$PLUGIN"/); + assert.doesNotMatch(plugins, /verify-published-plugins\.mjs/); + + const verification = jobBlock(publish, "verify-plugins", "probe"); + assert.match(verification, /needs: \[version, publish, plugins\]/); + assert.match(verification, /verify-published-plugins\.mjs "\$VERSION"/); +}); + +test("full core smoke checks are required but off the publish critical path", () => { + const corePublish = jobBlock(publish, "publish", "verify-core"); + assert.match(corePublish, /name: Registry visibility gate/); + assert.doesNotMatch(corePublish, /name: Registry clean-install smoke test/); + + const verification = jobBlock(publish, "verify-core", "persist-version"); + assert.match(verification, /needs: \[version, publish\]/); + assert.match(verification, /name: Registry clean-install smoke test/); + assert.match(verification, /name: Registry CLI smoke test on older glibc/); +}); From 4f8fcf8c706794e963c7f6615ead269739bee26d Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:13:05 -0700 Subject: [PATCH 2/3] fix(ci): gate releases on runtime smoke tests --- .github/workflows/publish.yml | 71 +++++++++------------------ scripts/workflow-performance.test.mjs | 15 +++--- 2 files changed, 31 insertions(+), 55 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c010afd2..4782cffc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -759,9 +759,8 @@ jobs: set -euo pipefail if [ "$DRY_RUN" != "true" ]; then npm publish --access public; fi - # Wait only for registry visibility on the publication critical path. - # The slower clean installs and runtime probes run in verify-core while - # plugin publication proceeds in parallel. + # Confirm all packages are visible before the compatibility gates. This + # gives the registry time to converge without retrying every install. - name: Registry visibility gate if: ${{ !inputs.dry_run }} working-directory: . @@ -776,12 +775,27 @@ jobs: await waitForRegistryPackages(process.argv[2], { attempts, delayMs }); NODE - # Tag the tree that was published, then create the GitHub Release, - # before pushing the version commit. skip_core rebuilds from this tag, - # so a later persist failure (or a merge that lands during smoke) - # cannot leave npm at a version that has no tag. - # The commit itself was prepared before the publish-only - # sdk-ts/package.json rewrite, so the tag stays version-only. + - name: Registry clean-install smoke test + if: ${{ !inputs.dry_run }} + working-directory: . + env: + REGISTRY_VISIBILITY_ATTEMPTS: 36 + REGISTRY_VISIBILITY_DELAY_MS: 10000 + NPM_REGISTRY_RETRY_ATTEMPTS: 36 + NPM_REGISTRY_RETRY_DELAY_MS: 10000 + run: node scripts/registry-clean-install-smoke.mjs + - name: Registry CLI smoke test on older glibc + if: ${{ !inputs.dry_run }} + working-directory: . + run: | + set -euo pipefail + for image in node:22-bookworm-slim ubuntu:22.04; do + bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" + done + + # Only finalize the release after the published packages pass the full + # clean-install and older-glibc runtime gates. Downstream publication + # depends on this job, so it cannot outrun those checks either. - name: Tag the published tree id: tag-published-tree if: ${{ !inputs.dry_run }} @@ -808,45 +822,6 @@ jobs: --title "ai-hist@$VERSION" \ --generate-notes - verify-core: - name: Verify published core - needs: [version, publish] - # Publishing and the visibility gate have completed. These slower clean - # installs remain a release check but no longer serialize plugins/crate. - if: ${{ !cancelled() && !inputs.dry_run && !inputs.skip_core && needs.publish.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - working-directory: . - env: - VERSION: ${{ needs.version.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.publish.outputs.release_sha }} - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: '22.14.0' - registry-url: 'https://registry.npmjs.org' - - name: Match release npm version - run: npm install -g npm@11.19.1 - - name: Registry clean-install smoke test - env: - REGISTRY_VISIBILITY_ATTEMPTS: 36 - REGISTRY_VISIBILITY_DELAY_MS: 10000 - NPM_REGISTRY_RETRY_ATTEMPTS: 36 - NPM_REGISTRY_RETRY_DELAY_MS: 10000 - run: node scripts/registry-clean-install-smoke.mjs - - name: Registry CLI smoke test on older glibc - run: | - set -euo pipefail - for image in node:22-bookworm-slim ubuntu:22.04; do - bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" - done - persist-version: name: Persist release version needs: [version, publish] diff --git a/scripts/workflow-performance.test.mjs b/scripts/workflow-performance.test.mjs index f4578405..9e9f72ef 100644 --- a/scripts/workflow-performance.test.mjs +++ b/scripts/workflow-performance.test.mjs @@ -45,13 +45,14 @@ test("plugin families publish in parallel and verify afterwards", () => { assert.match(verification, /verify-published-plugins\.mjs "\$VERSION"/); }); -test("full core smoke checks are required but off the publish critical path", () => { - const corePublish = jobBlock(publish, "publish", "verify-core"); +test("full core smoke checks gate release finalization and downstream publication", () => { + const corePublish = jobBlock(publish, "publish", "persist-version"); assert.match(corePublish, /name: Registry visibility gate/); - assert.doesNotMatch(corePublish, /name: Registry clean-install smoke test/); + assert.match(corePublish, /name: Registry clean-install smoke test/); + assert.match(corePublish, /name: Registry CLI smoke test on older glibc/); + assert.match(corePublish, /name: Tag the published tree/); + assert.match(corePublish, /name: Create GitHub Release/); - const verification = jobBlock(publish, "verify-core", "persist-version"); - assert.match(verification, /needs: \[version, publish\]/); - assert.match(verification, /name: Registry clean-install smoke test/); - assert.match(verification, /name: Registry CLI smoke test on older glibc/); + const plugins = jobBlock(publish, "plugins", "verify-plugins"); + assert.match(plugins, /needs: \[version, publish, package-plugins\]/); }); From b071b58dcb0124a186a066082a8a6e68eaff4b6e Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:17:10 -0700 Subject: [PATCH 3/3] fix(release): make post-publish verification recoverable --- .github/workflows/publish.yml | 117 +++++++++++++++++--------- scripts/workflow-performance.test.mjs | 25 ++++-- 2 files changed, 98 insertions(+), 44 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4782cffc..7fa04879 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,7 +49,7 @@ on: default: true type: boolean skip_core: - description: 'Re-run plugins/probe/crates.io for an already published core version; requires custom_version' + description: 'Verify and finish plugins/probe/crates.io for an already published core version; requires custom_version' required: false default: false type: boolean @@ -775,27 +775,9 @@ jobs: await waitForRegistryPackages(process.argv[2], { attempts, delayMs }); NODE - - name: Registry clean-install smoke test - if: ${{ !inputs.dry_run }} - working-directory: . - env: - REGISTRY_VISIBILITY_ATTEMPTS: 36 - REGISTRY_VISIBILITY_DELAY_MS: 10000 - NPM_REGISTRY_RETRY_ATTEMPTS: 36 - NPM_REGISTRY_RETRY_DELAY_MS: 10000 - run: node scripts/registry-clean-install-smoke.mjs - - name: Registry CLI smoke test on older glibc - if: ${{ !inputs.dry_run }} - working-directory: . - run: | - set -euo pipefail - for image in node:22-bookworm-slim ubuntu:22.04; do - bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" - done - - # Only finalize the release after the published packages pass the full - # clean-install and older-glibc runtime gates. Downstream publication - # depends on this job, so it cannot outrun those checks either. + # Push the immutable recovery point as soon as npm confirms visibility. + # If a later runtime gate fails, skip_core can verify this exact tree and + # finish the release without trying to republish immutable npm versions. - name: Tag the published tree id: tag-published-tree if: ${{ !inputs.dry_run }} @@ -811,24 +793,81 @@ jobs: git tag -a "sdk-ts-v$VERSION" HEAD -m "ai-hist@$VERSION" git push origin "refs/tags/sdk-ts-v$VERSION" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Create GitHub Release - if: ${{ !inputs.dry_run }} + + verify-core: + name: Verify published core + needs: [version, publish] + # A normal run verifies after npm publication. A skip_core retry verifies + # the existing tagged release before resuming any downstream mutation. + if: ${{ !cancelled() && !inputs.dry_run && needs.version.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: . + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ format('refs/tags/sdk-ts-v{0}', needs.version.outputs.version) }} + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + registry-url: 'https://registry.npmjs.org' + - name: Match release npm version + run: npm install -g npm@11.19.1 + - name: Registry clean-install smoke test env: - GH_TOKEN: ${{ github.token }} + REGISTRY_VISIBILITY_ATTEMPTS: 36 + REGISTRY_VISIBILITY_DELAY_MS: 10000 + NPM_REGISTRY_RETRY_ATTEMPTS: 36 + NPM_REGISTRY_RETRY_DELAY_MS: 10000 + run: node scripts/registry-clean-install-smoke.mjs + - name: Registry CLI smoke test on older glibc run: | set -euo pipefail - gh release create "sdk-ts-v$VERSION" \ - --target "${{ steps.tag-published-tree.outputs.sha }}" \ - --title "ai-hist@$VERSION" \ - --generate-notes + for image in node:22-bookworm-slim ubuntu:22.04; do + bash scripts/smoke-linux-native.sh "$image" registry "$VERSION" + done + + finalize-core: + name: Finalize verified core release + needs: [version, publish, verify-core] + if: ${{ !cancelled() && !inputs.dry_run && needs.verify-core.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + working-directory: . + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Create GitHub Release after runtime verification + run: | + set -euo pipefail + tag="sdk-ts-v$VERSION" + gh api "repos/${GH_REPO}/git/ref/tags/$tag" >/dev/null + if gh release view "$tag" >/dev/null 2>&1; then + echo "GitHub Release $tag already exists; leave it unchanged" + else + gh release create "$tag" \ + --title "ai-hist@$VERSION" \ + --generate-notes + fi persist-version: name: Persist release version - needs: [version, publish] + needs: [version, publish, finalize-core] # Bookkeeping only: npm, the tag and the GitHub Release already exist. - # Crate, plugins and probe depend on `publish`, not this job, so a rebase + # Crate, plugins and probe depend on `finalize-core`, not this job, so a rebase # conflict cannot drop a version that is already on the registry. - if: ${{ !cancelled() && !inputs.dry_run && !inputs.skip_core && needs.publish.result == 'success' }} + if: ${{ !cancelled() && !inputs.dry_run && !inputs.skip_core && needs.finalize-core.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -854,10 +893,10 @@ jobs: publish-crate: name: Publish crate to crates.io - needs: [version, publish] + needs: [version, publish, verify-core, finalize-core] # skip_core is the crates.io-only retry: npm already published this version # and tagged it, so this job checks out that tag instead of the dispatch SHA. - if: ${{ !cancelled() && needs.version.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + if: ${{ !cancelled() && needs.version.result == 'success' && ((inputs.dry_run && (inputs.skip_core || needs.publish.result == 'success')) || (!inputs.dry_run && needs.verify-core.result == 'success' && needs.finalize-core.result == 'success')) }} runs-on: ubuntu-latest permissions: contents: read @@ -1040,10 +1079,10 @@ jobs: plugins: name: Publish optional plugin ${{ matrix.plugin }} - needs: [version, publish, package-plugins] + needs: [version, publish, verify-core, finalize-core, package-plugins] # `publish` is deliberately skipped by a skip_core re-run, so it is only # required when this run is actually releasing the core. - if: ${{ !cancelled() && inputs.plugins && needs.version.result == 'success' && needs.package-plugins.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + if: ${{ !cancelled() && inputs.plugins && needs.version.result == 'success' && needs.package-plugins.result == 'success' && ((inputs.dry_run && (inputs.skip_core || needs.publish.result == 'success')) || (!inputs.dry_run && needs.verify-core.result == 'success' && needs.finalize-core.result == 'success')) }} runs-on: ubuntu-latest permissions: contents: read @@ -1102,7 +1141,7 @@ jobs: esac done # Each row checks the oldest core admitted by the plugin it is about to - # publish. The new core has already passed the visibility gate. + # publish. The new core has already passed its full runtime gate. - name: Verify actual published core compatibility before publication if: ${{ !inputs.dry_run }} run: node scripts/verify-published-history-core.mjs "$PLUGIN" @@ -1192,8 +1231,8 @@ jobs: probe: name: Attach agent-relay-probe binaries - needs: [version, publish, helpers] - if: ${{ !cancelled() && inputs.probe && needs.version.result == 'success' && needs.helpers.result == 'success' && (inputs.skip_core || needs.publish.result == 'success') }} + needs: [version, publish, verify-core, finalize-core, helpers] + if: ${{ !cancelled() && inputs.probe && needs.version.result == 'success' && needs.helpers.result == 'success' && ((inputs.dry_run && (inputs.skip_core || needs.publish.result == 'success')) || (!inputs.dry_run && needs.verify-core.result == 'success' && needs.finalize-core.result == 'success')) }} runs-on: ubuntu-latest # Release assets only; no checkout and no registry credentials. permissions: diff --git a/scripts/workflow-performance.test.mjs b/scripts/workflow-performance.test.mjs index 9e9f72ef..afb10f81 100644 --- a/scripts/workflow-performance.test.mjs +++ b/scripts/workflow-performance.test.mjs @@ -46,13 +46,28 @@ test("plugin families publish in parallel and verify afterwards", () => { }); test("full core smoke checks gate release finalization and downstream publication", () => { - const corePublish = jobBlock(publish, "publish", "persist-version"); + const corePublish = jobBlock(publish, "publish", "verify-core"); assert.match(corePublish, /name: Registry visibility gate/); - assert.match(corePublish, /name: Registry clean-install smoke test/); - assert.match(corePublish, /name: Registry CLI smoke test on older glibc/); assert.match(corePublish, /name: Tag the published tree/); - assert.match(corePublish, /name: Create GitHub Release/); + assert.doesNotMatch(corePublish, /name: Registry clean-install smoke test/); + assert.doesNotMatch(corePublish, /name: Create GitHub Release/); + + const verification = jobBlock(publish, "verify-core", "finalize-core"); + assert.match(verification, /needs: \[version, publish\]/); + assert.match(verification, /inputs\.skip_core \|\| needs\.publish\.result == 'success'/); + assert.match(verification, /name: Registry clean-install smoke test/); + assert.match(verification, /name: Registry CLI smoke test on older glibc/); + + const finalization = jobBlock(publish, "finalize-core", "persist-version"); + assert.match(finalization, /needs: \[version, publish, verify-core\]/); + assert.match(finalization, /needs\.verify-core\.result == 'success'/); + assert.match(finalization, /name: Create GitHub Release after runtime verification/); const plugins = jobBlock(publish, "plugins", "verify-plugins"); - assert.match(plugins, /needs: \[version, publish, package-plugins\]/); + assert.match( + plugins, + /needs: \[version, publish, verify-core, finalize-core, package-plugins\]/, + ); + assert.match(plugins, /needs\.verify-core\.result == 'success'/); + assert.match(plugins, /needs\.finalize-core\.result == 'success'/); });