chore: add release train workflow

This commit is contained in:
Dotta
2026-03-09 13:55:30 -05:00
parent d20341c797
commit 469bfe3953
12 changed files with 911 additions and 599 deletions

View File

@@ -15,10 +15,11 @@ set -euo pipefail
# 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"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
dry_run=false
canary=false
@@ -41,6 +42,7 @@ 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.
EOF
}
@@ -73,15 +75,6 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
exit 1
fi
info() {
echo "$@"
}
fail() {
echo "Error: $*" >&2
exit 1
}
restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
@@ -130,28 +123,22 @@ set_cleanup_trap() {
trap cleanup_release_state EXIT
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
fail "working tree is not clean. Commit, stash, or remove changes before releasing."
fi
}
require_npm_publish_auth() {
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
info " ✓ Logged in to npm as $(npm whoami)"
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
}
list_public_package_info() {
@@ -202,66 +189,6 @@ for (const [dir, name] of rows) {
NODE
}
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
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(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
replace_version_string() {
local from_version="$1"
local to_version="$2"
@@ -312,25 +239,55 @@ for (const relFile of extraFiles) {
NODE
}
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0"
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_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_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"
if [ "$canary" = true ]; then
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
fail "next stable version matches the current stable version. Refusing to publish."
release_fail "next stable version matches the current stable version. Refusing to publish."
fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
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
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
@@ -338,33 +295,36 @@ 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
fail "no public packages were found in the workspace."
release_fail "no public packages were found in the workspace."
fi
info ""
info "==> Release plan"
info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
info " Current stable version: $CURRENT_STABLE_VERSION"
release_info ""
release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then
info " Target stable version: $TARGET_STABLE_VERSION"
info " Canary version: $TARGET_PUBLISH_VERSION"
info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
release_info " Target stable version: $TARGET_STABLE_VERSION"
release_info " Canary version: $TARGET_PUBLISH_VERSION"
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else
info " Stable version: $TARGET_STABLE_VERSION"
release_info " Stable version: $TARGET_STABLE_VERSION"
fi
info ""
info "==> Step 1/7: Preflight checks..."
require_clean_worktree
info " ✓ Working tree is clean"
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
info ""
info "==> Step 2/7: Creating release changeset..."
release_info ""
release_info "==> Step 2/7: Creating release changeset..."
{
echo "---"
while IFS= read -r pkg_name; do
@@ -379,10 +339,10 @@ info "==> Step 2/7: Creating release changeset..."
echo "Stable release preparation for $TARGET_STABLE_VERSION"
fi
} > "$TEMP_CHANGESET_FILE"
info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
info ""
info "==> Step 3/7: Versioning packages..."
release_info ""
release_info "==> Step 3/7: Versioning packages..."
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
npx changeset pre enter canary
@@ -398,12 +358,12 @@ fi
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
info ""
info "==> Step 4/7: Building workspace artifacts..."
release_info ""
release_info "==> Step 4/7: Building workspace artifacts..."
cd "$REPO_ROOT"
pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
@@ -411,49 +371,49 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc
rm -rf "$REPO_ROOT/$pkg_dir/skills"
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
done
info " ✓ Workspace build complete"
release_info " ✓ Workspace build complete"
info ""
info "==> Step 5/7: Building publishable CLI bundle..."
release_info ""
release_info "==> Step 5/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
info " ✓ CLI bundle ready"
release_info " ✓ CLI bundle ready"
info ""
release_info ""
if [ "$dry_run" = true ]; then
info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do
[ -z "$pkg_dir" ] && continue
info " --- $pkg_dir ---"
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
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
else
if [ "$canary" = true ]; then
info "==> Step 6/7: Publishing canary to npm..."
release_info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
info "==> Step 6/7: Publishing stable release to npm..."
release_info "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
fi
info ""
release_info ""
if [ "$dry_run" = true ]; then
info "==> Step 7/7: Cleaning up dry-run state..."
info " ✓ Dry run leaves the working tree unchanged"
release_info "==> Step 7/7: Cleaning up dry-run state..."
release_info " ✓ Dry run leaves the working tree unchanged"
elif [ "$canary" = true ]; then
info "==> Step 7/7: Cleaning up canary state..."
info " ✓ Canary state will be discarded after publish"
release_info "==> Step 7/7: Cleaning up canary state..."
release_info " ✓ Canary state will be discarded after publish"
else
info "==> Step 7/7: Finalizing stable release commit..."
release_info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts
git -C "$REPO_ROOT" add -u .changeset packages server cli
@@ -463,23 +423,24 @@ else
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
fi
info ""
release_info ""
if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then
info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else
info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi
elif [ "$canary" = true ]; then
info "Published canary ${TARGET_PUBLISH_VERSION}."
info "Install with: npx paperclipai@canary onboard"
info "Stable version remains: $CURRENT_STABLE_VERSION"
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
else
info "Published stable v${TARGET_STABLE_VERSION}."
info "Next steps:"
info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags"
info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
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"
fi