From 5aaf6f5bd2dd763472f25561ba59de0b12dfe806 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:55:17 -0800 Subject: [PATCH] Fix main release provenance and preserve the 0.4.7 baseline --- .github/tests/release-plan.test.mjs | 158 ++++++++++++++++++ .github/workflows/release.yml | 48 ++++-- .github/workflows/test.yml | 3 + .prettierignore | 3 + CONTRIBUTING.md | 33 ++-- .../convex/test/scenarios/burstyBatches.ts | 3 +- example/convex/test/scenarios/sustained.ts | 3 +- example/convex/test/scenarios/throughput.ts | 3 +- package-lock.json | 13 +- src/component/stateMachine.test.ts | 3 +- 10 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 .github/tests/release-plan.test.mjs diff --git a/.github/tests/release-plan.test.mjs b/.github/tests/release-plan.test.mjs new file mode 100644 index 00000000..ddc98b2c --- /dev/null +++ b/.github/tests/release-plan.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { test } from "node:test"; +import { URL } from "node:url"; + +const workflow = readFileSync( + new URL("../workflows/release.yml", import.meta.url), + "utf8", +); +// Exercise the actual workflow script, rather than a second implementation. +const selection = workflow + .match(/ {6}- id: select[\s\S]*? {8}run: \|\n([\s\S]*?)\n {2}build:/)[1] + .split("\n") + .map((line) => line.replace(/^ {10}/, "")) + .join("\n"); +function fixture(t, version = "0.4.16") { + const cwd = mkdtempSync(join(tmpdir(), "workpool-release-test-")); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + const git = (...args) => + execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); + git("init", "-q"); + git("config", "user.name", "Release test"); + git("config", "user.email", "release-test@example.invalid"); + const commit = (version, message) => { + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ name: "@convex-dev/workpool", version }), + ); + writeFileSync(join(cwd, "notes.txt"), message); + git("add", "."); + git("commit", "-qm", message); + return git("rev-parse", "HEAD"); + }; + const before = commit("0.4.15", "Initial version"); + const bump = commit(version, "Version packages"); + const head = commit(version, "Fix release tooling"); + const run = (overrides = {}, eventBefore = before) => { + const output = join(cwd, "output"); + const event = join(cwd, "event.json"); + writeFileSync(output, ""); + writeFileSync(event, JSON.stringify({ before: eventBefore })); + const result = spawnSync( + "bash", + ["-e", "-o", "pipefail", "-c", selection], + { + cwd, + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_SHA: head, + GITHUB_REF: "refs/heads/main", + GITHUB_OUTPUT: output, + GITHUB_EVENT_PATH: event, + RELEASE_COMMIT: "", + ...overrides, + }, + }, + ); + return { ...result, output: readFileSync(output, "utf8") }; + }; + return { before, bump, head, git, run }; +} +function succeeds(result) { + assert.equal(result.status, 0, result.stderr || result.stdout); +} +function fails(result, message) { + assert.notEqual(result.status, 0); + assert.match(result.stderr + result.stdout, message); +} +test("manual recovery releases the workflow tip after a version bump", (t) => { + const { run, head } = fixture(t); + const result = run(); + succeeds(result); + assert.match(result.output, new RegExp(`sha=${head}\\n`)); + assert.match(result.output, /version=0\.4\.16\n/); + assert.match(result.output, /channel=latest\n/); +}); +test("explicit commit is an equality guard, not an older source selector", (t) => { + const { run, head, bump } = fixture(t); + succeeds(run({ RELEASE_COMMIT: head })); + fails(run({ RELEASE_COMMIT: bump }), /must match the selected ref/); + fails(run({ RELEASE_COMMIT: "main" }), /40-character commit SHA/); +}); +test("a push containing a bump and subsequent fix releases the push tip", (t) => { + const { run, head } = fixture(t); + const result = run({ GITHUB_EVENT_NAME: "push" }); + succeeds(result); + assert.match(result.output, new RegExp(`sha=${head}\\n`)); +}); +test("a push without a version bump does not stage a release", (t) => { + const { run, bump } = fixture(t); + const result = run({ GITHUB_EVENT_NAME: "push" }, bump); + succeeds(result); + assert.equal(result.output, ""); +}); +test("manual dispatch without any version bump is rejected", (t) => { + const { run, before } = fixture(t); + fails(run({ GITHUB_SHA: before }), /No version bump/); +}); +test("stable releases still require main", (t) => { + const { run } = fixture(t); + fails( + run({ GITHUB_REF: "refs/heads/release" }), + /Stable releases require main/, + ); +}); +test("prereleases use their channel on a branch", (t) => { + const { run } = fixture(t, "0.4.16-alpha.0"); + const result = run({ GITHUB_REF: "refs/heads/alpha" }); + succeeds(result); + assert.match(result.output, /channel=alpha\n/); +}); +test("downgrades and invalid versions are rejected", (t) => { + fails(fixture(t, "0.4.14").run(), /must increase/); + fails(fixture(t, "invalid").run(), /must increase/); +}); +test("a staged tag at a different source prevents manual restaging", (t) => { + const { run, git, bump } = fixture(t); + git("tag", "v0.4.16", bump); + fails(run(), /points elsewhere/); + const automatic = run({ GITHUB_EVENT_NAME: "push" }); + succeeds(automatic); + assert.doesNotMatch(automatic.output, /sha=/); +}); +test("a staged tag at this source permits explicit recovery", (t) => { + const { run, git, head } = fixture(t); + git("tag", "v0.4.16", head); + succeeds(run()); +}); +test("stage checks the signed source and does not override GITHUB_SHA", () => { + assert.match(workflow, /test "\$SOURCE_SHA" = "\$GITHUB_SHA"/); + assert.doesNotMatch(workflow, /GITHUB_SHA=.*npm stage publish/); +}); +test("the baseline alias stays at 0.4.7 in manifest and lockfile", () => { + const pkg = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url)), + ); + const lock = JSON.parse( + readFileSync(new URL("../../package-lock.json", import.meta.url)), + ); + assert.equal( + pkg.devDependencies["@convex-dev/workpool-old"], + "npm:@convex-dev/workpool@0.4.7", + ); + assert.equal( + lock.packages[""].devDependencies["@convex-dev/workpool-old"], + pkg.devDependencies["@convex-dev/workpool-old"], + ); + assert.equal( + lock.packages["node_modules/@convex-dev/workpool-old"].version, + "0.4.7", + ); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfd18910..24defd93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,8 @@ on: inputs: commit: description: - "Optional 40-character version-bump commit SHA reachable from the - selected ref" + "Optional expected 40-character SHA of the selected ref (must match + the workflow run commit)" required: false type: string @@ -44,28 +44,39 @@ jobs: node-version: ${{ env.NODE_VERSION }} package-manager-cache: false - id: select - name: Select the latest version-bump commit + name: Select the workflow commit for release env: RELEASE_COMMIT: ${{ inputs.commit }} run: | + # GitHub's OIDC certificate binds provenance to the workflow's SHA. + # Always build that exact commit, including fixes after a version bump. + source="$GITHUB_SHA" if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then - source="${RELEASE_COMMIT:-$GITHUB_SHA}" - [[ "$source" =~ ^[0-9a-f]{40}$ ]] || { echo 'Expected a 40-character commit SHA' >&2; exit 1; } - git merge-base --is-ancestor "$source" "$GITHUB_SHA" + if [[ -n "$RELEASE_COMMIT" ]]; then + [[ "$RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo 'Expected a 40-character commit SHA' >&2; exit 1; } + [[ "$RELEASE_COMMIT" == "$source" ]] || { echo 'Release commit must match the selected ref at dispatch time' >&2; exit 1; } + fi + range="$source" else before=$(jq -r .before "$GITHUB_EVENT_PATH") - source='' - candidates=$(git rev-list --first-parent "$before..$GITHUB_SHA" -- package.json) - while read -r candidate && [[ -n "$candidate" ]]; do - current=$(git show "$candidate:package.json" | jq -r .version) - previous=$(git show "$candidate^1:package.json" | jq -r .version) - if [[ "$current" != "$previous" ]]; then source="$candidate"; break; fi - done <<< "$candidates" - if [[ -z "$source" ]]; then echo 'No version bump'; exit 0; fi + range="$before..$source" + fi + bump='' + candidates=$(git rev-list --first-parent "$range" -- package.json) + while read -r candidate && [[ -n "$candidate" ]]; do + git rev-parse --verify "$candidate^1" >/dev/null 2>&1 || continue + current=$(git show "$candidate:package.json" | jq -r .version) + previous=$(git show "$candidate^1:package.json" | jq -r .version) + if [[ "$current" != "$previous" ]]; then bump="$candidate"; break; fi + done <<< "$candidates" + if [[ -z "$bump" ]]; then + echo 'No version bump' + [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]] || exit 1 + exit 0 fi name=$(git show "$source:package.json" | jq -r .name) VERSION=$(git show "$source:package.json" | jq -r .version) - PREVIOUS_VERSION=$(git show "$source^1:package.json" | jq -r .version) + PREVIOUS_VERSION=$(git show "$bump^1:package.json" | jq -r .version) export VERSION PREVIOUS_VERSION node --input-type=module <<'NODE' import { execFileSync } from 'node:child_process'; @@ -75,7 +86,7 @@ jobs: const npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim(); const semver = createRequire(`${npmRoot}/npm/package.json`)('semver'); const { VERSION: version, PREVIOUS_VERSION: previous, GITHUB_REF: ref } = process.env; - if (semver.valid(version) !== version || !semver.gt(version, previous)) throw new Error('Selected commit must increase the package version'); + if (semver.valid(version) !== version || !semver.gt(version, previous)) throw new Error('Release version must increase from the latest version bump'); const pre = semver.prerelease(version); const channel = pre ? String(pre[0]) : 'latest'; if (pre && (!/^[a-z][a-z0-9-]*$/.test(channel) || channel === 'latest' || semver.validRange(channel))) throw new Error('Invalid prerelease channel'); @@ -183,8 +194,9 @@ jobs: echo "$PACKAGE_NAME@$VERSION — source $SOURCE_SHA — npm channel $CHANNEL" echo "Review on npm's Staged Packages page, then approve with 2FA or npm stage approve ." } >> "$GITHUB_STEP_SUMMARY" - # npm reads GITHUB_SHA for provenance; checkout alone does not change it. - GITHUB_SHA="$SOURCE_SHA" npm stage publish "$TARBALL" \ + # Keep the artifact source and signed GitHub provenance identical. + test "$SOURCE_SHA" = "$GITHUB_SHA" + npm stage publish "$TARBALL" \ --ignore-scripts --provenance --access public --tag "$CHANNEL" \ 2>&1 | tee "$RUNNER_TEMP/npm-stage.log" - name: Preserve staging output and stage ID diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b7c9c99..d52bd335 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,9 @@ jobs: node-version: "24.22.1" cache: "npm" + - name: Test release selection + run: node --test .github/tests/release-plan.test.mjs + - name: Install and build run: | npm ci diff --git a/.prettierignore b/.prettierignore index 9d581fbc..c1034db7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,4 @@ .context/ + +# Local anonymous test deployment state +.convex/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5b8cc00..a7e22491 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,8 +74,9 @@ npx changeset version ``` Review and commit the version, changelog, and consumed changesets, then merge -through a checked PR. A push to main stages the latest version-bump commit in -that push. +through a checked PR. A push to main containing a version bump stages the tip +commit of that push. The built source must match the workflow run commit for npm +provenance verification. For a branch prerelease, prepare a prerelease version with Changesets and dispatch **Release** from the Actions page on that branch. The first prerelease @@ -84,17 +85,21 @@ main. ### Recovery -If staging has not been attempted, dispatch **Release** on the appropriate ref -with the 40-character SHA of a version-bump commit reachable from that ref. Once -staging starts, inspect npm before retrying: dispatching **Release** runs +Dispatch **Release** on the appropriate ref to release its current tip, +including fixes made after the version bump. The optional commit input is an +expected 40-character SHA and must equal the selected ref at dispatch time; it +cannot select an older commit. Stable releases still require main. + +Once staging starts, inspect npm before retrying: dispatching **Release** runs `npm stage publish` again. If the package is already staged, retry only the failed tag job or, after verifying the staged version, create its source tag at -the version-bump commit. - -| Failure | Recovery | -| ----------------------------------------------- | -------------------------------------------------------------------------------- | -| Failure before staging was attempted | Retry failed jobs for the same commit. | -| Staging succeeded; tagging failed | Retry only the failed tag job, or create the source tag after verifying staging. | -| Upload outcome uncertain | Inspect npm; retry staging only if absent, or recover the tag if already staged. | -| Fix requires changing the release commit's code | Prepare a new version-bump commit. | -| Candidate rejected after staging | Leave its tag and prepare a new version. | +the workflow run commit. If staging failed and npm confirms the version is +absent, merge any necessary fixes and dispatch from the corrected main tip. + +| Failure | Recovery | +| ---------------------------------------------- | -------------------------------------------------------------------------------- | +| Failure before staging was attempted | Retry failed jobs for the same commit. | +| Staging succeeded; tagging failed | Retry only the failed tag job, or create the source tag after verifying staging. | +| Upload outcome uncertain | Inspect npm; retry staging only if absent, or recover the tag if already staged. | +| Already-staged candidate requires code changes | Prepare a new version-bump commit. | +| Candidate rejected after staging | Leave its tag and prepare a new version. | diff --git a/example/convex/test/scenarios/burstyBatches.ts b/example/convex/test/scenarios/burstyBatches.ts index a40ec034..97db0424 100644 --- a/example/convex/test/scenarios/burstyBatches.ts +++ b/example/convex/test/scenarios/burstyBatches.ts @@ -162,8 +162,7 @@ export default internalAction({ // Log results const latency = metrics!.latency as - | { p50: number; p95: number; p99: number; max: number } - | undefined; + { p50: number; p95: number; p99: number; max: number } | undefined; const waves = metrics!.waves as | Array<{ wave: number; count: number; p50?: number; p99?: number }> | undefined; diff --git a/example/convex/test/scenarios/sustained.ts b/example/convex/test/scenarios/sustained.ts index 337b1d1b..cb8b4efb 100644 --- a/example/convex/test/scenarios/sustained.ts +++ b/example/convex/test/scenarios/sustained.ts @@ -210,8 +210,7 @@ export default internalAction({ const tps = (completedCount / total) * 1000; const msPerTask = total / completedCount; const latency = metrics.latency as - | { p50: number; p95: number; p99: number; max: number } - | undefined; + { p50: number; p95: number; p99: number; max: number } | undefined; console.log(`\n=== ${scenarioLabel} ===`); console.log( diff --git a/example/convex/test/scenarios/throughput.ts b/example/convex/test/scenarios/throughput.ts index f7e90ff4..a4c08f6b 100644 --- a/example/convex/test/scenarios/throughput.ts +++ b/example/convex/test/scenarios/throughput.ts @@ -131,8 +131,7 @@ export default internalAction({ const totalDurationMs = metrics!.totalDurationMs as number; const completedCount = metrics!.completedCount as number; const latency = metrics!.latency as - | { p50: number; p95: number; p99: number; max: number } - | undefined; + { p50: number; p95: number; p99: number; max: number } | undefined; const tasksPerSec = ((completedCount / totalDurationMs) * 1000).toFixed(0); console.log(`\n=== throughput results ===`); diff --git a/package-lock.json b/package-lock.json index bd3ceed2..d362ba38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@changesets/cli": "3.0.3", "@convex-dev/eslint-plugin": "5.0.0", "@convex-dev/static-hosting": "0.2.0", - "@convex-dev/workpool-old": "npm:@convex-dev/workpool@0.4.15", + "@convex-dev/workpool-old": "npm:@convex-dev/workpool@0.4.7", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", @@ -949,16 +949,13 @@ }, "node_modules/@convex-dev/workpool-old": { "name": "@convex-dev/workpool", - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.4.15.tgz", - "integrity": "sha512-eweNWj3nHnUpwT40S5qwtA/d9LbuVMXlBP6GqM5tE4+eAOkzWbudiiCz9bi1bBpFSjtnbdRfQ+PoBG/STqgj8A==", + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.4.7.tgz", + "integrity": "sha512-4O3VKcJXqYZ9icDgKdVPxjDGUAFK3oG0hbUwLcyYMYgsvVKlZDhvZRmczqSBZHLyrCGPpf925byh0dBigCfAGA==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@convex-dev/batch-worker": "^0.3.6" - }, "peerDependencies": { - "convex": "^1.43.0", + "convex": "^1.31.7", "convex-helpers": "^0.1.94" } }, diff --git a/src/component/stateMachine.test.ts b/src/component/stateMachine.test.ts index d0826f8c..74f1caa3 100644 --- a/src/component/stateMachine.test.ts +++ b/src/component/stateMachine.test.ts @@ -34,8 +34,7 @@ type WorkConfig = { }; type PendingCompletionConfig = - | false - | { retry: boolean; resultKind: "success" | "failed" | "canceled" }; + false | { retry: boolean; resultKind: "success" | "failed" | "canceled" }; /** Declarative description of a job's composite state across all tables. */ type CompositeState = {