From 9aaa8a196ad589d166d9395ba2b78e6cf4d16872 Mon Sep 17 00:00:00 2001 From: Valentino Zegna Date: Fri, 28 Aug 2026 10:11:00 -0700 Subject: [PATCH] ci: simplify PR and release builds --- .github/workflows/ci.yml | 50 ++++++--- .github/workflows/release.yml | 197 +++++++++++----------------------- CHANGELOG.md | 3 +- CLAUDE.md | 94 +++++++--------- CONTRIBUTING.md | 12 +-- PRIVACY.md | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/build-binary.sh | 12 +-- scripts/tag-release.sh | 71 +++++------- 10 files changed, 181 insertions(+), 268 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6754d8..a19e4d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,21 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] - # Required for the merge queue: a queued PR is built on a temporary - # `gh-readonly-queue/...` ref that fires `merge_group`, not `pull_request`. - # Without this the required `build` check never reports and every entry is - # ejected on timeout. - merge_group: - branches: [main] + workflow_call: + inputs: + release_tag: + description: Version tag to stamp into the npm package, such as v1.8.0 + required: false + type: string + default: '' + +# A new push makes an older run for the same PR irrelevant. Cancelling it saves +# minutes while still leaving the newest commit as the required `build` check. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: @@ -27,10 +32,14 @@ jobs: node-version: '22' cache: npm - # `npm ci` installs the committed tree exactly and fails when - # `package-lock.json` and `package.json` disagree, so a dependency bump - # that was never locked stops here instead of resolving differently on - # every run. + # A release tag is the source of the published version. Stamp it only in + # this disposable checkout; main stays at an unmistakable dev version. + - name: Stamp release version + if: inputs.release_tag != '' + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: npm version "${RELEASE_TAG#v}" --no-git-tag-version --allow-same-version --ignore-scripts + - name: Install dependencies run: npm ci @@ -45,3 +54,20 @@ jobs: - name: Build run: npm run build + + # The release job publishes this exact CI-built tarball instead of doing + # another npm install and build on a second runner. + - name: Pack npm release + if: inputs.release_tag != '' + run: | + mkdir npm-package + npm pack --pack-destination npm-package --ignore-scripts + + - name: Save npm release + if: inputs.release_tag != '' + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: npm-package/*.tgz + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e4720a..6c5705c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,88 +4,64 @@ on: push: tags: - 'v*' - # A tag push is a webhook delivery like any other, and when Actions throttles - # webhooks to recover from an incident those deliveries are dropped. The tag - # then exists with no release built from it, and nothing in this repo could - # start the run by hand, so the only way back was deleting and re-pushing a - # public tag. Dispatch this against the tag (not a branch) to run it manually. + # Re-run a dropped tag webhook against the tag without deleting a public tag. workflow_dispatch: -permissions: - contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - build-and-release: + # Releases must pass the same required checks as pull requests. This call also + # builds the npm tarball that the release job publishes unchanged. + ci: + uses: ./.github/workflows/ci.yml + with: + release_tag: ${{ github.ref_name }} + + release: + needs: ci runs-on: macos-latest + permissions: + contents: write + id-token: write steps: - name: Checkout uses: actions/checkout@v7 - # Every version below is derived by stripping `refs/tags/v` off GITHUB_REF. - # Dispatched against a branch that strip is a no-op, so the version would - # silently become the ref itself; fail here instead, before anything is - # built, signed, released or published. - - name: Validate the ref is a version tag - run: | - case "$GITHUB_REF" in - refs/tags/v*) - echo "Releasing from $GITHUB_REF" - ;; - *) - echo "::error::Release must run on a v* tag, got $GITHUB_REF. Re-run this workflow with the tag as the ref." - exit 1 - ;; - esac - - - name: Validate CHANGELOG has release notes + - name: Validate release tag + id: version + env: + RELEASE_TAG: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} run: | - VERSION=${GITHUB_REF#refs/tags/v} - if ! grep -q "## \[$VERSION\]" CHANGELOG.md; then - echo "::error::No release notes found in CHANGELOG.md for version $VERSION" - echo "Add a section like '## [$VERSION] - $(date +%Y-%m-%d)' to CHANGELOG.md" + if [ "$REF_TYPE" != tag ]; then + echo "::error::Release must run against a version tag, got $GITHUB_REF." exit 1 fi - echo "Found release notes for version $VERSION" - # package.json is the single source of the version the binaries carry, so - # a tag that disagrees with it would ship a binary reporting one version - # under a release named another. Catch it here, next to the changelog - # check, before anything is built. - - name: Validate the tag matches package.json - run: | - TAG_VERSION=${GITHUB_REF#refs/tags/v} - PKG_VERSION=$(node -p "require('./package.json').version") - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "::error::Tag v$TAG_VERSION does not match package.json version $PKG_VERSION. Bump package.json (or retag) so the two agree." + VERSION=${RELEASE_TAG#v} + if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::$RELEASE_TAG is not a semantic version tag such as v1.8.0." exit 1 fi - echo "Tag matches package.json version $PKG_VERSION" - - # `bun build --compile` embeds the Bun runtime in the binary, so the Bun - # version is part of what ships. On `latest` the contents of a release - # depended on whichever Bun was current the day the workflow ran, and two - # builds of the same tag months apart differed with no change in this - # repo. The version lives in `.bun-version` so a downstream build can - # match it; bump it in its own PR. + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing $RELEASE_TAG" + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version - # The binaries a release ships are compiled against this tree, so it has - # to be the locked one: `--frozen-lockfile` fails the release rather than - # re-resolving `^` ranges into something the tests never ran against. - - name: Install dependencies + - name: Install locked binary dependencies run: bun install --frozen-lockfile - # One entry point, shared with `npm run compile:*` and with any downstream - # build, so a release binary can be reproduced outside this workflow. The - # script takes its version from package.json, which the tag was validated - # against above. - name: Build all platforms + env: + VERSION: ${{ steps.version.outputs.version }} run: | - chmod +x scripts/build-binary.sh for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do scripts/build-binary.sh "bun-$target" "bin/universal-netlist-$target" done @@ -96,20 +72,15 @@ jobs: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | - # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Import certificate - echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/certificate.p12 - security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" - - # Allow codesign to access the key security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - name: Create macOS universal binary @@ -157,44 +128,42 @@ jobs: cat checksums.txt - name: Build .mcpb desktop extension - run: | - chmod +x scripts/build-mcpb.sh - RELEASE_DIR=$PWD/bin OUTPUT_DIR=$PWD/bin scripts/build-mcpb.sh ${GITHUB_REF#refs/tags/v} + env: + VERSION: ${{ steps.version.outputs.version }} + run: RELEASE_DIR=$PWD/bin OUTPUT_DIR=$PWD/bin scripts/build-mcpb.sh "$VERSION" - - name: Get version from tag - id: version - run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + - name: Download CI-built npm package + uses: actions/download-artifact@v4 + with: + name: npm-package + path: npm-package + + - name: Setup npm trusted publishing + uses: actions/setup-node@v7 + with: + node-version: '22' - - name: Extract release notes from CHANGELOG - id: changelog + - name: Update npm for OIDC + run: npm install -g npm@latest + + # Publishing is idempotent so a retry can finish the GitHub Release after + # npm succeeded without failing because that immutable version exists. + - name: Publish to npm + env: + VERSION: ${{ steps.version.outputs.version }} run: | - VERSION=${GITHUB_REF#refs/tags/v} - # Extract section between current version header and next version header (or EOF) - NOTES=$(awk -v ver="$VERSION" ' - /^## \[/ { if (found) exit; if ($0 ~ "\\[" ver "\\]") found=1; next } - /^\[.*\]:/ { exit } - found { print } - ' CHANGELOG.md) - - # Use delimiter for multiline output - echo "notes<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + PACKAGE_NAME=$(node -p "require('./package.json').name") + if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then + echo "$PACKAGE_NAME@$VERSION is already published; continuing." + else + npm publish npm-package/*.tgz --provenance --access public --ignore-scripts + fi - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: - name: ${{ steps.version.outputs.version }} - body: | - ${{ steps.changelog.outputs.notes }} - - ## Installation - - See [Setup Instructions](https://github.com/IntelligentElectron/universal-netlist?tab=readme-ov-file#connect-the-mcp-with-your-favorite-ai-tool) for installation and configuration. - - ## Checksums - - See `checksums.txt` for SHA256 checksums. + name: ${{ github.ref_name }} + generate_release_notes: true files: | bin/universal-netlist-darwin-universal bin/universal-netlist-darwin-arm64 @@ -205,40 +174,4 @@ jobs: bin/checksums.txt bin/universal-netlist.mcpb draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-npm: - runs-on: ubuntu-latest - needs: build-and-release - permissions: - contents: read - id-token: write # Required for OIDC trusted publishing - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22' - # NOTE: Do NOT add registry-url here - it creates a .npmrc with - # auth token placeholder that interferes with OIDC authentication - cache: npm - - - name: Update npm to latest (OIDC requires npm 11.5.1+) - run: npm install -g npm@latest - - - name: Verify npm version - run: npm --version - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Publish to npm - run: npm publish --provenance --access public --ignore-scripts + prerelease: ${{ contains(steps.version.outputs.version, '-') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a48043f..6b4123f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog -All notable changes to this project will be documented in this file. +This file records releases through v1.7.4. Later changelogs are generated from merged +pull requests and published with each [GitHub Release](https://github.com/IntelligentElectron/universal-netlist/releases). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/CLAUDE.md b/CLAUDE.md index 03719c5..9d70cff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,9 @@ npm run dev ### Dependencies -Both lockfiles are committed. The release build installs from `bun.lock`; CI and the -npm publish job install from `package-lock.json`. When you change a dependency, +Both lockfiles are committed. Binary packaging installs from `bun.lock`; CI installs +from `package-lock.json` and produces the npm tarball that is later published unchanged. +When you change a dependency, regenerate both lockfiles in the same commit: ```bash @@ -27,7 +28,7 @@ bun install # updates bun.lock npm install --package-lock-only # updates package-lock.json ``` -CI installs with `npm ci` and the release workflow with `bun install --frozen-lockfile`. +CI installs with `npm ci` and binary packaging with `bun install --frozen-lockfile`. Both fail if the lockfile does not match `package.json`, so an out-of-date lockfile is caught instead of being silently re-resolved. @@ -63,9 +64,9 @@ Targets: `bun-darwin-arm64`, `bun-darwin-x64`, `bun-linux-arm64`, `bun-linux-x64 `bun-windows-x64`, plus `host` for the machine running the script. The script only compiles. It does not use git, the network, signing, publishing, or -`GITHUB_REF`. The version it bakes in comes from `package.json`, which is the single -source of the version. The release workflow validates the git tag against `package.json`; -it does not derive the version from the tag. +`GITHUB_REF`. Locally, the version it bakes in comes from `package.json`. A release passes +the version tag through `VERSION`, making the tag the single source for every shipped +artifact without committing release-only version changes to `main`. The `VERSION` environment variable overrides that. This is for downstream packagers who want to stamp their own version string without editing a tracked file: @@ -136,56 +137,32 @@ Otherwise `gh` fails TLS verification with `x509: OSStatus -26276`. ### Releasing -`CHANGELOG.md` and the version bump belong in the release PR, never in a feature PR. -Feature PRs contain code and tests only. They must NOT edit `CHANGELOG.md` or -`package.json`. - -Reason: both files append at the top, so if every feature PR bumped them, each -concurrent PR would conflict with the previous one, and a contributor cannot know -whether their change is a patch or a minor. Instead, each PR describes its user-visible -effect in a `## Changelog` section of the PR body. Those sections are collected into one -release PR. - -Cutting the release PR is ordinary work. An agent asked to release, or to carry a change -through to a release, writes the changelog section and bumps the version without asking -first. The version number is the one judgment call to state explicitly: say which bump -you chose and why, so any disagreement surfaces before the tag is pushed. - -1. Cut a release PR from `main` after the fixes for the release have merged: - - Update `CHANGELOG.md` with a new version section, written from the merged PRs' - Changelog sections - - `npm version minor|patch --no-git-tag-version` (writes the version, creates no tag) - - One commit, e.g. `chore: vX.Y.Z changelog` - - That command writes the version to **both `package.json` and `package-lock.json`**, - so the release commit carries three files rather than two. `bun.lock` records the - workspace root's name and no version, so it does not change. - - Run the command instead of editing `package.json` by hand. A hand-edit leaves - `package-lock.json` one version behind, and nothing downstream notices: `npm ci` - compares dependencies and ignores the root version, so CI passes; `npm publish` reads - the version from `package.json`, so the publish is correct; the lockfile is simply - wrong. `tag-release.sh` refuses on the mismatch, and it is the only check that catches - it. -2. Open it as a normal PR and let the merge queue land it -3. After merge, tag the merge commit and push: - - ```bash - git checkout main && git pull - scripts/tag-release.sh - ``` - - The script tags the version in `package.json`. It refuses to run if: you are not on - `main`, the tree is dirty, local `main` is behind `origin/main` (which would tag the - wrong commit), `CHANGELOG.md` has no section for the version, the lockfile records a - different version, or the tag already exists. Pass `--yes` to skip the prompt. - - **Note:** Do NOT use `npm version` without `--no-git-tag-version`. It creates a local git tag that points to the feature branch commit, not the merge commit on main. The tag must be created on the merge commit, which is what the script checks for. - -The tag push triggers the release workflow, which automatically: -- Builds signed binaries for all platforms -- Creates GitHub Release with binaries -- Publishes to npm via OIDC (no tokens) +There is no release PR. Feature PRs contain the code, tests, and documentation users +need. Give each PR a user-facing title and description; GitHub uses the merged PRs to +generate release notes. Do not edit `CHANGELOG.md` or bump the development version. + +After the intended changes have merged, choose the semantic version bump explicitly, +then tag the current `main` commit: + +```bash +git checkout main && git pull +scripts/tag-release.sh 1.8.0 +``` + +The script refuses to run off `main`, with a dirty or stale tree, for a malformed +semantic version, or for an existing tag. Pass `--yes` to skip the prompt. The tag is the +release version; CI stamps it into the npm package and binaries only in the disposable +runner checkout. + +The tag triggers one release workflow, which automatically: + +- Runs the same type check, lint, tests, and TypeScript build required on PRs +- Packs the npm tarball once and publishes that exact CI artifact via OIDC +- Builds, signs, and notarizes the standalone binaries +- Generates release notes from merged PRs and creates the GitHub Release + +`CHANGELOG.md` is the historical changelog through v1.7.4. New changelogs live with the +GitHub Releases generated by the tag workflow. ## Scripts @@ -203,8 +180,9 @@ npm run test:watch # Watch mode ## CI/CD -- **CI** (`ci.yml`): Runs on every push - type-check, lint, test -- **Release** (`release.yml`): Triggered by `v*` tags - builds binaries, signs macOS, publishes npm +- **CI** (`ci.yml`): One required PR run — type-check, lint, test, build +- **Release** (`release.yml`): One `v*` tag run — calls CI, then signs/releases binaries + and publishes the CI-built npm tarball npm publishing uses OIDC trusted publishing (configured on npmjs.com) - no tokens required. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d254bd1..413a9af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,17 +120,15 @@ npm test # Unit tests - Link any related issues - Describe what you changed and why -5. **Do not edit `CHANGELOG.md` or `package.json`:** - Release notes and version bumps are the maintainer's, collected into a separate release - PR. Both files append at the top, so editing them in a feature PR conflicts with every - other open PR. Instead, add a short `## Changelog` section to your PR description saying - what changes for a user — that text is what ends up in the release notes. +5. **Do not edit `CHANGELOG.md` or bump `package.json`:** + A version tag stamps every release artifact and GitHub generates its notes from merged + PRs. Use a clear, user-facing PR title and description so the generated changelog says + what changed and why. 6. **Code Review:** - Respond to feedback - Make requested changes - - A merge queue lands merged PRs, so you do not need to keep your branch up to date - with `main` yourself + - If `main` changes before merge, update your branch and let the required check rerun ## Reporting Issues diff --git a/PRIVACY.md b/PRIVACY.md index f7b3e39..4f42ca7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -102,8 +102,8 @@ The server is a professional engineering tool and is not directed at children. ## Changes -Material changes to this policy will be recorded in `CHANGELOG.md` and in this -file's effective date. The current version always lives at +Material changes to this policy will be recorded in the GitHub Release notes and in +this file's effective date. The current version always lives at . ## Contact diff --git a/package-lock.json b/package-lock.json index 1e0c0c7..8d6d854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@intelligentelectron/universal-netlist", - "version": "1.7.4", + "version": "0.0.0-development", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@intelligentelectron/universal-netlist", - "version": "1.7.4", + "version": "0.0.0-development", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index d65e037..923af9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@intelligentelectron/universal-netlist", - "version": "1.7.4", + "version": "0.0.0-development", "description": "MCP server for netlist parsing and circuit analysis", "type": "module", "main": "dist/index.js", diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index a386b82..dfe987d 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -25,11 +25,9 @@ # VERSION=1.5.2-3 ./scripts/build-binary.sh host bin/universal-netlist packaged # # This compiles and nothing else: no git, no network, no signing, no -# publishing, no reading GITHUB_REF. The version comes from package.json, -# which is the single source of it — the release workflow validates the tag -# against package.json rather than deriving a version from the tag, so a -# build outside a tag push produces the same binary CI would. $VERSION is the -# one way to say otherwise, for a downstream packager stamping its own. +# publishing, no reading GITHUB_REF. A local build defaults to package.json. +# The release workflow and downstream packagers pass $VERSION explicitly; for +# official releases that value comes from the version tag. # # Bun is the whole toolchain this needs. An image holding just the version in # `.bun-version` builds this. @@ -68,8 +66,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # A downstream packager stamping its own string (`1.5.2-3`, a snapshot date, a # commit-derived name) sets $VERSION rather than patching a tracked file. Here # `:-` rather than `-`, unlike the channel above: an unset caller variable falls -# back to a true upstream version, which is the behaviour every existing caller -# already has, where the same slip on the channel would arm self-update. +# back to the package's development version, where the same slip on the channel +# would arm self-update. # # Bun reads package.json because the compile below needs Bun anyway. Reading it # with Node made a build fail on `node: command not found` in an environment diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh index 472dff7..6387627 100755 --- a/scripts/tag-release.sh +++ b/scripts/tag-release.sh @@ -1,19 +1,13 @@ #!/bin/bash # -# Tag a release on the merge commit that carries it, and push the tag. +# Tag the current main commit and start the complete release pipeline. # -# scripts/tag-release.sh # tag the version in package.json -# scripts/tag-release.sh 1.5.1 # tag that version, and check it matches -# scripts/tag-release.sh --yes # skip the confirmation prompt +# scripts/tag-release.sh 1.8.0 +# scripts/tag-release.sh v1.8.0 --yes # -# The tag push is what publishes: it builds the signed binaries, cuts the GitHub -# Release and publishes to npm. Everything before it, the changelog and the -# version bump, goes through a normal release PR and the merge queue. This -# script only does the last step, and only once the checks below all hold. -# -# It refuses rather than repairs. Each check guards a way a release has actually -# gone wrong or could: a tag on a feature-branch commit instead of the merge -# commit, a version nobody wrote a changelog for, a tag that already exists. +# The tag is the release version. The workflow runs CI, stamps that version into +# the npm tarball and binaries, generates release notes from merged pull +# requests, signs the binaries, creates the GitHub Release, and publishes npm. set -euo pipefail @@ -25,58 +19,43 @@ for arg in "$@"; do case "$arg" in --yes|-y) ASSUME_YES=1 ;; -*) echo "unknown option: $arg" >&2; exit 2 ;; - *) VERSION="$arg" ;; + *) + [ -z "$VERSION" ] || { echo "pass one version only" >&2; exit 2; } + VERSION="${arg#v}" + ;; esac done -PACKAGE_VERSION=$(node -p 'require("./package.json").version') -VERSION="${VERSION:-$PACKAGE_VERSION}" -TAG="v${VERSION}" - fail() { echo "✗ $1" >&2; exit 1; } -# The version has to be the one that was merged, or the published package and -# the tag disagree about what this release is. -[ "$VERSION" = "$PACKAGE_VERSION" ] || \ - fail "package.json is at $PACKAGE_VERSION, not $VERSION. Merge the version bump first." +[ -n "$VERSION" ] || fail "usage: scripts/tag-release.sh [--yes]" + +# Accept SemVer release and prerelease tags. GitHub generated notes use the tag +# as their comparison boundary, and npm uses the same value as the package +# version, so reject an ambiguous tag before it becomes public. +if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + fail "v$VERSION is not a semantic version such as v1.8.0 or v1.8.0-rc.1." +fi +TAG="v${VERSION}" -# A release is cut from main. Tagging anywhere else points the release at a -# commit that is not what the merge queue built and npm will publish. BRANCH=$(git rev-parse --abbrev-ref HEAD) [ "$BRANCH" = "main" ] || fail "on branch $BRANCH, not main." - [ -z "$(git status --porcelain)" ] || fail "working tree is dirty. Commit or stash first." -git fetch --quiet origin main +git fetch --quiet origin main --tags LOCAL=$(git rev-parse HEAD) REMOTE=$(git rev-parse origin/main) -[ "$LOCAL" = "$REMOTE" ] || \ - fail "local main is not origin/main. Pull, so the tag lands on the merge commit." - -grep -q "## \[${VERSION}\]" CHANGELOG.md || \ - fail "CHANGELOG.md has no ## [${VERSION}] section. Write the release notes first." - -# `npm version` writes the version to package-lock.json as well, so the two agree -# whenever the bump was made with it. Editing package.json by hand leaves the -# lockfile a version behind, and nothing downstream says so: `npm ci` compares -# dependencies and ignores the root version, so CI passes and the release ships -# with a lockfile describing the version before it. This is the only check on it. -# (bun.lock records the root's name and no version, so there is nothing to check.) -LOCK_VERSION=$(node -p 'require("./package-lock.json").version') -[ "$LOCK_VERSION" = "$VERSION" ] || \ - fail "package-lock.json is at $LOCK_VERSION, not $VERSION. Run 'npm install --package-lock-only' and commit it." +[ "$LOCAL" = "$REMOTE" ] || fail "local main is not origin/main. Pull before tagging." git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null && \ - fail "tag ${TAG} already exists locally. Delete it, or pick another version." -git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1 && \ - fail "tag ${TAG} already exists on origin. This release has already been cut." + fail "tag ${TAG} already exists. This release has already been cut." echo "Releasing ${TAG}" echo " commit $(git log -1 --format='%h %s')" echo " npm $(node -p 'require("./package.json").name')@${VERSION}" echo "" -echo "Pushing the tag builds signed binaries for every platform, creates the" -echo "GitHub Release and publishes to npm. It cannot be taken back cleanly." +echo "The tag runs CI, publishes the CI-built npm package, signs the binaries," +echo "generates release notes, and creates the GitHub Release." echo "" if [ "$ASSUME_YES" -eq 0 ]; then @@ -87,7 +66,7 @@ if [ "$ASSUME_YES" -eq 0 ]; then esac fi -git tag "$TAG" +git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" echo ""