Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .github/tests/release-plan.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This negative assertion can't catch a GITHUB_SHA= override split across lines, because .* stops at newlines. Scope the pattern to the stage command so it also covers backslash continuations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/tests/release-plan.test.mjs, line 137:

<comment>This negative assertion can't catch a `GITHUB_SHA=` override split across lines, because `.*` stops at newlines. Scope the pattern to the stage command so it also covers backslash continuations.</comment>

<file context>
@@ -0,0 +1,158 @@
+});
+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", () => {
</file context>
Suggested change
assert.doesNotMatch(workflow, /GITHUB_SHA=.*npm stage publish/);
assert.doesNotMatch(workflow, /GITHUB_SHA=\S*\s*\\?\s*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",
);
});
48 changes: 30 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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 <stage-id>."
} >> "$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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
.context/

# Local anonymous test deployment state
.convex/
33 changes: 19 additions & 14 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
3 changes: 1 addition & 2 deletions example/convex/test/scenarios/burstyBatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions example/convex/test/scenarios/sustained.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions example/convex/test/scenarios/throughput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===`);
Expand Down
13 changes: 5 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions src/component/stateMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading