diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b24fcf3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74852bc --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/scripts/next-version.mjs b/scripts/next-version.mjs new file mode 100644 index 0000000..61f39fc --- /dev/null +++ b/scripts/next-version.mjs @@ -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`); diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..151da23 --- /dev/null +++ b/scripts/release.sh @@ -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 + 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 <${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 <