ci: cut and publish releases

Two lanes to the same place. `make release` bumps, validates packaging, commits and
tags locally, and `git push --follow-tags` fires release.yml; `make release:ci`
does the whole thing in publish.yml.

publish.yml pushes its tag with GITHUB_TOKEN, which by design does not trigger
other workflows — so release.yml stays dormant for CI-cut releases instead of
double-publishing. Both need an NPM_TOKEN secret.
This commit is contained in:
Nayan
2026-08-09 22:58:00 +05:30
parent 14214fff55
commit ff566d91c7
4 changed files with 433 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
name: Publish (CI)
run-name: "Publish · ${{ inputs.version || inputs.bump }}${{ inputs.dry_run && ' (dry-run)' || '' }}"
# All-in-CI release for @airshiplabs/cli: pick a bump (or an explicit version)
# and this bumps apps/cli/package.json, commits, tags cli-vX.Y.Z, pushes, and
# publishes — the CI equivalent of `make release` plus pushing the tag, with no
# local steps.
#
# Run it from your release/* branch: the version-bump commit lands there and
# reaches main through the normal release PR. branch-policy.yml is what makes
# that the only route in.
#
# Note: the cli-v* tag is pushed with GITHUB_TOKEN, which by design does NOT
# trigger release.yml — so this workflow publishes here, and the two lanes can
# never double-publish.
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump (ignored if a version is given)"
type: choice
options: [patch, minor, major]
default: patch
version:
description: "Explicit version, e.g. 1.4.0 (overrides bump)"
type: string
default: ""
dry_run:
description: "Dry run — validate packaging only; no commit, tag, push or publish"
type: boolean
default: false
permissions:
contents: write # push the release commit + tag
id-token: write # npm provenance attestation
concurrency:
group: publish
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
# Not the setup-workspace composite: this job needs registry-url, which
# is what writes the .npmrc that NODE_AUTH_TOKEN binds to.
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: pnpm install --frozen-lockfile
- name: Compute the next version
id: ver
env:
BUMP: ${{ inputs.bump }}
VERSION: ${{ inputs.version }}
run: |
if [ -n "$VERSION" ]; then
NEXT=$(node scripts/next-version.mjs --version "$VERSION")
else
NEXT=$(node scripts/next-version.mjs --bump "$BUMP")
fi
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
echo "tag=cli-v$NEXT" >> "$GITHUB_OUTPUT"
echo "::notice::Releasing @airshiplabs/cli v$NEXT (tag cli-v$NEXT)"
- name: Guard against an existing tag
run: |
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.tag }}" >/dev/null; then
echo "::error::Tag ${{ steps.ver.outputs.tag }} already exists."
exit 1
fi
- name: Bump apps/cli/package.json
run: |
node -e "const f='apps/cli/package.json';const p=require('./'+f);p.version='${{ steps.ver.outputs.next }}';require('fs').writeFileSync(f, JSON.stringify(p,null,2)+'\n');"
pnpm install --lockfile-only
# Builds the CLI (tsup + the vendor step) and validates the tarball
# without publishing it. Catches a missing vendored asset here rather
# than in someone's npx.
- name: Validate packaging (build + pack dry-run)
run: pnpm --filter @airshiplabs/cli publish --dry-run --no-git-checks --access public
- name: Commit, tag and push
if: ${{ inputs.dry_run == false }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
# --no-verify skips the husky commit-msg and pre-push hooks. The
# pre-push hook blocks pushes to main, and commit-msg would reject
# nothing here, but both are pointless in CI.
git commit --no-verify -m "chore(release): cli v${{ steps.ver.outputs.next }}"
git tag -a "${{ steps.ver.outputs.tag }}" -m "${{ steps.ver.outputs.tag }}"
git push --no-verify origin "HEAD:${{ github.ref_name }}" --follow-tags
- name: Publish to npm
if: ${{ inputs.dry_run == false }}
run: pnpm --filter @airshiplabs/cli publish --access public --no-git-checks --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
name: Release
run-name: "Release · ${{ github.ref_name }}"
# Publishes @airshiplabs/cli to npm when a cli-v* tag is pushed. Cut the release
# with `make release` (bumps apps/cli/package.json, commits, tags) then push the
# tag — see scripts/release.sh.
#
# This lane is for locally-cut releases only. publish.yml pushes its tag with
# GITHUB_TOKEN, and GitHub does not fire workflows from GITHUB_TOKEN-authored
# pushes, so a CI-cut release never lands here and the two never both publish.
on:
push:
tags: ["cli-v*"]
permissions:
contents: read
id-token: write # npm provenance attestation
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: pnpm install --frozen-lockfile
- name: Verify the tag matches apps/cli's version
run: |
TAG="${GITHUB_REF_NAME#cli-v}"
PKG=$(node -p "require('./apps/cli/package.json').version")
if [ "$TAG" != "$PKG" ]; then
echo "::error::Tag cli-v$TAG does not match @airshiplabs/cli version $PKG (cut releases with 'make release')"
exit 1
fi
# `pnpm publish` runs the package's build first, which is tsup plus
# scripts/vendor-assets.mjs — the step that puts the overlay bundles and
# the editor fonts inside the tarball. Without it the published CLI 404s
# on its own overlay.
- name: Publish to npm
run: pnpm --filter @airshiplabs/cli publish --access public --no-git-checks --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+87
View File
@@ -0,0 +1,87 @@
// The one place airship's next version is computed.
//
// Both release lanes call this — scripts/release.sh locally and
// .github/workflows/publish.yml in CI — so the two can never disagree about
// what "patch" means. Prints the version to stdout and nothing else, so it
// composes into `NEXT=$(node scripts/next-version.mjs --bump patch)`.
//
// node scripts/next-version.mjs --bump patch|minor|major [--current x.y.z]
// node scripts/next-version.mjs --version 1.4.0
//
// --current is for CI, which may want to bump from something other than what
// is on disk. Omitted, it reads apps/cli/package.json.
import { readFileSync } from "node:fs";
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const BUMPS = new Set(["patch", "minor", "major"]);
const PKG = new URL("../apps/cli/package.json", import.meta.url);
function die(message) {
process.stderr.write(`next-version: ${message}\n`);
process.exit(1);
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i];
if (!key.startsWith("--")) {
die(`unexpected argument "${key}"`);
}
const value = argv[i + 1];
if (value === undefined) {
die(`${key} needs a value`);
}
args[key.slice(2)] = value;
}
return args;
}
function currentVersion(explicit) {
if (explicit) {
return explicit;
}
try {
return JSON.parse(readFileSync(PKG, "utf8")).version;
} catch (error) {
die(`could not read apps/cli/package.json — ${error.message}`);
}
}
function bump(version, kind) {
const [major, minor, patch] = version.split(".").map(Number);
if (kind === "major") {
return `${major + 1}.0.0`;
}
if (kind === "minor") {
return `${major}.${minor + 1}.0`;
}
return `${major}.${minor}.${patch + 1}`;
}
const args = parseArgs(process.argv.slice(2));
// An explicit version wins outright — it is the escape hatch for anything the
// three bump kinds cannot express (a first release, a prerelease, a correction).
if (args.version) {
if (!SEMVER.test(args.version)) {
die(`"${args.version}" is not a plain x.y.z version`);
}
process.stdout.write(`${args.version}\n`);
process.exit(0);
}
if (!args.bump) {
die("pass either --bump patch|minor|major or --version x.y.z");
}
if (!BUMPS.has(args.bump)) {
die(`unknown bump "${args.bump}" — expected patch, minor or major`);
}
const current = currentVersion(args.current);
if (!SEMVER.test(current)) {
die(`current version "${current}" is not a plain x.y.z version`);
}
process.stdout.write(`${bump(current, args.bump)}\n`);
+182
View File
@@ -0,0 +1,182 @@
#!/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"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$BRANCH" = "main" ]; then
warn "you are on main — releases normally go out from a release/* branch"
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