mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
merge: align Runner D contract with current master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` : ""})`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+129
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user