chore: automate canary and stable releases

This commit is contained in:
Dotta
2026-03-17 14:08:55 -05:00
parent 7b9718cbaa
commit 21c1235277
18 changed files with 1536 additions and 1260 deletions

View File

@@ -14,11 +14,11 @@ Usage:
./scripts/create-github-release.sh <version> [--dry-run]
Examples:
./scripts/create-github-release.sh 1.2.3
./scripts/create-github-release.sh 1.2.3 --dry-run
./scripts/create-github-release.sh 2026.3.17
./scripts/create-github-release.sh 2026.3.17 --dry-run
Notes:
- Run this after pushing the stable release branch and tag.
- Run this after pushing the stable tag.
- Defaults to git remote public-gh.
- If the release already exists, this script updates its title and notes.
EOF
@@ -48,7 +48,7 @@ if [ -z "$version" ]; then
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable semver like 1.2.3." >&2
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
exit 1
fi

View File

@@ -37,7 +37,7 @@ const workspacePaths = [
];
// Workspace packages that are NOT bundled and must stay as npm dependencies.
// These get published separately via Changesets and resolved at runtime.
// These get published separately and resolved at runtime.
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
@@ -57,7 +57,7 @@ for (const pkgPath of workspacePaths) {
if (externalWorkspacePackages.has(name)) {
const pkgDirMap = { "@paperclipai/server": "server" };
const wsPkg = readPkg(pkgDirMap[name]);
allDeps[name] = `^${wsPkg.version}`;
allDeps[name] = wsPkg.version;
continue;
}
// Keep the more specific (pinned) version if conflict

View File

@@ -64,6 +64,11 @@ resolve_release_remote() {
return
fi
if git_remote_exists public; then
printf 'public\n'
return
fi
if git_remote_exists origin; then
printf 'origin\n'
return
@@ -76,6 +81,18 @@ fetch_release_remote() {
git -C "$REPO_ROOT" fetch "$1" --prune --tags
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_tag_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
}
git_remote_tag_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
}
get_last_stable_tag() {
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
}
@@ -90,32 +107,27 @@ get_current_stable_version() {
fi
}
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
stable_version_for_date() {
node - "${1:-}" <<'NODE'
const input = process.argv[2];
if (!match) {
throw new Error(`invalid semver version: ${current}`);
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
process.stdout.write(`${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}`);
NODE
}
process.stdout.write(`${major}.${minor}.${patch}`);
utc_date_iso() {
node <<'NODE'
const date = new Date();
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
process.stdout.write(`${y}-${m}-${d}`);
NODE
}
@@ -150,50 +162,16 @@ process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
release_branch_name() {
printf 'release/%s\n' "$1"
}
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
default_release_worktree_path() {
local version="$1"
local parent_dir
local repo_name
parent_dir="$(cd "$REPO_ROOT/.." && pwd)"
repo_name="$(basename "$REPO_ROOT")"
printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version"
stable_tag_name() {
printf 'v%s\n' "$1"
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_branch_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1"
}
git_remote_branch_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1
}
git_local_tag_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
}
git_remote_tag_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
}
npm_version_exists() {
local version="$1"
local resolved
resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)"
[ "$resolved" = "$version" ]
canary_tag_name() {
printf 'canary/v%s\n' "$1"
}
npm_package_version_exists() {
@@ -232,50 +210,38 @@ require_clean_worktree() {
fi
}
git_worktree_path_for_branch() {
local branch_ref="refs/heads/$1"
git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" '
$1 == "worktree" { path = substr($0, 10) }
$1 == "branch" && $2 == branch_ref { print path; exit }
'
}
path_is_worktree_for_branch() {
local path="$1"
local branch="$2"
require_on_master_branch() {
local current_branch
[ -d "$path" ] || return 1
current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$branch" ]
}
ensure_release_branch_for_version() {
local stable_version="$1"
local current_branch
local expected_branch
current_branch="$(git_current_branch)"
expected_branch="$(release_branch_name "$stable_version")"
if [ -z "$current_branch" ]; then
release_fail "release work must run from branch $expected_branch, but HEAD is detached."
fi
if [ "$current_branch" != "$expected_branch" ]; then
release_fail "release work must run from branch $expected_branch, but current branch is $current_branch."
if [ "$current_branch" != "master" ]; then
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
fi
}
stable_release_exists_anywhere() {
local stable_version="$1"
local remote="$2"
local tag="v$stable_version"
require_npm_publish_auth() {
local dry_run="$1"
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
}
release_train_is_frozen() {
stable_release_exists_anywhere "$1" "$2"
list_public_package_info() {
node "$REPO_ROOT/scripts/release-package-map.mjs" list
}
set_public_package_version() {
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env node
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const roots = ["packages", "server", "ui", "cli"];
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function discoverPublicPackages() {
const packages = [];
function walk(relDir) {
const absDir = join(repoRoot, relDir);
if (!existsSync(absDir)) return;
const pkgPath = join(absDir, "package.json");
if (existsSync(pkgPath)) {
const pkg = readJson(pkgPath);
if (!pkg.private) {
packages.push({
dir: relDir,
pkgPath,
name: pkg.name,
version: pkg.version,
pkg,
});
}
return;
}
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
walk(join(relDir, entry.name));
}
}
for (const rel of roots) {
walk(rel);
}
return packages;
}
function sortTopologically(packages) {
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
const visited = new Set();
const visiting = new Set();
const ordered = [];
function visit(pkg) {
if (visited.has(pkg.name)) return;
if (visiting.has(pkg.name)) {
throw new Error(`cycle detected in public package graph at ${pkg.name}`);
}
visiting.add(pkg.name);
const dependencySections = [
pkg.pkg.dependencies ?? {},
pkg.pkg.optionalDependencies ?? {},
pkg.pkg.peerDependencies ?? {},
];
for (const deps of dependencySections) {
for (const depName of Object.keys(deps)) {
const dep = byName.get(depName);
if (dep) visit(dep);
}
}
visiting.delete(pkg.name);
visited.add(pkg.name);
ordered.push(pkg);
}
for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) {
visit(pkg);
}
return ordered;
}
function replaceWorkspaceDeps(deps, version) {
if (!deps) return deps;
const next = { ...deps };
for (const [name, value] of Object.entries(next)) {
if (!name.startsWith("@paperclipai/")) continue;
if (typeof value !== "string" || !value.startsWith("workspace:")) continue;
next[name] = version;
}
return next;
}
function setVersion(version) {
const packages = sortTopologically(discoverPublicPackages());
for (const pkg of packages) {
const nextPkg = {
...pkg.pkg,
version,
dependencies: replaceWorkspaceDeps(pkg.pkg.dependencies, version),
optionalDependencies: replaceWorkspaceDeps(pkg.pkg.optionalDependencies, version),
peerDependencies: replaceWorkspaceDeps(pkg.pkg.peerDependencies, version),
devDependencies: replaceWorkspaceDeps(pkg.pkg.devDependencies, version),
};
writeFileSync(pkg.pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`);
}
const cliEntryPath = join(repoRoot, "cli/src/index.ts");
const cliEntry = readFileSync(cliEntryPath, "utf8");
const nextCliEntry = cliEntry.replace(
/\.version\("([^"]+)"\)/,
`.version("${version}")`,
);
if (cliEntry === nextCliEntry) {
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
}
writeFileSync(cliEntryPath, nextCliEntry);
}
function listPackages() {
const packages = sortTopologically(discoverPublicPackages());
for (const pkg of packages) {
process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`);
}
}
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/release-package-map.mjs list",
" node scripts/release-package-map.mjs set-version <version>",
"",
].join("\n"),
);
}
const [command, arg] = process.argv.slice(2);
if (command === "list") {
listPackages();
process.exit(0);
}
if (command === "set-version") {
if (!arg) {
usage();
process.exit(1);
}
setVersion(arg);
process.exit(0);
}
usage();
process.exit(1);

View File

@@ -1,201 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
export GIT_PAGER=cat
channel=""
bump_type=""
usage() {
cat <<'EOF'
Usage:
./scripts/release-preflight.sh <canary|stable> <patch|minor|major>
Examples:
./scripts/release-preflight.sh canary patch
./scripts/release-preflight.sh stable minor
What it does:
- verifies the git worktree is clean, including untracked files
- verifies you are on the matching release/X.Y.Z branch
- shows the last stable tag and the target version(s)
- shows the git/npm/GitHub release-train state
- shows commits since the last stable tag
- highlights migration/schema/breaking-change signals
- runs the verification gate:
pnpm -r typecheck
pnpm test:run
pnpm build
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
usage
exit 0
;;
*)
if [ -z "$channel" ]; then
channel="$1"
elif [ -z "$bump_type" ]; then
bump_type="$1"
else
echo "Error: unexpected argument: $1" >&2
exit 1
fi
;;
esac
shift
done
if [ -z "$channel" ] || [ -z "$bump_type" ]; then
usage
exit 1
fi
if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then
usage
exit 1
fi
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
RELEASE_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$RELEASE_REMOTE"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
CURRENT_BRANCH="$(git_current_branch)"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
require_clean_worktree
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
echo "Error: next stable version matches the current stable version." >&2
exit 1
fi
if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
echo "Error: canary target was derived from the current stable version, which is not allowed." >&2
exit 1
fi
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
REMOTE_BRANCH_EXISTS="no"
REMOTE_TAG_EXISTS="no"
LOCAL_TAG_EXISTS="no"
NPM_STABLE_EXISTS="no"
if git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$RELEASE_REMOTE"; then
REMOTE_BRANCH_EXISTS="yes"
fi
if git_local_tag_exists "$RELEASE_TAG"; then
LOCAL_TAG_EXISTS="yes"
fi
if git_remote_tag_exists "$RELEASE_TAG" "$RELEASE_REMOTE"; then
REMOTE_TAG_EXISTS="yes"
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
NPM_STABLE_EXISTS="yes"
fi
if [ "$LOCAL_TAG_EXISTS" = "yes" ] || [ "$REMOTE_TAG_EXISTS" = "yes" ] || [ "$NPM_STABLE_EXISTS" = "yes" ]; then
echo "Error: release train $EXPECTED_RELEASE_BRANCH is frozen because $RELEASE_TAG already exists locally, remotely, or version $TARGET_STABLE_VERSION is already on npm." >&2
exit 1
fi
echo ""
echo "==> Release preflight"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Current branch: ${CURRENT_BRANCH:-<detached>}"
echo " Expected branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
echo " Next canary version: $TARGET_CANARY_VERSION"
echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N"
fi
echo ""
echo "==> Working tree"
echo " ✓ Clean"
echo " ✓ Branch matches release train"
echo ""
echo "==> Release train state"
echo " Remote branch exists: $REMOTE_BRANCH_EXISTS"
echo " Local stable tag exists: $LOCAL_TAG_EXISTS"
echo " Remote stable tag exists: $REMOTE_TAG_EXISTS"
echo " Stable version on npm: $NPM_STABLE_EXISTS"
if [ -f "$NOTES_FILE" ]; then
echo " Release notes: present at $NOTES_FILE"
else
echo " Release notes: missing at $NOTES_FILE"
fi
if [ "$REMOTE_BRANCH_EXISTS" = "no" ]; then
echo " Warning: remote branch $EXPECTED_RELEASE_BRANCH does not exist on $RELEASE_REMOTE yet."
fi
echo ""
echo "==> Commits since last stable tag"
if [ -n "$LAST_STABLE_TAG" ]; then
git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true
else
git -C "$REPO_ROOT" --no-pager log --oneline --no-merges || true
fi
echo ""
echo "==> Migration / breaking change signals"
if [ -n "$LAST_STABLE_TAG" ]; then
echo "-- migrations --"
git -C "$REPO_ROOT" --no-pager diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true
echo "-- schema --"
git -C "$REPO_ROOT" --no-pager diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true
echo "-- breaking commit messages --"
git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
else
echo "No stable tag exists yet. Review the full current tree manually."
fi
echo ""
echo "==> Verification gate"
cd "$REPO_ROOT"
pnpm -r typecheck
pnpm test:run
pnpm build
echo ""
echo "==> Release preflight summary"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Release branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
echo " Next canary version: $TARGET_CANARY_VERSION"
echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N"
fi
echo ""
echo "Preflight passed for $channel release."

View File

@@ -1,182 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
dry_run=false
push_branch=true
bump_type=""
worktree_path=""
usage() {
cat <<'EOF'
Usage:
./scripts/release-start.sh <patch|minor|major> [--dry-run] [--no-push] [--worktree-dir PATH]
Examples:
./scripts/release-start.sh patch
./scripts/release-start.sh minor --dry-run
./scripts/release-start.sh major --worktree-dir ../paperclip-release-1.0.0
What it does:
- fetches the release remote and tags
- computes the next stable version from the latest stable tag
- creates or resumes branch release/X.Y.Z
- creates or resumes a dedicated worktree for that branch
- pushes the release branch to the remote by default
Notes:
- Stable publishes freeze a release train. If vX.Y.Z already exists locally,
remotely, or on npm, this script refuses to reuse release/X.Y.Z.
- Use --no-push only if you intentionally do not want the release branch on
GitHub yet.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--no-push) push_branch=false ;;
--worktree-dir)
shift
[ $# -gt 0 ] || release_fail "--worktree-dir requires a path."
worktree_path="$1"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$bump_type" ]; then
release_fail "only one bump type may be provided."
fi
bump_type="$1"
;;
esac
shift
done
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
release_remote="$(resolve_release_remote)"
fetch_release_remote "$release_remote"
last_stable_tag="$(get_last_stable_tag)"
current_stable_version="$(get_current_stable_version)"
target_stable_version="$(compute_bumped_version "$current_stable_version" "$bump_type")"
target_canary_version="$(next_canary_version "$target_stable_version")"
release_branch="$(release_branch_name "$target_stable_version")"
release_tag="v$target_stable_version"
if [ -z "$worktree_path" ]; then
worktree_path="$(default_release_worktree_path "$target_stable_version")"
fi
if stable_release_exists_anywhere "$target_stable_version" "$release_remote"; then
release_fail "release train $release_branch is frozen because $release_tag already exists locally, remotely, or version $target_stable_version is already on npm."
fi
branch_exists_local=false
branch_exists_remote=false
branch_worktree_path=""
created_worktree=false
created_branch=false
pushed_branch=false
if git_local_branch_exists "$release_branch"; then
branch_exists_local=true
fi
if git_remote_branch_exists "$release_branch" "$release_remote"; then
branch_exists_remote=true
fi
branch_worktree_path="$(git_worktree_path_for_branch "$release_branch")"
if [ -n "$branch_worktree_path" ]; then
worktree_path="$branch_worktree_path"
fi
if [ -e "$worktree_path" ] && ! path_is_worktree_for_branch "$worktree_path" "$release_branch"; then
release_fail "path $worktree_path already exists and is not a worktree for $release_branch."
fi
if [ -z "$branch_worktree_path" ]; then
if [ "$dry_run" = true ]; then
if [ "$branch_exists_local" = true ] || [ "$branch_exists_remote" = true ]; then
release_info "[dry-run] Would add worktree $worktree_path for existing branch $release_branch"
else
release_info "[dry-run] Would create branch $release_branch from $release_remote/master"
release_info "[dry-run] Would add worktree $worktree_path"
fi
else
if [ "$branch_exists_local" = true ]; then
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
elif [ "$branch_exists_remote" = true ]; then
git -C "$REPO_ROOT" branch --track "$release_branch" "$release_remote/$release_branch"
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
created_branch=true
else
git -C "$REPO_ROOT" worktree add -b "$release_branch" "$worktree_path" "$release_remote/master"
created_branch=true
fi
created_worktree=true
fi
fi
if [ "$dry_run" = false ] && [ "$push_branch" = true ] && [ "$branch_exists_remote" = false ]; then
git -C "$worktree_path" push -u "$release_remote" "$release_branch"
pushed_branch=true
fi
if [ "$dry_run" = false ] && [ "$branch_exists_remote" = true ]; then
git -C "$worktree_path" branch --set-upstream-to "$release_remote/$release_branch" "$release_branch" >/dev/null 2>&1 || true
fi
release_info ""
release_info "==> Release train"
release_info " Remote: $release_remote"
release_info " Last stable tag: ${last_stable_tag:-<none>}"
release_info " Current stable version: $current_stable_version"
release_info " Bump: $bump_type"
release_info " Target stable version: $target_stable_version"
release_info " Next canary version: $target_canary_version"
release_info " Branch: $release_branch"
release_info " Tag (reserved until stable publish): $release_tag"
release_info " Worktree: $worktree_path"
release_info " Release notes path: $worktree_path/releases/v${target_stable_version}.md"
release_info ""
release_info "==> Status"
if [ -n "$branch_worktree_path" ]; then
release_info " ✓ Reusing existing worktree for $release_branch"
elif [ "$dry_run" = true ]; then
release_info " ✓ Dry run only; no branch or worktree created"
else
[ "$created_branch" = true ] && release_info " ✓ Created branch $release_branch"
[ "$created_worktree" = true ] && release_info " ✓ Created worktree $worktree_path"
fi
if [ "$branch_exists_remote" = true ]; then
release_info " ✓ Remote branch already exists on $release_remote"
elif [ "$dry_run" = true ] && [ "$push_branch" = true ]; then
release_info " [dry-run] Would push $release_branch to $release_remote"
elif [ "$push_branch" = true ] && [ "$pushed_branch" = true ]; then
release_info " ✓ Pushed $release_branch to $release_remote"
elif [ "$push_branch" = false ]; then
release_warn "release branch was not pushed. Stable publish will later refuse until the branch exists on $release_remote."
fi
release_info ""
release_info "Next steps:"
release_info " cd $worktree_path"
release_info " Draft or update releases/v${target_stable_version}.md"
release_info " ./scripts/release-preflight.sh canary $bump_type"
release_info " ./scripts/release.sh $bump_type --canary"
release_info ""
release_info "Merge rule:"
release_info " Merge $release_branch back to master without squash or rebase so tag $release_tag remains reachable from master."

476
scripts/release.sh Executable file → Normal file
View File

@@ -1,80 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# release.sh — Prepare and publish a Paperclip release.
#
# Stable release:
# ./scripts/release.sh patch
# ./scripts/release.sh minor --dry-run
#
# Canary release:
# ./scripts/release.sh patch --canary
# ./scripts/release.sh minor --canary --dry-run
#
# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
CLI_DIR="$REPO_ROOT/cli"
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
channel=""
release_date=""
dry_run=false
canary=false
bump_type=""
skip_verify=false
tag_name=""
cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <patch|minor|major> [--canary] [--dry-run]
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify]
Examples:
./scripts/release.sh patch
./scripts/release.sh minor --dry-run
./scripts/release.sh patch --canary
./scripts/release.sh minor --canary --dry-run
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
Notes:
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm
dist-tag "canary".
- Stable publishes 1.2.3 under the npm dist-tag "latest".
- Run this from branch release/X.Y.Z matching the computed target version.
- Dry runs leave the working tree clean.
- Canary releases publish YYYY.M.D-canary.N under the npm dist-tag "canary"
and create the git tag canary/vYYYY.M.D-canary.N.
- Stable releases publish YYYY.M.D under the npm dist-tag "latest" and create
the git tag vYYYY.M.D.
- Stable release notes must already exist at releases/vYYYY.M.D.md.
- 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.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--canary) canary=true ;;
-h|--help)
usage
exit 0
;;
--promote)
echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead."
exit 1
;;
*)
if [ -n "$bump_type" ]; then
echo "Error: only one bump type may be provided."
exit 1
fi
bump_type="$1"
;;
esac
shift
done
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
@@ -91,8 +53,6 @@ restore_publish_artifacts() {
cleanup_release_state() {
restore_publish_artifacts
rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE"
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
if [ -n "$tracked_changes" ]; then
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
@@ -114,260 +74,134 @@ cleanup_release_state() {
fi
}
if [ "$cleanup_on_exit" = true ]; then
trap cleanup_release_state EXIT
fi
set_cleanup_trap() {
cleanup_on_exit=true
trap cleanup_release_state EXIT
}
require_npm_publish_auth() {
if [ "$dry_run" = true ]; then
return
fi
while [ $# -gt 0 ]; do
case "$1" in
canary|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
channel="$1"
;;
--date)
shift
[ $# -gt 0 ] || release_fail "--date requires YYYY-MM-DD."
release_date="$1"
;;
--dry-run) dry_run=true ;;
--skip-verify) skip_verify=true ;;
-h|--help)
usage
exit 0
;;
*)
release_fail "unexpected argument: $1"
;;
esac
shift
done
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
}
list_public_package_info() {
node - "$REPO_ROOT" <<'NODE'
const fs = require('fs');
const path = require('path');
const root = process.argv[2];
const roots = ['packages', 'server', 'ui', 'cli'];
const seen = new Set();
const rows = [];
function walk(relDir) {
const absDir = path.join(root, relDir);
const pkgPath = path.join(absDir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (!pkg.private) {
rows.push([relDir, pkg.name]);
}
return;
}
if (!fs.existsSync(absDir)) {
return;
}
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
walk(path.join(relDir, entry.name));
}
}
for (const rel of roots) {
walk(rel);
}
rows.sort((a, b) => a[0].localeCompare(b[0]));
for (const [dir, name] of rows) {
const pkgPath = path.join(root, dir, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const key = `${dir}\t${name}\t${pkg.version}`;
if (seen.has(key)) continue;
seen.add(key);
process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`);
}
NODE
}
replace_version_string() {
local from_version="$1"
local to_version="$2"
node - "$REPO_ROOT" "$from_version" "$to_version" <<'NODE'
const fs = require('fs');
const path = require('path');
const root = process.argv[2];
const fromVersion = process.argv[3];
const toVersion = process.argv[4];
const roots = ['packages', 'server', 'ui', 'cli'];
const targets = new Set(['package.json', 'CHANGELOG.md']);
const extraFiles = [path.join('cli', 'src', 'index.ts')];
function rewriteFile(filePath) {
if (!fs.existsSync(filePath)) return;
const current = fs.readFileSync(filePath, 'utf8');
if (!current.includes(fromVersion)) return;
fs.writeFileSync(filePath, current.split(fromVersion).join(toVersion));
}
function walk(relDir) {
const absDir = path.join(root, relDir);
if (!fs.existsSync(absDir)) return;
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
walk(path.join(relDir, entry.name));
continue;
}
if (targets.has(entry.name)) {
rewriteFile(path.join(absDir, entry.name));
}
}
}
for (const rel of roots) {
walk(rel);
}
for (const relFile of extraFiles) {
rewriteFile(path.join(root, relFile));
}
NODE
[ -n "$channel" ] || {
usage
exit 1
}
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
CURRENT_BRANCH="$(git_current_branch)"
CURRENT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
TARGET_STABLE_VERSION="$(stable_version_for_date "$RELEASE_DATE")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
CURRENT_BRANCH="$(git_current_branch)"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
DIST_TAG="latest"
if [ "$canary" = true ]; then
if [ "$channel" = "canary" ]; then
require_on_master_branch
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
DIST_TAG="canary"
tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
release_fail "next stable version matches the current stable version. Refusing to publish."
fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
require_clean_worktree
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then
release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE."
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH."
fi
if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then
release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable."
fi
if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then
if [ "$canary" = false ] && [ "$dry_run" = false ]; then
release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish."
fi
release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet."
fi
require_npm_publish_auth "$dry_run"
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
if [ -z "$PUBLIC_PACKAGE_INFO" ]; then
release_fail "no public packages were found in the workspace."
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
if [ "$channel" = "stable" ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$channel" = "canary" ] && [ -f "$NOTES_FILE" ]; then
release_info " ✓ Stable release notes already exist at $NOTES_FILE"
fi
if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLISH_REMOTE"; then
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
fi
while IFS= read -r package_name; do
[ -z "$package_name" ] && continue
if npm_package_version_exists "$package_name" "$TARGET_PUBLISH_VERSION"; then
release_fail "npm version ${package_name}@${TARGET_PUBLISH_VERSION} already exists."
fi
done <<< "$PUBLIC_PACKAGE_NAMES"
release_info ""
release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE"
release_info " Channel: $channel"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
release_info " Source commit: $CURRENT_SHA"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then
release_info " Target stable version: $TARGET_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"
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else
release_info " Stable version: $TARGET_STABLE_VERSION"
release_info " Stable version: $TARGET_PUBLISH_VERSION"
fi
release_info " Dist-tag: $DIST_TAG"
release_info " Git tag: $tag_name"
if [ "$channel" = "stable" ]; then
release_info " Release notes: $NOTES_FILE"
fi
set_cleanup_trap
if [ "$skip_verify" = false ]; then
release_info ""
release_info "==> Step 1/7: Verification gate..."
cd "$REPO_ROOT"
pnpm -r typecheck
pnpm test:run
pnpm build
else
release_info ""
release_info "==> Step 1/7: Verification gate skipped (--skip-verify)"
fi
release_info ""
release_info "==> Step 1/7: Preflight checks..."
release_info " ✓ Working tree is clean"
release_info " ✓ Branch matches release train"
require_npm_publish_auth
if [ "$dry_run" = true ] || [ "$canary" = true ]; then
set_cleanup_trap
fi
release_info ""
release_info "==> Step 2/7: Creating release changeset..."
{
echo "---"
while IFS= read -r pkg_name; do
[ -z "$pkg_name" ] && continue
echo "\"$pkg_name\": $bump_type"
done <<< "$PUBLIC_PACKAGE_NAMES"
echo "---"
echo ""
if [ "$canary" = true ]; then
echo "Canary release preparation for $TARGET_STABLE_VERSION"
else
echo "Stable release preparation for $TARGET_STABLE_VERSION"
fi
} > "$TEMP_CHANGESET_FILE"
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
release_info ""
release_info "==> Step 3/7: Versioning packages..."
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
npx changeset pre enter canary
fi
npx changeset version
if [ "$canary" = true ]; then
BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0"
if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then
replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION"
fi
fi
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
release_info "==> Step 2/7: Rewriting workspace versions..."
set_public_package_version "$TARGET_PUBLISH_VERSION"
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info ""
release_info "==> Step 4/7: Building workspace artifacts..."
release_info "==> Step 3/7: Building workspace artifacts..."
cd "$REPO_ROOT"
pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
@@ -378,42 +212,47 @@ done
release_info " ✓ Workspace build complete"
release_info ""
release_info "==> Step 5/7: Building publishable CLI bundle..."
release_info "==> Step 4/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
release_info " ✓ CLI bundle ready"
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " --- $pkg_dir ---"
cd "$REPO_ROOT/$pkg_dir"
npm pack --dry-run 2>&1 | tail -3
done <<< "$PUBLIC_PACKAGE_DIRS"
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
else
if [ "$canary" = true ]; then
release_info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish
release_info " Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
release_info "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
release_info "==> Step 5/7: Publishing packages to npm..."
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " Publishing $pkg_name@$pkg_version"
cd "$REPO_ROOT/$pkg_dir"
pnpm publish --no-git-checks --tag "$DIST_TAG" --access public
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " ✓ Published all packages under dist-tag $DIST_TAG"
fi
release_info ""
release_info "==> Post-publish verification: Confirming npm package availability..."
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
else
release_info "==> Step 6/7: Confirming npm package availability..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES=""
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do
[ -z "$pkg_name" ] && continue
release_info " Checking $pkg_name@$pkg_version"
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
@@ -427,49 +266,32 @@ else
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
done <<< "$VERSIONED_PACKAGE_INFO"
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
fi
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
release_info " ✓ Verified all versioned packages are available on npm"
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 7/7: Cleaning up dry-run state..."
release_info " ✓ Dry run leaves the working tree unchanged"
elif [ "$canary" = true ]; then
release_info "==> Step 7/7: Cleaning up canary state..."
release_info " ✓ Canary state will be discarded after publish"
release_info "==> Step 7/7: Dry run complete..."
else
release_info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts
git -C "$REPO_ROOT" add -u .changeset packages server cli
if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then
git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md"
fi
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
release_info "==> Step 7/7: Creating git tag..."
git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
fi
release_info ""
if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi
elif [ "$canary" = true ]; then
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
release_info "Published stable v${TARGET_STABLE_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase"
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
fi

View File

@@ -12,8 +12,8 @@ Usage:
./scripts/rollback-latest.sh <stable-version> [--dry-run]
Examples:
./scripts/rollback-latest.sh 1.2.2
./scripts/rollback-latest.sh 1.2.2 --dry-run
./scripts/rollback-latest.sh 2026.3.17
./scripts/rollback-latest.sh 2026.3.17 --dry-run
Notes:
- This repoints the npm dist-tag "latest" for every public package.
@@ -45,7 +45,7 @@ if [ -z "$version" ]; then
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable semver like 1.2.2." >&2
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
exit 1
fi