merge: align Runner D contract with current master

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-18 16:30:10 +00:00
commit 3ef1f6276c
995 files changed
+488017 -15838

No files matched your search

@@ -38,10 +38,24 @@ get_current_stable_version() { printf '2026.709.0\\n'; }
utc_date_iso() { printf '2026-07-10\\n'; }
list_public_package_info() { printf 'cli\\tpaperclipai\\t0.0.0\\n'; }
next_stable_version() { printf '2026.710.0\\n'; }
next_canary_version() { printf '2026.710.0-canary.0\\n'; }
next_prerelease_version() { printf '2026.710.0-%s.0\\n' "$1"; }
release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; }
stable_tag_name() { printf 'v%s\\n' "$1"; }
canary_tag_name() { printf 'canary/v%s\\n' "$1"; }
prerelease_tag_name() { printf '%s/v%s\\n' "$1" "$2"; }
require_channel_tag_at_head() {
if [ "\${FAKE_MISSING_CHANNEL_TAG:-}" = "$1" ]; then
echo "Error: HEAD has no $1/v* tag; this channel only publishes commits that already shipped a $1 release." >&2
exit 1
fi
echo "[fixture] require_channel_tag_at_head $1"
}
require_channel_tag_absent_at_head() {
if [ "\${FAKE_PRESENT_CHANNEL_TAG:-}" = "$1" ]; then
echo "Error: HEAD already shipped as $1/v2026.710.0-$1.0; delete that tag first if you really want to republish this commit on the $1 channel." >&2
exit 1
fi
echo "[fixture] require_channel_tag_absent_at_head $1"
}
require_clean_worktree() { :; }
require_npm_publish_auth() { :; }
git_local_tag_exists() { return 1; }
@@ -108,7 +122,7 @@ exit 0
return { binDir, callLog, fixtureDir, script: join(scriptsDir, "release.sh") };
}
function runRelease(args) {
function runRelease(args, extraEnv = {}) {
const fixture = createReleaseFixture();
const result = spawnSync(fixture.script, args, {
cwd: fixture.fixtureDir,
@@ -117,6 +131,7 @@ function runRelease(args) {
...process.env,
PATH: `${fixture.binDir}:${process.env.PATH}`,
FAKE_CALL_LOG: fixture.callLog,
...extraEnv,
},
});
@@ -148,3 +163,87 @@ test("stable publish still requires release notes before publish work starts", (
assert.doesNotMatch(result.output, /==> Step 2\/7: Building workspace artifacts/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
test("nightly dry-run publishes under the nightly identity without release notes", () => {
const result = runRelease(["nightly", "--skip-verify", "--dry-run"]);
assert.equal(result.status, 42);
assert.match(result.output, /\[fixture\] require_channel_tag_at_head canary/);
assert.match(result.output, /Nightly version: 2026\.710\.0-nightly\.0/);
assert.match(result.output, /Dist-tag: nightly/);
assert.match(result.output, /Git tag: nightly\/v2026\.710\.0-nightly\.0/);
assert.doesNotMatch(result.output, /stable release notes file is required/);
assert.match(result.calls, /^pnpm build$/m);
});
test("nightly refuses commits that never shipped a canary", () => {
const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
FAKE_MISSING_CHANNEL_TAG: "canary",
});
assert.equal(result.status, 1);
assert.match(result.output, /HEAD has no canary\/v\* tag/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
test("nightly refuses commits that already shipped as a nightly", () => {
const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
FAKE_PRESENT_CHANNEL_TAG: "nightly",
});
assert.equal(result.status, 1);
assert.match(result.output, /HEAD already shipped as nightly\/v/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
test("beta dry-run publishes under the beta identity without release notes", () => {
const result = runRelease(["beta", "--skip-verify", "--dry-run"]);
assert.equal(result.status, 42);
assert.match(result.output, /\[fixture\] require_channel_tag_at_head nightly/);
assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/);
assert.match(result.output, /Dist-tag: beta/);
assert.match(result.output, /Git tag: beta\/v2026\.710\.0-beta\.0/);
assert.doesNotMatch(result.output, /stable release notes file is required/);
assert.match(result.calls, /^pnpm build$/m);
});
test("beta refuses commits that already shipped as a beta", () => {
const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
FAKE_PRESENT_CHANNEL_TAG: "beta",
});
assert.equal(result.status, 1);
assert.match(result.output, /HEAD already shipped as beta\/v/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
test("beta --from-candidate waives the nightly requirement but keeps the duplicate guard", () => {
const result = runRelease(["beta", "--from-candidate", "--skip-verify", "--dry-run"], {
FAKE_MISSING_CHANNEL_TAG: "nightly",
});
assert.equal(result.status, 42);
assert.doesNotMatch(result.output, /require_channel_tag_at_head nightly/);
assert.match(result.output, /\[fixture\] require_channel_tag_absent_at_head beta/);
assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/);
assert.match(result.calls, /^pnpm build$/m);
});
test("--from-candidate is rejected outside the beta channel", () => {
const result = runRelease(["nightly", "--from-candidate", "--skip-verify", "--dry-run"]);
assert.equal(result.status, 1);
assert.match(result.output, /--from-candidate only applies to the beta channel/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
test("beta refuses commits that never shipped a nightly", () => {
const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
FAKE_MISSING_CHANNEL_TAG: "nightly",
});
assert.equal(result.status, 1);
assert.match(result.output, /HEAD has no nightly\/v\* tag/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
@@ -17,13 +17,86 @@ test("release workflow delegates stable and canary verification to the reusable
releaseWorkflow,
/verify_canary:\n\s+if: github\.event_name == 'push'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ github\.sha \}\}/,
);
// The stable lane is gated on the stable channel since the nightly lane
// was added; a `needs:` line (for example a preflight job) may sit between
// the gate and the delegation.
// The stable preflight resolves source_ref to an immutable SHA exactly
// once; verification must consume that pin, not re-resolve the ref.
assert.match(
releaseWorkflow,
/verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ inputs\.source_ref \}\}/,
/verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ needs\.preflight_stable\.outputs\.sha \}\}/,
);
assert.doesNotMatch(releaseWorkflow, /verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/);
});
test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => {
const dockerfile = readFileSync(path.join(repoRoot, "docker/Dockerfile.onboard-smoke"), "utf8");
// `onboard --yes` without an explicit --bind prefers trusted-local
// defaults and writes a loopback bind, which Docker port mapping cannot
// reach. The smoke container must pin a non-loopback preset.
assert.match(dockerfile, /onboard --yes --bind lan/);
});
test("promotion selection guards against sources that predate their channel tooling", () => {
const releaseWorkflow = readWorkflow("release.yml");
// Promotions run the source commit's release.sh, so selection must reject
// sources whose tooling does not know the target channel yet.
assert.match(releaseWorkflow, /git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly'/);
assert.match(releaseWorkflow, /git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly\|beta\|stable\)'/);
});
test("candidate-branch betas are validated and fully verified before publish", () => {
const releaseWorkflow = readWorkflow("release.yml");
// Candidate heads are new commits: selection must pin the naming
// convention and publication must be gated on full verification.
assert.match(releaseWorkflow, /candidate\/beta-\*\)/);
assert.match(
releaseWorkflow,
/verify_beta_candidate:\n\s+needs: select_beta\n\s+if: needs\.select_beta\.outputs\.mode == 'candidate'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml/,
);
assert.match(releaseWorkflow, /needs\.verify_beta_candidate\.result == 'success'/);
});
test("post-publish beta smoke survives the skipped candidate-verification ancestor", () => {
const releaseWorkflow = readWorkflow("release.yml");
// publish_beta's needs chain contains verify_beta_candidate, which is
// skipped on promote-mode betas. An `if:` without a status-check function
// gets an implicit success() that evaluates that chain transitively and
// silently skips the smoke. The condition must stay explicit.
assert.match(
releaseWorkflow,
/smoke_beta:\n\s+needs: publish_beta\n\s+if: \$\{\{ !cancelled\(\) && needs\.publish_beta\.result == 'success' && !inputs\.dry_run \}\}/,
);
});
test("every lane's tag push degrades to recovery instructions when rejected", () => {
const releaseWorkflow = readWorkflow("release.yml");
// GITHUB_TOKEN may not create refs pointing at workflow-modifying commits
// from dispatch or scheduled runs; a rejected tag push after a successful
// npm publish must surface runbook recovery commands, not a bare error.
const occurrences = releaseWorkflow.match(/## Tag push rejected/g) ?? [];
assert.equal(occurrences.length, 3, "nightly, beta, and stable each carry the recovery summary");
});
test("release smoke workflow extends the container readiness budget for CI", () => {
const smokeWorkflow = readWorkflow("release-smoke.yml");
const harness = readFileSync(path.join(repoRoot, "scripts/docker-onboard-smoke.sh"), "utf8");
// CI containers cold-install paperclipai and embedded postgres, so the
// workflow must extend the harness's local-default readiness budget.
assert.match(smokeWorkflow, /SMOKE_READY_TIMEOUT_SECONDS=\d+/);
const ciBudget = Number(smokeWorkflow.match(/SMOKE_READY_TIMEOUT_SECONDS=(\d+)/)[1]);
assert.ok(ciBudget >= 300, `CI readiness budget ${ciBudget}s should be at least 300s`);
assert.match(harness, /SMOKE_READY_TIMEOUT_SECONDS="\$\{SMOKE_READY_TIMEOUT_SECONDS:-\d+\}"/);
assert.match(harness, /wait_for_http "\$PAPERCLIP_PUBLIC_URL\/api\/health" "\$SMOKE_READY_TIMEOUT_SECONDS" 1/);
});
test("release verify workflow covers the same split test surface as stable PR verification", () => {
const verifyWorkflow = readWorkflow("release-verify.yml");
@@ -47,6 +120,15 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`));
}
// workspaces-a splits with Vitest native --shard in pr.yml; release
// verification must keep the same two-shard coverage.
for (const shardIndex of [0, 1]) {
assert.match(
verifyWorkflow,
new RegExp(`group: general-workspaces-a[\\s\\S]*?shard_index: ${shardIndex}\\n\\s+shard_count: 2`),
);
}
assert.match(verifyWorkflow, /pnpm test:run:general -- --group/);
assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/);
});
@@ -13,6 +13,11 @@ import {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
const durationsManifest = path.join(repoRoot, "scripts", "general-server-shard-durations.json");
const serializedDurationsManifest = path.join(
repoRoot,
"scripts",
"serialized-shard-durations.json",
);
function dryRun(args) {
const result = spawnSync(process.execPath, [script, ...args, "--dry-run"], {
@@ -77,9 +82,37 @@ test("a route/authz suite never leaks into the general-server shards", () => {
}
});
test("shard flags are rejected for the parallel workspace groups", () => {
const result = dryRun(["--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "3"]);
assert.notEqual(result.status, 0, "workspace groups must not accept shard flags");
test("shard flags are rejected for the workspaces-b group", () => {
const result = dryRun(["--mode", "general", "--group", "general-workspaces-b", "--shard-index", "0", "--shard-count", "3"]);
assert.notEqual(result.status, 0, "workspaces-b must not accept shard flags");
});
test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => {
const shards = [0, 1].map((index) =>
dryRunJson([
"--mode", "general", "--group", "general-workspaces-a",
"--shard-index", String(index), "--shard-count", "2",
]),
);
assert.deepEqual(
shards.map((shard) => shard.workspacesVitestShard),
["1/2", "2/2"],
"each matrix job must pass its own --shard slice to vitest",
);
// Vitest's --shard partitions each project's file list deterministically, so
// an identical project list across jobs is what guarantees complete,
// non-overlapping coverage of the lane.
assert.deepEqual(shards[0].workspaceProjects, shards[1].workspaceProjects);
assert.ok(shards[0].workspaceProjects.length > 0, "workspaces-a must run at least one project");
const unsharded = dryRunJson(["--mode", "general", "--group", "general-workspaces-a"]);
assert.deepEqual(
unsharded.workspaceProjects,
shards[0].workspaceProjects,
"sharding must not change which projects the lane covers",
);
assert.equal(unsharded.workspacesVitestShard, null);
});
test("duration-aware partition balances skewed weights better than round-robin", () => {
@@ -139,6 +172,39 @@ test("the checked-in manifest loads and covers most of the current suite set", (
);
});
test("the checked-in serialized manifest loads and covers most of the current suite set", () => {
const durations = loadShardDurations(serializedDurationsManifest);
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
const shard = dryRunJson(["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]);
const currentFiles = shard.selectedSerializedSuites;
const known = currentFiles.filter((file) => durations[file] !== undefined).length;
assert.ok(
known / currentFiles.length >= 0.5,
`manifest is stale: only ${known} of ${currentFiles.length} suites have recorded durations — regenerate it from a recent PR run (see the manifest's $comment)`,
);
});
test("the real serialized shard partition is duration-balanced", () => {
const durations = loadShardDurations(serializedDurationsManifest);
const fallback = defaultSuiteWeight(durations);
const shards = Array.from({ length: SERIALIZED_SHARD_COUNT }, (_, index) =>
dryRunJson(["--mode", "serialized", "--shard-index", String(index), "--shard-count", String(SERIALIZED_SHARD_COUNT)]),
);
const totals = shards.map((shard) =>
shard.selectedSerializedSuites.reduce((sum, file) => sum + (durations[file] ?? fallback), 0),
);
const maxTotal = Math.max(...totals);
const minTotal = Math.min(...totals);
// LPT keeps the spread within the heaviest single suite; use that as the bound.
const heaviest = Math.max(...Object.values(durations));
assert.ok(
maxTotal - minTotal <= heaviest,
`serialized shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
);
});
test("the real shard partition is duration-balanced", () => {
const durations = loadShardDurations(durationsManifest);
const fallback = defaultSuiteWeight(durations);
+28 -2
View File
@@ -49,6 +49,11 @@
* `fontSize: "..."` / `font-size:` string-literal declarations in
* inline styles or css-in-js.
*
* Gate 4 — zero legacy hsl(var(--token)) wrappers in the token layer.
* Semantic colors are complete color values (currently OKLCH), not bare
* HSL channels. Wrapping one in hsl() creates an invalid declaration and
* can void an entire composed box-shadow.
*
* The ALLOWLIST is parsed from the machine-readable block in
* ui/src/index.css (search for "── ALLOWLIST" below it), one entry per
* line in the form:
@@ -240,6 +245,17 @@ function findFontSizeIssues(content) {
return issues;
}
// Semantic color custom properties hold complete color values. Legacy
// Tailwind-v3-era hsl(var(--token) / alpha) composition is therefore invalid.
const LEGACY_HSL_VAR_WRAPPER_RE = /\bhsla?\(\s*var\(--[^)]+\)[^)]*\)/g;
function findLegacyHslVarWrapperIssues(content) {
return Array.from(content.matchAll(LEGACY_HSL_VAR_WRAPPER_RE), (match) => ({
index: match.index,
snippet: match[0],
}));
}
function lineNumberAt(content, index) {
return content.slice(0, index).split("\n").length;
}
@@ -248,7 +264,7 @@ function main() {
const allowlist = loadAllowlist(CSS_PATH);
const files = listFiles();
const violations = { gate1: [], gate2: [], gate3: [] };
const violations = { gate1: [], gate2: [], gate3: [], gate4: [] };
let allowlistedSkips = 0;
for (const filePath of files) {
@@ -277,7 +293,16 @@ function main() {
}
}
const totalViolations = violations.gate1.length + violations.gate2.length + violations.gate3.length;
const tokenLayer = readFileSync(CSS_PATH, "utf8");
for (const issue of findLegacyHslVarWrapperIssues(tokenLayer)) {
violations.gate4.push({
file: relPathToPosix(CSS_PATH),
line: lineNumberAt(tokenLayer, issue.index),
snippet: issue.snippet,
});
}
const totalViolations = Object.values(violations).reduce((total, gate) => total + gate.length, 0);
console.log("check-token-gates summary");
console.log(` Files scanned: ${files.length}`);
@@ -287,6 +312,7 @@ function main() {
console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`);
console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`);
console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`);
console.log(` Gate 4 (legacy hsl(var())): ${violations.gate4.length === 0 ? "CLEAN" : `${violations.gate4.length} violation(s)`}`);
if (totalViolations > 0) {
console.log("\nViolations:\n");
+14 -1
View File
@@ -7,15 +7,17 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
dry_run=false
version=""
notes_file_override=""
usage() {
cat <<'EOF'
Usage:
./scripts/create-github-release.sh <version> [--dry-run]
./scripts/create-github-release.sh <version> [--dry-run] [--notes-file PATH]
Examples:
./scripts/create-github-release.sh 2026.318.0
./scripts/create-github-release.sh 2026.318.0 --dry-run
./scripts/create-github-release.sh 2026.318.0 --notes-file /tmp/stable-notes.md
Notes:
- Run this after pushing the stable tag.
@@ -28,6 +30,14 @@ EOF
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--notes-file)
shift
if [ $# -eq 0 ]; then
echo "Error: --notes-file requires a path." >&2
exit 1
fi
notes_file_override="$1"
;;
-h|--help)
usage
exit 0
@@ -55,6 +65,9 @@ fi
tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md"
if [ -n "$notes_file_override" ]; then
notes_file="$notes_file_override"
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -z "${PUBLISH_REMOTE:-}" ] && git_remote_exists origin; then
PUBLISH_REMOTE=origin
fi
+6 -1
View File
@@ -90,6 +90,7 @@ const tailscaleAuthFlagNames = new Set([
let tailscaleAuth = false;
let bindMode: BindMode | null = null;
let bindHost: string | null = null;
const managedRuntimeExposure = process.env.PAPERCLIP_MANAGED_RUNTIME_EXPOSURE === "tailscale_https";
const forwardedArgs: string[] = [];
for (let index = 0; index < cliArgs.length; index += 1) {
@@ -133,6 +134,10 @@ if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.
if (!bindHost && process.env.npm_config_bind_host) {
bindHost = process.env.npm_config_bind_host;
}
if (managedRuntimeExposure) {
bindMode = "custom";
bindHost = "127.0.0.1";
}
if (bindMode === "custom" && !bindHost) {
console.error("[paperclip] --bind custom requires --bind-host <host>");
process.exit(1);
@@ -174,7 +179,7 @@ if (tailscaleAuth || bindMode) {
} else {
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
env.PAPERCLIP_AUTH_BASE_URL_MODE = managedRuntimeExposure ? "explicit" : "auto";
console.log(
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
);
+9 -1
View File
@@ -13,6 +13,11 @@ PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}"
SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}"
# Seconds to wait for /api/health after the container starts. The container
# cold-installs paperclipai from npm and initializes embedded postgres before
# it can serve health, so CI callers with no warm caches need far more than
# the local default.
SMOKE_READY_TIMEOUT_SECONDS="${SMOKE_READY_TIMEOUT_SECONDS:-90}"
SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}"
SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}"
SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}"
@@ -63,6 +68,9 @@ wait_for_http() {
if ! container_is_running; then
echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before readiness check completed" >&2
docker logs "$CONTAINER_NAME" >&2 || true
else
echo "Smoke bootstrap failed: $url not ready after ${attempts} attempts; container is still running. Last container logs:" >&2
docker logs --tail 150 "$CONTAINER_NAME" >&2 || true
fi
return 1
}
@@ -278,7 +286,7 @@ fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")"
COOKIE_JAR="$TMP_DIR/cookies.txt"
if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then
if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" "$SMOKE_READY_TIMEOUT_SECONDS" 1; then
echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2
exit 1
fi
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
beta_version=""
out_file=""
repo_dir="$REPO_ROOT"
usage() {
cat <<'EOF'
Usage:
./scripts/draft-stable-notes.sh <beta-version> [--out PATH] [--repo-dir PATH]
Examples:
./scripts/draft-stable-notes.sh 2026.817.1-beta.0
./scripts/draft-stable-notes.sh 2026.817.1-beta.0 --out /tmp/draft.md
Notes:
- Generates a stable release-notes draft for the given published beta:
the commit range from the newest stable tag (v*) to the beta's source
commit, grouped by conventional-commit type.
- Written to releases/beta/v<beta-version>.md by default. The draft is
meant to be committed to master via PR and edited during the beta
soak; the stable promotion reads it from master.
- With no stable tag yet, the range falls back to the previous beta
tag, and failing that to the full history of the source commit.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--out)
shift
[ $# -gt 0 ] || release_fail "--out requires a path."
out_file="$1"
;;
--repo-dir)
shift
[ $# -gt 0 ] || release_fail "--repo-dir requires a path."
repo_dir="$1"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$beta_version" ]; then
release_fail "only one beta version may be provided."
fi
beta_version="$1"
;;
esac
shift
done
if [ -z "$beta_version" ]; then
usage
exit 1
fi
if [[ ! "$beta_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
release_fail "beta version must look like 2026.318.1-beta.0, got: $beta_version"
fi
beta_tag="beta/v${beta_version}"
if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2>&1; then
release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta."
fi
source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")"
if [ -z "$out_file" ]; then
out_file="${repo_dir}/releases/beta/v${beta_version}.md"
fi
# Range start: the newest stable tag reachable from the source commit —
# not the newest by version, which can sit on a divergent lineage (a
# stable cut from a candidate branch, or a source that predates it) and
# would produce an empty or wrong range. Before the first reachable
# stable, fall back to the nearest beta tag strictly before the source;
# with no marker at all, cover the source commit's full history.
range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)"
range_label="$range_start"
if [ -z "$range_start" ]; then
range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)"
range_label="$range_start"
fi
if [ -n "$range_start" ]; then
range="${range_start}..${source_sha}"
else
range="$source_sha"
range_label="the beginning of history"
release_info "No stable or prior beta tag found; drafting from full history."
fi
subjects="$(git -C "$repo_dir" log --no-merges --format='%s' "$range")"
section() {
local title="$1" pattern="$2" invert="${3:-false}" body
if [ "$invert" = true ]; then
body="$(printf '%s\n' "$subjects" | grep -Ev "$pattern" || true)"
else
body="$(printf '%s\n' "$subjects" | grep -E "$pattern" || true)"
fi
[ -n "$body" ] || return 0
printf '## %s\n\n' "$title"
printf '%s\n' "$body" | sed 's/^/- /'
printf '\n'
}
conventional='^(feat|fix)(\([^)]*\))?!?: '
mkdir -p "$(dirname "$out_file")"
{
printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version"
printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}"
printf '> Edit freely during the soak: rewrite for release-notes voice,\n'
printf '> fold noise, and call out anything a self-hoster must act on.\n'
printf '> The stable promotion reads this file from master and publishes\n'
printf '> it as the GitHub Release body under the stable version.\n\n'
section 'Features' '^feat(\([^)]*\))?!?: '
section 'Fixes' '^fix(\([^)]*\))?!?: '
section 'Other changes' "$conventional" true
} > "$out_file"
release_info "Draft stable notes written to $out_file"
release_info " Source beta: $beta_version ($source_sha)"
release_info " Range start: $range_label"
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const script = join(repoRoot, "scripts", "draft-stable-notes.sh");
function git(dir, ...args) {
return execFileSync("git", ["-C", dir, ...args], {
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.invalid",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.invalid",
},
});
}
function commit(dir, subject) {
git(dir, "commit", "--allow-empty", "-m", subject);
}
function makeFixtureRepo() {
const dir = mkdtempSync(join(tmpdir(), "draft-stable-notes-"));
git(dir, "init", "-q", "-b", "master");
return dir;
}
function runDraft(dir, betaVersion, extraArgs = []) {
const out = join(dir, "draft.md");
const stdout = execFileSync(
"bash",
[script, betaVersion, "--repo-dir", dir, "--out", out, ...extraArgs],
{ encoding: "utf8" }
);
return { stdout, body: readFileSync(out, "utf8") };
}
test("drafts grouped notes from the newest stable tag to the beta source", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: ancient work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: add widgets (#2)");
commit(dir, "fix(ui): unbreak widget list (#3)");
commit(dir, "chore: bump deps (#4)");
commit(dir, "feat(api)!: breaking widget API (#5)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /^# Paperclip stable draft — from beta 2026\.101\.0-beta\.0/);
assert.match(body, /## Features\n\n- feat\(api\)!: breaking widget API \(#5\)\n- feat: add widgets \(#2\)/);
assert.match(body, /## Fixes\n\n- fix\(ui\): unbreak widget list \(#3\)/);
assert.match(body, /## Other changes\n\n- chore: bump deps \(#4\)/);
assert.doesNotMatch(body, /ancient work/);
});
test("uses the nearest ancestor stable tag, not the newest by version", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: old work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: mid work (#2)");
git(dir, "tag", "beta/v2026.150.0-beta.0");
commit(dir, "feat: new work (#3)");
git(dir, "tag", "v2026.200.0");
// Promoting the older source must draft against its own lineage's last
// stable (v2026.100.0), not the newer v2026.200.0 that already contains
// it — that range would be empty.
const { body } = runDraft(dir, "2026.150.0-beta.0");
assert.match(body, /- feat: mid work \(#2\)/);
assert.doesNotMatch(body, /old work/);
assert.doesNotMatch(body, /new work/);
});
test("falls back to the previous beta tag when no stable tag exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: first-train work (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
commit(dir, "fix: second-train fix (#2)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /- fix: second-train fix \(#2\)/);
assert.doesNotMatch(body, /first-train work/);
});
test("covers full history when no earlier marker exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: the very first commit (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
const { body } = runDraft(dir, "2026.100.0-beta.0");
assert.match(body, /- feat: the very first commit \(#1\)/);
});
test("writes to releases/beta/v<version>.md inside the repo by default", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: default path (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
execFileSync("bash", [script, "2026.100.0-beta.0", "--repo-dir", dir], {
encoding: "utf8",
});
const body = readFileSync(
join(dir, "releases", "beta", "v2026.100.0-beta.0.md"),
"utf8"
);
assert.match(body, /- feat: default path \(#1\)/);
});
test("rejects a malformed beta version", () => {
const dir = makeFixtureRepo();
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});
test("rejects a beta version whose tag does not exist", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: unrelated (#1)");
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0-beta.9", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});
+66 -14
View File
@@ -197,13 +197,24 @@ process.stdout.write(`${stableSlot}.${max + 1}`);
NODE
}
next_canary_version() {
local stable_version="$1"
shift
require_prerelease_channel() {
case "$1" in
canary|nightly|beta) ;;
*) release_fail "unknown prerelease channel: $1" ;;
esac
}
node - "$stable_version" "$@" <<'NODE'
const stable = process.argv[2];
const packageNames = process.argv.slice(3);
next_prerelease_version() {
local channel="$1"
local stable_version="$2"
shift 2
require_prerelease_channel "$channel"
node - "$channel" "$stable_version" "$@" <<'NODE'
const channel = process.argv[2];
const stable = process.argv[3];
const packageNames = process.argv.slice(4);
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
@@ -218,7 +229,7 @@ if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
}
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-${channel}\\.(\\d+)$`);
let max = -1;
for (const packageName of packageNames) {
@@ -249,10 +260,16 @@ for (const packageName of packageNames) {
}
}
process.stdout.write(`${stable}-canary.${max + 1}`);
process.stdout.write(`${stable}-${channel}.${max + 1}`);
NODE
}
next_canary_version() {
local stable_version="$1"
shift
next_prerelease_version canary "$stable_version" "$@"
}
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
@@ -261,8 +278,13 @@ stable_tag_name() {
printf 'v%s\n' "$1"
}
prerelease_tag_name() {
require_prerelease_channel "$1"
printf '%s/v%s\n' "$1" "$2"
}
canary_tag_name() {
printf 'canary/v%s\n' "$1"
prerelease_tag_name canary "$1"
}
npm_package_version_exists() {
@@ -369,11 +391,14 @@ publish_package_to_npm() {
return 0
fi
if [ "$dist_tag" != "canary" ]; then
release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
rm -f "$publish_log"
return 1
fi
case "$dist_tag" in
canary|nightly) ;;
*)
release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
rm -f "$publish_log"
return 1
;;
esac
release_warn "Retrying ${package_name}@${package_version} once with npm provenance disabled."
if run_package_publish "$publish_tool" "$dist_tag" true; then
@@ -468,6 +493,33 @@ require_on_master_branch() {
fi
}
# Promotion channels only republish commits that already shipped on the
# previous lane, so the source commit must carry that lane's release tag.
require_channel_tag_at_head() {
local channel="$1"
require_prerelease_channel "$channel"
if ! git -C "$REPO_ROOT" tag --points-at HEAD | grep -q "^${channel}/v"; then
release_fail "HEAD has no ${channel}/v* tag; this channel only publishes commits that already shipped a ${channel} release."
fi
}
# The inverse guard: a commit ships on a promotion channel at most once, so
# concurrent or repeated runs cannot double-publish it. Delete the lane tag
# first if a republish is genuinely intended.
require_channel_tag_absent_at_head() {
local channel="$1"
local existing
require_prerelease_channel "$channel"
existing="$(git -C "$REPO_ROOT" tag --points-at HEAD | grep "^${channel}/v" | head -1 || true)"
if [ -n "$existing" ]; then
release_fail "HEAD already shipped as ${existing}; delete that tag first if you really want to republish this commit on the ${channel} channel."
fi
}
require_npm_publish_auth() {
local dry_run="$1"
@@ -195,3 +195,64 @@ test("next_stable_version falls back to npm view without a versions file", () =>
assert.equal(result.output, "2026.707.2");
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
});
test("next_prerelease_version counts per channel so nightly numbering ignores canaries", () => {
const fixture = makeFixture();
const versionsFile = join(fixture.fixtureDir, "versions.json");
writeFileSync(
versionsFile,
JSON.stringify({
"@paperclipai/a": ["2026.707.1-canary.4", "2026.707.1-nightly.0", "2026.707.1-nightly.1"],
}),
);
const result = runReleaseLibHelper(
'next_prerelease_version nightly 2026.707.1 "@paperclipai/a"',
fixture,
{ RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
);
assert.equal(result.status, 0);
assert.equal(result.output, "2026.707.1-nightly.2");
assert.doesNotMatch(result.calls, /npm view/);
});
test("next_prerelease_version counts beta numbering independently of other channels", () => {
const fixture = makeFixture();
const versionsFile = join(fixture.fixtureDir, "versions.json");
writeFileSync(
versionsFile,
JSON.stringify({
"@paperclipai/a": ["2026.707.1-canary.4", "2026.707.1-nightly.3", "2026.707.1-beta.0"],
}),
);
const result = runReleaseLibHelper(
'next_prerelease_version beta 2026.707.1 "@paperclipai/a"',
fixture,
{ RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
);
assert.equal(result.status, 0);
assert.equal(result.output, "2026.707.1-beta.1");
assert.doesNotMatch(result.calls, /npm view/);
});
test("next_prerelease_version rejects unknown channels", () => {
const fixture = makeFixture();
const result = runReleaseLibHelper(
'next_prerelease_version weekly 2026.707.1 "@paperclipai/a"',
fixture,
);
assert.equal(result.status, 1);
assert.match(result.output, /unknown prerelease channel: weekly/);
});
test("prerelease_tag_name namespaces tags by channel", () => {
const fixture = makeFixture();
const result = runReleaseLibHelper("prerelease_tag_name nightly 2026.707.1-nightly.2", fixture);
assert.equal(result.status, 0);
assert.equal(result.output.trim(), "nightly/v2026.707.1-nightly.2");
});
+81 -20
View File
@@ -11,6 +11,8 @@ release_date=""
dry_run=false
skip_verify=false
print_version_only=false
from_candidate=false
notes_file_override=
tag_name=""
cleanup_on_exit=false
@@ -18,11 +20,14 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
./scripts/release.sh <canary|nightly|beta|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--notes-file PATH]
Examples:
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh nightly --dry-run
./scripts/release.sh beta --dry-run
./scripts/release.sh beta --from-candidate --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
./scripts/release.sh stable --date 2026-03-18 --print-version
@@ -32,9 +37,22 @@ Notes:
zero-padded UTC day, and P is the same-day stable patch slot.
- Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag
"canary" and create the git tag canary/vYYYY.MDD.P-canary.N.
- Nightly releases republish a commit that already shipped a canary (HEAD
must carry a canary/v* tag) as YYYY.MDD.P-nightly.N under the npm
dist-tag "nightly", with the git tag nightly/vYYYY.MDD.P-nightly.N.
The version dates the nightly cut, not the source canary.
- Beta releases republish a commit that already shipped a nightly (HEAD
must carry a nightly/v* tag) as YYYY.MDD.P-beta.N under the npm
dist-tag "beta", with the git tag beta/vYYYY.MDD.P-beta.N.
- --from-candidate (beta only) waives the nightly-tag requirement for
cherry-picked candidate-branch builds; callers are responsible for
validating the candidate branch before using it.
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
create the git tag vYYYY.MDD.P.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md,
or be supplied explicitly with --notes-file (stable only). The override
exists so promotions can read notes maintained on master instead of
requiring them inside the promoted source commit's tree.
- The script rewrites versions temporarily and restores the working tree on
exit. Tags always point at the original source commit, not a generated
release commit.
@@ -85,7 +103,7 @@ set_cleanup_trap() {
while [ $# -gt 0 ]; do
case "$1" in
canary|stable)
canary|nightly|beta|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
@@ -99,6 +117,12 @@ while [ $# -gt 0 ]; do
--dry-run) dry_run=true ;;
--skip-verify) skip_verify=true ;;
--print-version) print_version_only=true ;;
--from-candidate) from_candidate=true ;;
--notes-file)
shift
[ $# -gt 0 ] || release_fail "--notes-file requires a path."
notes_file_override="$1"
;;
-h|--help)
usage
exit 0
@@ -115,6 +139,14 @@ done
exit 1
}
if [ "$from_candidate" = true ] && [ "$channel" != "beta" ]; then
release_fail "--from-candidate only applies to the beta channel."
fi
if [ -n "$notes_file_override" ] && [ "$channel" != "stable" ]; then
release_fail "--notes-file only applies to the stable channel."
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
@@ -146,9 +178,31 @@ DIST_TAG="latest"
if [ "$channel" = "canary" ]; then
require_on_master_branch
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
TARGET_PUBLISH_VERSION="$(next_prerelease_version canary "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="canary"
tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
tag_name="$(prerelease_tag_name canary "$TARGET_PUBLISH_VERSION")"
elif [ "$channel" = "nightly" ]; then
# Nightly promotes an already-shipped canary commit, so it runs from a
# detached checkout of that commit rather than the master branch tip.
require_channel_tag_at_head canary
require_channel_tag_absent_at_head nightly
TARGET_PUBLISH_VERSION="$(next_prerelease_version nightly "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="nightly"
tag_name="$(prerelease_tag_name nightly "$TARGET_PUBLISH_VERSION")"
elif [ "$channel" = "beta" ]; then
if [ "$from_candidate" = true ]; then
# Candidate builds carry targeted cherry-picks that never shipped as a
# nightly, so the nightly-tag requirement does not apply; the workflow
# validates the candidate branch identity before invoking this path.
:
else
# Beta promotes an already-shipped nightly commit.
require_channel_tag_at_head nightly
fi
require_channel_tag_absent_at_head beta
TARGET_PUBLISH_VERSION="$(next_prerelease_version beta "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="beta"
tag_name="$(prerelease_tag_name beta "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
@@ -162,6 +216,9 @@ if [ "$print_version_only" = true ]; then
fi
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
if [ -n "$notes_file_override" ]; then
NOTES_FILE="$notes_file_override"
fi
require_clean_worktree
require_npm_publish_auth "$dry_run"
@@ -193,11 +250,12 @@ release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
release_info " Canary version: $TARGET_PUBLISH_VERSION"
else
release_info " Stable version: $TARGET_PUBLISH_VERSION"
fi
case "$channel" in
canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;;
nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;;
beta) release_info " Beta version: $TARGET_PUBLISH_VERSION" ;;
*) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;;
esac
release_info " Dist-tag: $DIST_TAG"
release_info " Git tag: $tag_name"
if [ "$channel" = "stable" ]; then
@@ -358,14 +416,17 @@ release_info ""
if [ "$dry_run" = true ]; then
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
if [ "$channel" = "canary" ]; then
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
else
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
fi
case "$channel" in
canary|nightly|beta)
release_info "Published $channel ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@$channel onboard"
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
;;
*)
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
;;
esac
fi
+45 -6
View File
@@ -11,6 +11,9 @@ const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const generalServerShardDurations = loadShardDurations(
path.join(scriptsDir, "general-server-shard-durations.json"),
);
const serializedShardDurations = loadShardDurations(
path.join(scriptsDir, "serialized-shard-durations.json"),
);
const serverRoot = path.join(repoRoot, "server");
const serverSrcDir = path.join(repoRoot, "server", "src");
const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__");
@@ -48,6 +51,7 @@ const additionalSerializedServerTests = new Set([
"server/src/__tests__/invite-expiry.test.ts",
"server/src/__tests__/invite-join-manager.test.ts",
"server/src/__tests__/invite-onboarding-text.test.ts",
"server/src/__tests__/invite-url-public-base-url.test.ts",
"server/src/__tests__/issues-checkout-wakeup.test.ts",
"server/src/__tests__/issues-service.test.ts",
"server/src/__tests__/opencode-local-adapter-environment.test.ts",
@@ -208,10 +212,11 @@ function parseCliOptions(argv) {
const shardAllowed =
mode === serializedModeName ||
(mode === generalModeName && group === generalServerGroupName);
(mode === generalModeName &&
(group === generalServerGroupName || group === generalWorkspacesAGroupName));
if (!shardAllowed && shardIndex !== null) {
fail(
"--shard-index/--shard-count are only valid with --mode serialized or --mode general --group general-server.",
"--shard-index/--shard-count are only valid with --mode serialized, --mode general --group general-server, or --mode general --group general-workspaces-a.",
);
}
@@ -249,7 +254,18 @@ function parseCliOptions(argv) {
}
function selectSerializedSuites(routeTests, shardIndex, shardCount) {
return routeTests.filter((_, index) => index % shardCount === shardIndex);
// Same duration-aware LPT partition as the general-server lane. Round-robin
// over the alphabetical list clustered the heavy heartbeat/issues suites on
// one shard (291s vs 170-201s test steps across the matrix in actions run
// 32012408876), which made that shard the whole PR run's slowest check.
const byRepoPath = new Map(routeTests.map((routeTest) => [routeTest.repoPath, routeTest]));
const shardFiles = selectGeneralServerShard(
routeTests.map((routeTest) => routeTest.repoPath),
shardIndex,
shardCount,
serializedShardDurations,
);
return shardFiles.map((file) => byRepoPath.get(file));
}
function runVitest(args, label) {
@@ -287,9 +303,16 @@ function runGeneralSuites(routeTests) {
}
}
function runProjectGroup(projects, groupName) {
function runProjectGroup(projects, groupName, shardIndex = null, shardCount = null) {
// With shard args, lean on Vitest's native --shard: each matrix job runs the
// same per-project invocations but only its slice of each project's test
// files. Vitest's sharding is deterministic for an identical file list, so
// the matrix jobs form a complete, non-overlapping cover of every project.
const shardArgs =
shardCount !== null && shardCount > 1 ? [`--shard=${shardIndex + 1}/${shardCount}`] : [];
const shardSuffix = shardArgs.length > 0 ? ` shard ${shardIndex + 1}/${shardCount}` : "";
for (const project of projects) {
runVitest(["--project", project], `${groupName} project ${project}`);
runVitest(["--project", project, ...shardArgs], `${groupName} project ${project}${shardSuffix}`);
}
}
@@ -335,7 +358,11 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
}
if (groupName === generalWorkspacesAGroupName) {
runProjectGroup(generalWorkspacesAProjects, groupName);
// The ui project dominates this lane (~224s of a 319s job in actions run
// 31371439296, 2026-08-10, where workspaces-a was the slowest PR check).
// Its 439 test files shard cleanly with Vitest's native --shard, so the
// lane splits across runners without a duration manifest.
runProjectGroup(generalWorkspacesAProjects, groupName, shardIndex, shardCount);
return;
}
@@ -416,6 +443,18 @@ if (options.dryRun) {
generalServerShardDurations,
)
: null,
workspaceProjects:
options.group === generalWorkspacesAGroupName
? generalWorkspacesAProjects
: options.group === generalWorkspacesBGroupName
? generalWorkspacesBProjects
: null,
workspacesVitestShard:
options.group === generalWorkspacesAGroupName &&
options.shardCount !== null &&
options.shardCount > 1
? `${options.shardIndex + 1}/${options.shardCount}`
: null,
},
null,
2,
+140
View File
@@ -0,0 +1,140 @@
{
"$comment": "Per-suite wall-clock durations (ms) for the serialized route/authz vitest lane, used by scripts/run-vitest-stable.mjs to balance suites across the PR shard matrix. Sampled from a real PR run of .github/workflows/pr.yml (actions run 32012408876, 2026-08-17) by diffing consecutive '[test:run] <suite>' label timestamps in the 'Run serialized server test shard' logs - that captures each suite's true serial cost including the per-suite vitest spawn overhead, not just the vitest-reported test time. Suites missing here get the median weight, so the manifest only needs occasional refreshes.",
"unit": "ms",
"durations": {
"server/src/__tests__/access-routes-permissions-upgrade.test.ts": 11105,
"server/src/__tests__/activity-routes.test.ts": 3181,
"server/src/__tests__/adapter-model-refresh-routes.test.ts": 3934,
"server/src/__tests__/adapter-routes-authz.test.ts": 4391,
"server/src/__tests__/adapter-routes.test.ts": 5343,
"server/src/__tests__/agent-action-audit-routes.test.ts": 11458,
"server/src/__tests__/agent-adapter-validation-routes.test.ts": 5758,
"server/src/__tests__/agent-cross-tenant-authz-routes.test.ts": 3818,
"server/src/__tests__/agent-device-login-routes.test.ts": 6486,
"server/src/__tests__/agent-instructions-routes.test.ts": 5532,
"server/src/__tests__/agent-live-run-routes.test.ts": 5498,
"server/src/__tests__/agent-permissions-routes.test.ts": 8978,
"server/src/__tests__/agent-secrets-routes.test.ts": 11780,
"server/src/__tests__/agent-skills-routes.test.ts": 7404,
"server/src/__tests__/agent-test-environment-routes.test.ts": 5845,
"server/src/__tests__/approval-routes-idempotency.test.ts": 6142,
"server/src/__tests__/assets.test.ts": 3555,
"server/src/__tests__/auth-routes.test.ts": 2094,
"server/src/__tests__/auth-session-route.test.ts": 3070,
"server/src/__tests__/authz-company-access.test.ts": 2893,
"server/src/__tests__/authz-existence-oracle-guard.test.ts": 1121,
"server/src/__tests__/authz-secret-context.test.ts": 2398,
"server/src/__tests__/board-chat-route-feature-flag.test.ts": 1032,
"server/src/__tests__/bootstrap-claim-routes.test.ts": 4748,
"server/src/__tests__/built-in-agent-routes.test.ts": 4015,
"server/src/__tests__/cases-routes.test.ts": 12174,
"server/src/__tests__/cli-auth-routes.test.ts": 5228,
"server/src/__tests__/cloud-routes.test.ts": 2146,
"server/src/__tests__/companies-route-cross-company-authz.test.ts": 4314,
"server/src/__tests__/companies-route-path-guard.test.ts": 3004,
"server/src/__tests__/company-branding-route.test.ts": 3920,
"server/src/__tests__/company-import-transfer-routes.test.ts": 8905,
"server/src/__tests__/company-portability-routes.test.ts": 3034,
"server/src/__tests__/company-portability.test.ts": 4800,
"server/src/__tests__/company-search-extract-routes.test.ts": 7621,
"server/src/__tests__/company-search-rate-limit-routes.test.ts": 7587,
"server/src/__tests__/company-skill-policy-routes.test.ts": 6894,
"server/src/__tests__/company-skills-import-authz-routes.test.ts": 12068,
"server/src/__tests__/company-skills-routes.test.ts": 7484,
"server/src/__tests__/company-user-directory-route.test.ts": 4728,
"server/src/__tests__/costs-service.test.ts": 8319,
"server/src/__tests__/decision-queues-routes.test.ts": 6956,
"server/src/__tests__/document-annotation-routes.test.ts": 4375,
"server/src/__tests__/environment-custom-image-routes.test.ts": 4387,
"server/src/__tests__/environment-instance-routes.test.ts": 4527,
"server/src/__tests__/environment-routes.test.ts": 4780,
"server/src/__tests__/environment-selection-route-guards.test.ts": 4208,
"server/src/__tests__/execution-workspaces-routes.test.ts": 3450,
"server/src/__tests__/express5-auth-wildcard.test.ts": 1105,
"server/src/__tests__/external-object-routes.test.ts": 6231,
"server/src/__tests__/folders-routes.test.ts": 2802,
"server/src/__tests__/health-dev-server-token.test.ts": 2541,
"server/src/__tests__/health.test.ts": 2090,
"server/src/__tests__/heartbeat-dependency-scheduling.test.ts": 14289,
"server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts": 13747,
"server/src/__tests__/heartbeat-process-recovery.test.ts": 55336,
"server/src/__tests__/inbox-agent-policy-routes.test.ts": 10057,
"server/src/__tests__/inbox-archive-routes.test.ts": 9644,
"server/src/__tests__/instance-database-backups-routes.test.ts": 2962,
"server/src/__tests__/instance-settings-routes.test.ts": 5356,
"server/src/__tests__/invite-accept-existing-member.test.ts": 4812,
"server/src/__tests__/invite-accept-gateway-defaults.test.ts": 10423,
"server/src/__tests__/invite-accept-replay.test.ts": 5116,
"server/src/__tests__/invite-create-route.test.ts": 4640,
"server/src/__tests__/invite-expiry.test.ts": 7291,
"server/src/__tests__/invite-join-manager.test.ts": 7124,
"server/src/__tests__/invite-list-route.test.ts": 7580,
"server/src/__tests__/invite-logo-route.test.ts": 5104,
"server/src/__tests__/invite-onboarding-text.test.ts": 7095,
"server/src/__tests__/invite-rate-limit-route.test.ts": 7446,
"server/src/__tests__/invite-summary-route.test.ts": 7598,
"server/src/__tests__/invite-test-resolution-route.test.ts": 6135,
"server/src/__tests__/invite-url-public-base-url.test.ts": 3482,
"server/src/__tests__/issue-activity-events-routes.test.ts": 6254,
"server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts": 13283,
"server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts": 5442,
"server/src/__tests__/issue-assignee-invokability-routes.test.ts": 4602,
"server/src/__tests__/issue-attachment-routes.test.ts": 5267,
"server/src/__tests__/issue-blocker-diagnostics-routes.test.ts": 10902,
"server/src/__tests__/issue-closed-workspace-routes.test.ts": 6589,
"server/src/__tests__/issue-comment-attribution-audit-routes.test.ts": 11053,
"server/src/__tests__/issue-comment-cancel-routes.test.ts": 4687,
"server/src/__tests__/issue-comment-reopen-routes.test.ts": 4009,
"server/src/__tests__/issue-create-deduplication-routes.test.ts": 11885,
"server/src/__tests__/issue-dependency-wakeups-routes.test.ts": 5701,
"server/src/__tests__/issue-document-restore-routes.test.ts": 5902,
"server/src/__tests__/issue-execution-policy-routes.test.ts": 6571,
"server/src/__tests__/issue-feedback-routes.test.ts": 3978,
"server/src/__tests__/issue-identifier-routes.test.ts": 11276,
"server/src/__tests__/issue-list-assignee-filter-routes.test.ts": 12518,
"server/src/__tests__/issue-list-updatedsince-filter-routes.test.ts": 10919,
"server/src/__tests__/issue-onboarding-first-task-routes.test.ts": 12493,
"server/src/__tests__/issue-scheduled-retry-routes.test.ts": 11559,
"server/src/__tests__/issue-stale-execution-lock-routes.test.ts": 11281,
"server/src/__tests__/issue-stalled-review-decision-routes.test.ts": 11437,
"server/src/__tests__/issue-subtree-diagnostics-routes.test.ts": 11043,
"server/src/__tests__/issue-telemetry-routes.test.ts": 4745,
"server/src/__tests__/issue-thread-interaction-routes.test.ts": 8825,
"server/src/__tests__/issue-tree-control-routes.test.ts": 2689,
"server/src/__tests__/issue-update-comment-wakeup-routes.test.ts": 6552,
"server/src/__tests__/issue-wake-diagnostics-routes.test.ts": 11098,
"server/src/__tests__/issue-watchdogs-routes.test.ts": 15909,
"server/src/__tests__/issue-workspace-command-authz.test.ts": 3660,
"server/src/__tests__/issues-checkout-wakeup.test.ts": 1053,
"server/src/__tests__/issues-goal-context-routes.test.ts": 5356,
"server/src/__tests__/issues-service.test.ts": 37910,
"server/src/__tests__/llms-routes.test.ts": 2370,
"server/src/__tests__/low-trust-red-team-routes.test.ts": 25333,
"server/src/__tests__/multilingual-issues-routes.test.ts": 11329,
"server/src/__tests__/onboarding-seed-route.test.ts": 8981,
"server/src/__tests__/openapi-routes.test.ts": 3234,
"server/src/__tests__/openclaw-invite-prompt-route.test.ts": 3938,
"server/src/__tests__/opencode-local-adapter-environment.test.ts": 1071,
"server/src/__tests__/permissions-upgrade-boundary-routes.test.ts": 11054,
"server/src/__tests__/pipelines-routes.test.ts": 13514,
"server/src/__tests__/plugin-install-route-security.test.ts": 10977,
"server/src/__tests__/plugin-routes-authz.test.ts": 3628,
"server/src/__tests__/plugin-scoped-api-routes.test.ts": 3535,
"server/src/__tests__/project-goal-telemetry-routes.test.ts": 4566,
"server/src/__tests__/project-routes-env.test.ts": 3141,
"server/src/__tests__/projects-list-archived-routes.test.ts": 10943,
"server/src/__tests__/redaction.test.ts": 973,
"server/src/__tests__/resource-memberships-routes.test.ts": 11206,
"server/src/__tests__/routine-document-annotation-routes.test.ts": 2824,
"server/src/__tests__/routines-e2e.test.ts": 12006,
"server/src/__tests__/routines-routes.test.ts": 4019,
"server/src/__tests__/secret-proposals-routes.test.ts": 15670,
"server/src/__tests__/secrets-routes.test.ts": 4757,
"server/src/__tests__/sidebar-preferences-routes.test.ts": 3080,
"server/src/__tests__/summary-slot-routes.test.ts": 3858,
"server/src/__tests__/teams-catalog-routes.test.ts": 3463,
"server/src/__tests__/user-profile-routes.test.ts": 6441,
"server/src/__tests__/workspace-runtime-routes-authz.test.ts": 3296,
"server/src/__tests__/workspace-runtime-service-authz.test.ts": 6251
}
}
+14 -7
View File
@@ -3,6 +3,9 @@
import { pathToFileURL } from "node:url";
const CANARY_VERSION_RE = /-canary\.\d+$/;
const PRERELEASE_VERSION_RE = /-(?:canary|nightly|beta)\.\d+$/;
// Channels that publish prerelease versions and must never move `latest`.
const PRERELEASE_CHANNELS = new Set(["canary", "nightly", "beta"]);
const EXIT_RETRIABLE_FAILURE = 1;
const EXIT_NON_RETRIABLE_FAILURE = 2;
@@ -10,6 +13,10 @@ export function isCanaryVersion(version) {
return CANARY_VERSION_RE.test(version);
}
export function isPrereleaseVersion(version) {
return PRERELEASE_VERSION_RE.test(version);
}
function createExitError(message, exitCode = EXIT_RETRIABLE_FAILURE) {
return Object.assign(new Error(message), { exitCode });
}
@@ -22,7 +29,7 @@ function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/verify-release-registry-state.mjs --channel <canary|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--allow-canary-latest]",
" node scripts/verify-release-registry-state.mjs --channel <canary|nightly|beta|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--allow-canary-latest]",
"",
].join("\n"),
);
@@ -69,8 +76,8 @@ function parseArgs(argv) {
}
}
if (options.channel !== "canary" && options.channel !== "stable") {
throw createExitError("--channel must be canary or stable", EXIT_NON_RETRIABLE_FAILURE);
if (!PRERELEASE_CHANNELS.has(options.channel) && options.channel !== "stable") {
throw createExitError("--channel must be canary, nightly, beta, or stable", EXIT_NON_RETRIABLE_FAILURE);
}
if (!options.distTag) {
@@ -284,19 +291,19 @@ export function verifyPackageRegistryProblems({
}
}
if (channel === "canary") {
if (PRERELEASE_CHANNELS.has(channel)) {
const latestVersion = distTags.latest;
if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) {
if (latestVersion && isPrereleaseVersion(latestVersion) && !allowCanaryLatest) {
problems.push(
createProblem(
`${packageName}: latest dist-tag still resolves to canary ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`,
`${packageName}: latest dist-tag still resolves to prerelease ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`,
{ retriable: false },
),
);
}
if (latestVersion && isCanaryVersion(latestVersion)) {
if (latestVersion && isPrereleaseVersion(latestVersion)) {
const latestManifest = requireManifest(
packageName,
latestVersion,
+40 -2
View File
@@ -6,6 +6,7 @@ import {
createManifestLookupKey,
fetchRegistryJson,
isCanaryVersion,
isPrereleaseVersion,
verifyPackageRegistryProblems,
verifyPackageRegistryState,
} from "./verify-release-registry-state.mjs";
@@ -15,6 +16,13 @@ test("isCanaryVersion matches release canaries", () => {
assert.equal(isCanaryVersion("2026.427.0"), false);
});
test("isPrereleaseVersion matches canary and nightly versions", () => {
assert.equal(isPrereleaseVersion("2026.427.0-canary.3"), true);
assert.equal(isPrereleaseVersion("2026.427.0-nightly.0"), true);
assert.equal(isPrereleaseVersion("2026.427.0-beta.2"), true);
assert.equal(isPrereleaseVersion("2026.427.0"), false);
});
test("collectInternalDependencyProblems flags missing internal versions", () => {
const manifest = {
dependencies: {
@@ -192,7 +200,7 @@ test("verifyPackageRegistryState fails when canary latest is left in place by de
allowCanaryLatest: false,
}),
[
"@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest",
"@paperclipai/plugin-e2b: latest dist-tag still resolves to prerelease 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest",
"@paperclipai/plugin-e2b@2026.425.0-canary.5 via latest: dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version",
],
);
@@ -225,7 +233,37 @@ test("verifyPackageRegistryProblems marks canary latest drift as non-retriable",
});
assert.equal(problems[0]?.retriable, false);
assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to canary/);
assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease/);
});
test("verifyPackageRegistryProblems accepts the nightly channel and flags nightly latest drift", () => {
const packageDocsByName = new Map([
[
"@paperclipai/plugin-e2b",
{
"dist-tags": {
latest: "2026.425.0-nightly.1",
nightly: "2026.427.0-nightly.0",
},
versions: {
"2026.427.0-nightly.0": {},
},
},
],
]);
const problems = verifyPackageRegistryProblems({
packageName: "@paperclipai/plugin-e2b",
packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"),
packageDocsByName,
channel: "nightly",
distTag: "nightly",
targetVersion: "2026.427.0-nightly.0",
allowCanaryLatest: false,
});
assert.equal(problems[0]?.retriable, false);
assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease 2026\.425\.0-nightly\.1/);
});
test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => {