Files
airship/scripts/release.sh
T
Nayan 8f0d2ebe34 fix(ci): cut releases from main, not from release/*
publish.yml ran from release/* so its version-bump commit would reach main
through a PR. That guarded a package.json version field and a lockfile and left
the shipped tree ungated, which is the wrong way round — and it was not
theoretical. On release/cli-readme:

  00:33:19  v0.2.2 published to npm
  00:44:45  v0.2.3 published to npm
  00:48:18  PR #10 opened — first chance to review any of it
  00:48:21  Checks and Branch policy run for the first time
  00:51:33  merged to main

Two versions reached users from a branch with no pull request, no CI run and no
reviewer, seven minutes before the code reached main. `main` is not branch
protected either, so the PR route was never enforced to begin with.

Publishing from main means npm gets the tree that was reviewed, merged and
checked. The bump commit pushed back to main is mechanical and has nothing in
it to review; branch-policy.yml still governs the route for everything else.

Dry runs stay unrestricted — they publish and push nothing, and validating
packaging from a branch before merging it is what they are for.

The tag is now pushed on its own, ahead of the branch. main takes merges that a
release/* branch did not, so the bump commit can lose a race that a tag ref
cannot; rebasing to win it would move the commit off the tree that was actually
published. If the branch push loses, the release is still complete and recorded
by the tag, and the job says exactly which cherry-pick finishes it.
2026-08-12 01:59:54 +05:30

187 lines
6.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Guided release cutter for @airshiplabs/cli.
#
# Bumps apps/cli/package.json, refreshes the lockfile, validates the build and
# the npm packaging (dry-run), then creates the release commit and the
# cli-vX.Y.Z tag — and STOPS. Pushing the tag is deliberately left to you,
# because that push is what triggers .github/workflows/release.yml and
# publishes to npm:
#
# make release # interactive: pick patch / minor / major
# make release BUMP=minor # non-interactive bump
# make release VERSION=1.4.0 # set an explicit version
# make release DRY=1 # validate everything, commit and tag nothing
#
# Env knobs:
# YES=1 skip the final confirmation prompt
# NO_VERIFY=1 skip the build + publish dry-run (faster, less safe)
# ALLOW_DIRTY=1 escape hatch — proceed on a dirty tree (discouraged)
set -euo pipefail
PKG_DIR="apps/cli"
PKG_JSON="$PKG_DIR/package.json"
PKG_NAME="@airshiplabs/cli"
TAG_PREFIX="cli-v"
# Colors only when stdout is a terminal, so piping this into a log stays clean.
if [ -t 1 ]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[0;31m'
GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; CYAN=$'\033[0;36m'; RESET=$'\033[0m'
else
BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; CYAN=''; RESET=''
fi
info() { printf "%s»%s %s\n" "$CYAN" "$RESET" "$1"; }
ok() { printf "%s✓%s %s\n" "$GREEN" "$RESET" "$1"; }
warn() { printf "%s!%s %s\n" "$YELLOW" "$RESET" "$1"; }
die() { printf "%s✖%s %s\n" "$RED" "$RESET" "$1" >&2; exit 1; }
# Read from the terminal explicitly, so prompts still work when stdout is piped.
ask() {
local prompt="$1" reply
exec 3</dev/tty || die "no terminal to prompt on — pass BUMP= or VERSION=, or YES=1"
printf "%s" "$prompt" > /dev/tty
read -r reply <&3
exec 3<&-
printf "%s" "$reply"
}
# ---------------------------------------------------------------- preflight
for tool in git node pnpm; do
command -v "$tool" >/dev/null 2>&1 || die "$tool is not on PATH"
done
cd "$(git rev-parse --show-toplevel)" || die "not inside a git repository"
[ -f "$PKG_JSON" ] || die "$PKG_JSON not found"
# Releases go out from main, because what ships to npm should be what was
# reviewed and merged. Cutting from a branch publishes the branch: v0.2.2 and
# v0.2.3 both reached users from release/cli-readme before that branch had a
# pull request, a Checks run, or a single reviewer.
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$BRANCH" != "main" ]; then
warn "you are on '$BRANCH' — releases go out from main, so this would ship unmerged code"
fi
if [ -z "${ALLOW_DIRTY:-}" ] && [ -n "$(git status --porcelain)" ]; then
git status --short
die "working tree is dirty — commit or stash first (ALLOW_DIRTY=1 overrides)"
fi
info "fetching tags"
git fetch --tags --quiet 2>/dev/null || warn "could not fetch tags (no remote yet?)"
CURRENT="$(node -p "require('./$PKG_JSON').version")"
# ---------------------------------------------------------------- version
if [ -n "${VERSION:-}" ]; then
NEXT="$(node scripts/next-version.mjs --version "$VERSION")"
elif [ -n "${BUMP:-}" ]; then
NEXT="$(node scripts/next-version.mjs --bump "$BUMP")"
else
printf "\n current %s%s%s\n\n" "$BOLD" "$CURRENT" "$RESET"
printf " 1) patch -> %s\n" "$(node scripts/next-version.mjs --bump patch)"
printf " 2) minor -> %s\n" "$(node scripts/next-version.mjs --bump minor)"
printf " 3) major -> %s\n\n" "$(node scripts/next-version.mjs --bump major)"
case "$(ask " Which? [1/2/3] ")" in
1) NEXT="$(node scripts/next-version.mjs --bump patch)" ;;
2) NEXT="$(node scripts/next-version.mjs --bump minor)" ;;
3) NEXT="$(node scripts/next-version.mjs --bump major)" ;;
*) die "aborted" ;;
esac
fi
TAG="${TAG_PREFIX}${NEXT}"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null 2>&1; then
die "tag $TAG already exists"
fi
# ---------------------------------------------------------------- plan
DRY_NOTE=""
[ -n "${DRY:-}" ] && DRY_NOTE=" ${YELLOW}(dry run — nothing will be written)${RESET}"
cat <<PLAN
${BOLD}Release plan${RESET}${DRY_NOTE}
1. bump $PKG_JSON $CURRENT ${DIM}->${RESET} ${BOLD}$NEXT${RESET}
2. refresh the lockfile
3. build + validate npm packaging (dry-run)
4. commit ${DIM}chore(release): cli v$NEXT${RESET}
5. tag ${DIM}$TAG${RESET} (annotated)
Then, when you are ready: ${CYAN}git push --follow-tags${RESET}
${DIM}That push is what publishes — release.yml fires on $TAG_PREFIX* tags.${RESET}
PLAN
if [ -z "${YES:-}" ] && [ -z "${DRY:-}" ]; then
case "$(ask " Proceed? [y/N] ")" in
[yY]) ;;
*) die "aborted" ;;
esac
fi
# ---------------------------------------------------------------- cut it
info "bumping $PKG_JSON to $NEXT"
node -e "
const f='$PKG_JSON';
const p=require('./'+f);
p.version='$NEXT';
require('fs').writeFileSync(f, JSON.stringify(p,null,2)+'\n');
"
pnpm install --lockfile-only >/dev/null
ok "bumped"
if [ -z "${NO_VERIFY:-}" ]; then
info "validating packaging (build + publish --dry-run)"
pnpm --filter "$PKG_NAME" publish --dry-run --no-git-checks --access public >/dev/null
ok "packaging is valid"
else
warn "skipping packaging validation (NO_VERIFY=1)"
fi
if [ -n "${DRY:-}" ]; then
# Put the version back by rewriting it, NOT with `git checkout -- $PKG_JSON`:
# that would discard every other uncommitted change to the file too, which
# under ALLOW_DIRTY=1 is someone else's work.
info "dry run — restoring $PKG_JSON to $CURRENT"
node -e "
const f='$PKG_JSON';
const p=require('./'+f);
p.version='$CURRENT';
require('fs').writeFileSync(f, JSON.stringify(p,null,2)+'\n');
"
pnpm install --lockfile-only >/dev/null
ok "dry run complete: $CURRENT would become $NEXT ($TAG)"
exit 0
fi
info "committing and tagging"
git add "$PKG_JSON" pnpm-lock.yaml
git commit -m "chore(release): cli v$NEXT" >/dev/null
git tag -a "$TAG" -m "$TAG"
ok "committed and tagged $TAG"
# ---------------------------------------------------------------- postflight
if command -v gh >/dev/null 2>&1; then
if ! gh secret list 2>/dev/null | grep -q '^NPM_TOKEN'; then
warn "NPM_TOKEN is not set on the repo — release.yml cannot publish without it"
fi
fi
cat <<DONE
${GREEN}Cut $PKG_NAME v$NEXT.${RESET}
Publish it: ${CYAN}git push --follow-tags${RESET}
Undo it: ${DIM}git tag -d $TAG && git reset --hard HEAD~1${RESET}
DONE