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:
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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`);
|
||||
Executable
+182
@@ -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
|
||||
Reference in New Issue
Block a user