From f196db4cb8739dd22bb11760930ba3d5552f1a22 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Fri, 2 Oct 2026 00:13:09 +0200 Subject: [PATCH] Automate releases on merge into main A PR into main that bumps the version now releases it: the Release workflow publishes @beplus/be to public npm (trusted publishing, with provenance) and creates the vX.Y.Z tag and GitHub Release from CHANGELOG.md. Any other merge releases nothing. The org ruleset lets nothing push to main without a reviewed PR, so the bump travels inside the PR (bin/bump) and CI only reads it. - bin/bump replaces bin/release: sets the version in package.json, package-lock.json and bin/be, and opens its CHANGELOG section - scripts/release/verify-version.sh holds the rules for both the PR check and the release run; changelog-notes.sh extracts the Release notes - CI runs the bats suite on Ubuntu and macOS, shellcheck, and the version check; test:host now runs the repo's bin/be, not whichever be is on PATH - publishConfig pins registry.npmjs.org, including for the @beplus scope, which otherwise routes a local publish to npm.beplus.cloud - bats-assert/bats-support come from the npm registry instead of git+ssh - dev is retired; docs/releasing.md describes the flow Co-Authored-By: Claude Opus 5.5 --- .github/PULL_REQUEST_TEMPLATE.md | 7 +- .github/workflows/ci.yml | 53 +++++++ .github/workflows/release.yml | 102 ++++++++++++++ CHANGELOG.md | 2 +- bin/bump | 94 +++++++++++++ bin/release | 49 ------- docs/releasing.md | 62 +++++++++ package-lock.json | 26 ++-- package.json | 15 +- scripts/release/changelog-notes.sh | 34 +++++ scripts/release/verify-version.sh | 214 +++++++++++++++++++++++++++++ 11 files changed, 589 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100755 bin/bump delete mode 100755 bin/release create mode 100644 docs/releasing.md create mode 100755 scripts/release/changelog-notes.sh create mode 100755 scripts/release/verify-version.sh diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eecff11..e5843a3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,8 +18,11 @@ How did you solve the problem? Show the fixed output if appropriate. --> -## ChangeLog +## Release + +- [ ] Releases vX.Y.Z: ran `bin/bump …` and described it in CHANGELOG.md +- [ ] No release: `bin/be` is unchanged diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..775d9c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +# Pull requests run it directly; the Release workflow calls it on every push to main, so the +# merge result is tested again before anything is published. + +on: + pull_request: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # macOS is what the maintainers run, with its bash 3.2. + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: npm ci + - run: npm run test:host + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: npm run lint + + version: + name: Version + # Only a PR into main cuts a release, so only there do the release rules apply. + if: github.event_name == 'pull_request' && github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - run: scripts/release/verify-version.sh pr "origin/${BASE_REF}" "${HEAD_REF}" + env: + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e550357 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release + +# A merge into main that bumped the version publishes it to public npm and tags it with a GitHub +# Release; any other merge finds nothing to do. Every step checks what is already done, so re-running +# a failed run is safe. See docs/releasing.md. +# +# npm trusts this file by name (Trusted Publisher: beplus/be, release.yml, environment npm), so +# there is no npm token anywhere: the publish job authenticates with its OIDC token. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + ci: + name: CI + uses: ./.github/workflows/ci.yml + + plan: + name: Plan + needs: ci + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.verify.outputs.version }} + tag: ${{ steps.verify.outputs.tag }} + dist_tag: ${{ steps.verify.outputs.dist_tag }} + need_publish: ${{ steps.verify.outputs.need_publish }} + need_release: ${{ steps.verify.outputs.need_release }} + steps: + - uses: actions/checkout@v7 + - id: verify + run: scripts/release/verify-version.sh release + + publish: + name: Publish ${{ needs.plan.outputs.tag }} + needs: plan + if: needs.plan.outputs.need_publish == 'true' || needs.plan.outputs.need_release == 'true' + runs-on: ubuntu-latest + environment: npm + permissions: + contents: write # tag + GitHub Release + id-token: write # npm trusted publishing and provenance + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Make sure npm can publish with OIDC (>= 11.5.1) + run: | + if ! node -e ' + const [a, b, c] = process.argv[1].split(".").map(Number); + process.exit(a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))) ? 0 : 1); + ' "$(npm --version)"; then + npm install --global npm@11 + fi + npm --version + + - name: Publish to npm + if: needs.plan.outputs.need_publish == 'true' + run: npm publish --provenance --tag "${DIST_TAG}" + env: + DIST_TAG: ${{ needs.plan.outputs.dist_tag }} + + - name: Tag and create the GitHub Release + if: needs.plan.outputs.need_release == 'true' + run: | + scripts/release/changelog-notes.sh "${TAG}" > "${RUNNER_TEMP}/notes.md" + if [[ "${DIST_TAG}" == "latest" ]]; then + channel=(--latest) + else + channel=(--prerelease --latest=false) + fi + gh release create "${TAG}" --target "${GITHUB_SHA}" --title "${TAG}" \ + --notes-file "${RUNNER_TEMP}/notes.md" "${channel[@]}" + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.plan.outputs.tag }} + DIST_TAG: ${{ needs.plan.outputs.dist_tag }} + + - name: Summary + run: | + { + echo "### Released ${TAG}" + echo + echo "- npm: https://www.npmjs.com/package/@beplus/be/v/${VERSION}" + echo "- GitHub: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" + } >> "${GITHUB_STEP_SUMMARY}" + env: + TAG: ${{ needs.plan.outputs.tag }} + VERSION: ${{ needs.plan.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecdbaa..272a564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -https://github.com/beplus/be/compare/main...dev +https://github.com/beplus/be/compare/v0.7.0...main ## [v0.7.0] (07/03/2026) diff --git a/bin/bump b/bin/bump new file mode 100755 index 0000000..f76022e --- /dev/null +++ b/bin/bump @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# Prepare a release on the current branch: +# +# bin/bump patch|minor|major|X.Y.Z +# +# Writes the new version into package.json, package-lock.json and bin/be, and opens its section in +# CHANGELOG.md, moving any notes written under [Unreleased] into it. Commits, tags and publishes +# nothing: put the result in a PR into main, and merging that PR releases it (docs/releasing.md). +# +# On a _versions/vX.Y.Z branch the argument defaults to X.Y.Z, and any other version is refused. +# Re-running it for the version already set only re-syncs the files. + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +branch="$(git branch --show-current)" +branch_version="" +if [[ "${branch}" =~ ^_versions/v(.+)$ ]]; then + branch_version="${BASH_REMATCH[1]}" +fi + +bump="${1:-${branch_version}}" +if [[ -z "${bump}" ]]; then + echo "usage: bin/bump patch|minor|major|X.Y.Z" >&2 + exit 2 +fi +bump="${bump#v}" + +# npm computes the version; keep a copy so that a refused version leaves nothing behind. +backup="$(mktemp -d)" +trap 'rm -rf "${backup}"' EXIT +cp package.json package-lock.json "${backup}/" + +npm version "${bump}" --no-git-tag-version --allow-same-version > /dev/null +version="$(node -p 'require("./package.json").version')" + +if [[ -n "${branch_version}" && "${version}" != "${branch_version}" ]]; then + cp "${backup}/package.json" "${backup}/package-lock.json" . + echo "Error: branch ${branch} cuts v${branch_version}, not v${version}." >&2 + exit 1 +fi + +sed -i.bak "s/^VERSION=\".*\"\$/VERSION=\"v${version}\"/" bin/be +rm -f bin/be.bak + +# +# CHANGELOG.md: point [Unreleased] at what main has on top of this version, and open the version's +# section with whatever [Unreleased] collected (kept as is when the section already exists). +# + +has_section=0 +if awk -v heading="## [v${version}]" 'index($0, heading) == 1 { found = 1 } END { exit !found }' CHANGELOG.md; then + has_section=1 +fi + +awk \ + -v tag="v${version}" \ + -v date="$(date +%m/%d/%Y)" \ + -v link="$(node -p 'require("./package.json").homepage')/compare/v${version}...main" \ + -v has_section="${has_section}" ' + function flush( i, first, last) { + print "" + print link + print "" + first = 1 + while (first <= n && notes[first] !~ /[^[:space:]]/) first++ + last = n + while (last >= first && notes[last] !~ /[^[:space:]]/) last-- + if (!has_section) { + print "## [" tag "] (" date ")" + print "" + } + for (i = first; i <= last; i++) print notes[i] + if (last >= first) print "" + in_unreleased = 0 + } + /^## \[Unreleased\]/ { print; in_unreleased = 1; next } + in_unreleased && /^## \[/ { flush(); print; next } + in_unreleased { + if ($0 !~ /^https?:\/\/[^ ]*\/compare\//) notes[++n] = $0 + next + } + { print } + END { if (in_unreleased) flush() } +' CHANGELOG.md > "${backup}/CHANGELOG.md" +cat "${backup}/CHANGELOG.md" > CHANGELOG.md + +echo "Bumped to v${version}: package.json, package-lock.json, bin/be, CHANGELOG.md." +if ! scripts/release/changelog-notes.sh "v${version}" > /dev/null 2>&1; then + echo "Next: describe the release under \"## [v${version}]\" in CHANGELOG.md." +fi +echo "Then commit and open a PR into main; merging it publishes v${version}." diff --git a/bin/release b/bin/release deleted file mode 100755 index 6eccf74..0000000 --- a/bin/release +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash - -# Run this from the develop branch after setting the version number in bin/be, -# when ready to release apart from copying up to master and tagging. - -{ # force scan of whole file into memory so not affected by changing branches and self-modifying. - -# -# confirm -# - -function confirm { - read -p "$1 " -r - if [[ ! "${REPLY}" =~ ^[Yy]$ ]]; then - echo "Stopping" - exit 2 - fi -} - - -readonly BE_VERSION="$(be --version)" -if [[ "${BE_VERSION}" =~ ^[0-9.]+-[0-9]+$ ]]; then - echo "Error: internal version number still prerelease, set it to desired version first." - exit 2 -fi - -confirm "Are you releasing version '${BE_VERSION}' ?" -confirm "Have you updated CHANGELOG?" -confirm "Are you running this from a shell with full internet access (not through proxy to run tests)?" - -git checkout main -git merge dev -npm version "${BE_VERSION}" - -read -p "One Time Password for npm publish: " -r - -set -x -npm publish . --access public --otp "${REPLY}" -git push --follow-tags -git checkout dev -git merge main -npm version -no-git-tag-version prepatch -set +x - -echo "Reminder: update the internal number to match." -echo "Reminder: add release description to github (from CHANGELOG)" - -exit -} diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..5d8094f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,62 @@ +# Releasing + +A release is cut by merging a pull request into `main` that bumps the version. Nobody publishes +by hand. The [Release workflow](../.github/workflows/release.yml) publishes `@beplus/be` to the +**public npm registry** and creates the `vX.Y.Z` tag and GitHub Release. + +## The flow + +``` +feature/… ──▶ (optional) _versions/vX.Y.Z ──▶ PR into main ──▶ npm + tag + GitHub Release +``` + +1. On your branch, run `bin/bump patch|minor|major` (or `bin/bump X.Y.Z`). It writes the version + into `package.json`, `package-lock.json` and `bin/be`. It also opens the version's section in + `CHANGELOG.md` and moves any notes under `[Unreleased]` into it. It commits, tags and publishes + nothing. +2. Describe the release under its `## [vX.Y.Z]` heading in `CHANGELOG.md`. That text becomes the + GitHub Release notes. +3. Open a PR into `main`. CI runs the tests and lint, and checks the version. +4. Once the PR is approved and merged, the Release workflow publishes the version. + +A PR that doesn't touch `bin/be` (docs, tests, CI) can merge without a bump, and then releases +nothing. A PR that changes `bin/be` must bump. `scripts/install.sh` installs straight from `main`, +so whatever lands there must be a released version. + +To batch several PRs into one release, collect them on a `_versions/vX.Y.Z` branch first. Bump +on that branch, where `bin/bump` defaults to `X.Y.Z`, then open one PR from it into `main`. CI +checks that the branch name and the version agree. + +`dev` is retired. Nothing targets it any more. + +## What CI checks + +[`scripts/release/verify-version.sh`](../scripts/release/verify-version.sh) holds the rules. Both +the PR check and the release run use it. + +- `bin/be`, `package.json` and `package-lock.json` carry the same version. +- A bump is newer than `main`'s version, isn't tagged or on npm yet, and has a non-empty + `CHANGELOG.md` section. +- A change to `bin/be` comes with a bump. +- A `_versions/vX.Y.Z` branch carries exactly `vX.Y.Z`. + +## When a release run fails + +Re-run it from the Actions tab. Each step skips what's already done (on npm, tagged), so a re-run +picks up where the failed one stopped. If a run was cancelled while it waited behind another +release, re-run that run too, because it holds its own commit. + +## One-time setup + +- **npm.** On npmjs.com, go to `@beplus/be` → Settings → Trusted Publisher → GitHub Actions and + enter organization `beplus`, repository `be`, workflow `release.yml`, environment `npm`. No npm + token exists anywhere: the workflow authenticates with OIDC, and npm attaches provenance to every + version. Under Publishing access, choose "Require two-factor authentication and disallow tokens". +- **GitHub.** The `npm` environment is created on the first run. Limit its deployment branches to + `main`. + +`package.json` pins both `publishConfig.registry` and `publishConfig["@beplus:registry"]` to +`https://registry.npmjs.org/`. A beplus machine maps the `@beplus` scope to `npm.beplus.cloud`, and +that mapping beats a plain `registry` pin, so the scoped key is what keeps `@beplus/be` from being +published there. The same mapping also overrides `npm view --registry`. Check public npm with +`curl -s https://registry.npmjs.org/@beplus%2fbe`. diff --git a/package-lock.json b/package-lock.json index a6a756d..3036bb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,8 @@ }, "devDependencies": { "bats": "1.8.2", - "bats-assert": "github:bats-core/bats-assert", - "bats-support": "github:bats-core/bats-support" + "bats-assert": "2.2.4", + "bats-support": "0.3.0" }, "engines": { "node": "*" @@ -33,9 +33,9 @@ } }, "node_modules/bats-assert": { - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/bats-core/bats-assert.git#0a8dd57e2cc6d4cc064b1ed6b4e79b9f7fee096f", - "integrity": "sha512-cnCwMOelY9vFLjZu7/btGB+7Oy/+s6o4cimsxOg/ti1xdwu+i3//0IQs2Ln/qJ4xZwx7y2VSg8JtINVkd/Wqiw==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/bats-assert/-/bats-assert-2.2.4.tgz", + "integrity": "sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==", "dev": true, "license": "CC0-1.0", "peerDependencies": { @@ -45,8 +45,8 @@ }, "node_modules/bats-support": { "version": "0.3.0", - "resolved": "git+ssh://git@github.com/bats-core/bats-support.git#d140a65044b2d6810381935ae7f0c94c7023c8c3", - "integrity": "sha512-qYPuuKfC/qr6xopVY3IjyZNKN1u8/9f75TTR1RyxOAOMIDo2VHcIsv2C/tRmG5KWQ6qBFtwoH2R2WTeZ5mzS9Q==", + "resolved": "https://registry.npmjs.org/bats-support/-/bats-support-0.3.0.tgz", + "integrity": "sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==", "dev": true, "license": "CC0-1.0", "peerDependencies": { @@ -62,17 +62,17 @@ "dev": true }, "bats-assert": { - "version": "git+ssh://git@github.com/bats-core/bats-assert.git#0a8dd57e2cc6d4cc064b1ed6b4e79b9f7fee096f", - "integrity": "sha512-cnCwMOelY9vFLjZu7/btGB+7Oy/+s6o4cimsxOg/ti1xdwu+i3//0IQs2Ln/qJ4xZwx7y2VSg8JtINVkd/Wqiw==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/bats-assert/-/bats-assert-2.2.4.tgz", + "integrity": "sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==", "dev": true, - "from": "bats-assert@github:bats-core/bats-assert", "requires": {} }, "bats-support": { - "version": "git+ssh://git@github.com/bats-core/bats-support.git#d140a65044b2d6810381935ae7f0c94c7023c8c3", - "integrity": "sha512-qYPuuKfC/qr6xopVY3IjyZNKN1u8/9f75TTR1RyxOAOMIDo2VHcIsv2C/tRmG5KWQ6qBFtwoH2R2WTeZ5mzS9Q==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/bats-support/-/bats-support-0.3.0.tgz", + "integrity": "sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==", "dev": true, - "from": "bats-support@github:bats-core/bats-support", "requires": {} } } diff --git a/package.json b/package.json index a238dab..c423f24 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,22 @@ ], "repository": { "type": "git", - "url": "git://github.com/beplus/be.git" + "url": "git+https://github.com/beplus/be.git" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "@beplus:registry": "https://registry.npmjs.org/", + "access": "public" }, "scripts": { - "test": "test/bin/run-all-tests" + "test": "test/bin/run-all-tests", + "test:host": "PATH=\"$PWD/bin:$PATH\" bats test/tests", + "lint": "shellcheck -S warning bin/be bin/bump scripts/install.sh scripts/release/*.sh" }, "devDependencies": { "bats": "1.8.2", - "bats-assert": "github:bats-core/bats-assert", - "bats-support": "github:bats-core/bats-support" + "bats-assert": "2.2.4", + "bats-support": "0.3.0" }, "preferGlobal": true, "os": [ diff --git a/scripts/release/changelog-notes.sh b/scripts/release/changelog-notes.sh new file mode 100755 index 0000000..263d0c8 --- /dev/null +++ b/scripts/release/changelog-notes.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Print the body of one version's section of CHANGELOG.md — the GitHub Release notes. +# +# scripts/release/changelog-notes.sh v0.8.0 +# +# The section runs from its "## [vX.Y.Z]" heading to the next "## [" heading. Fails when the +# section is missing or has no text. + +set -euo pipefail + +readonly TAG="${1:?usage: changelog-notes.sh vX.Y.Z}" +readonly CHANGELOG="${CHANGELOG:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/CHANGELOG.md}" + +# Leading and trailing blank lines are dropped; blank lines inside the section are kept. +notes="$(awk -v heading="## [${TAG}]" ' + index($0, heading) == 1 { found = 1; next } + found && /^## \[/ { exit } + found && /[^[:space:]]/ { + for (i = 0; i < blanks; i++) print "" + blanks = 0 + started = 1 + print + next + } + found && started { blanks++ } +' "${CHANGELOG}")" + +if [[ -z "${notes}" ]]; then + echo "Error: CHANGELOG.md has no notes under \"## [${TAG}]\"." >&2 + exit 1 +fi + +printf '%s\n' "${notes}" diff --git a/scripts/release/verify-version.sh b/scripts/release/verify-version.sh new file mode 100755 index 0000000..b60f594 --- /dev/null +++ b/scripts/release/verify-version.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# +# The release rules, in one place: the pull-request check and the release workflow both run this. +# +# scripts/release/verify-version.sh pr [] +# scripts/release/verify-version.sh release +# +# Both modes first check that bin/be, package.json and package-lock.json carry the same version. +# +# pr A PR into main either bumps the version or leaves bin/be alone, because whatever lands +# on main is what scripts/install.sh installs. A bump must be newer than the base, not yet +# tagged or on npm, and described in CHANGELOG.md. A _versions/vX.Y.Z branch must arrive +# with exactly vX.Y.Z. +# +# release Decides what a push to main still has to do, and writes it as key=value lines to +# $GITHUB_OUTPUT (stdout when unset): version, tag, dist_tag, need_publish (not on npm +# yet) and need_release (not tagged yet). Re-checks CHANGELOG.md when either is true. + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +readonly NPM_REGISTRY="https://registry.npmjs.org" +g_errors=0 + +# +# error — report a problem and keep checking, so one run lists all of them +# + +function error() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::error::$*" >&2 + else + echo "Error: $*" >&2 + fi + g_errors=$((g_errors + 1)) +} + +function notice() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::notice::$*" + else + echo "$*" + fi +} + +function finish() { + if [[ "${g_errors}" -gt 0 ]]; then + exit 1 + fi +} + +# +# json_field — read a value out of a JSON file, "-" for stdin +# + +function json_field() { + node -e ' + const text = require("fs").readFileSync(process.argv[1] === "-" ? 0 : process.argv[1], "utf8"); + const json = JSON.parse(text); + process.stdout.write(String(new Function("json", "return " + process.argv[2])(json) ?? "")); + ' "$1" "$2" +} + +# +# semver_compare — print -1, 0 or 1 by SemVer precedence; fails on an invalid version +# + +function semver_compare() { + # shellcheck disable=SC2016 # ${v} is a JavaScript template literal, not a shell expansion + node -e ' + const parse = (v) => { + const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(v); + if (!m) { console.error(`Error: "${v}" is not a SemVer version.`); process.exit(2); } + return { core: m.slice(1, 4).map(Number), pre: m[4] ? m[4].split(".") : [] }; + }; + const cmpId = (x, y) => { + const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y); + if (nx && ny) return Math.sign(Number(x) - Number(y)); + if (nx !== ny) return nx ? -1 : 1; + return x < y ? -1 : x > y ? 1 : 0; + }; + const [a, b] = process.argv.slice(1).map(parse); + let r = 0; + for (let i = 0; i < 3 && r === 0; i++) r = Math.sign(a.core[i] - b.core[i]); + if (r === 0 && (a.pre.length === 0) !== (b.pre.length === 0)) r = a.pre.length === 0 ? 1 : -1; + for (let i = 0; r === 0 && i < Math.max(a.pre.length, b.pre.length); i++) { + if (i >= a.pre.length) r = -1; + else if (i >= b.pre.length) r = 1; + else r = cmpId(a.pre[i], b.pre[i]); + } + console.log(r); + ' "$1" "$2" +} + +# +# remote_tag_commit — print the commit a tag on origin points at, nothing when there is none +# + +function remote_tag_commit() { + # An annotated tag is listed twice; its peeled "^{}" line, which sorts last, is the commit. + git ls-remote --tags origin "refs/tags/$1" "refs/tags/$1^{}" | tail -n 1 | cut -f 1 +} + +# +# npm_has_version — whether public npm already has that version +# + +function npm_has_version() { + local status + status="$(curl -sS -o /dev/null -w '%{http_code}' "${NPM_REGISTRY}/${1/\//%2f}/$2")" + case "${status}" in + 200) return 0 ;; + 404) return 1 ;; + *) echo "Error: ${NPM_REGISTRY} answered HTTP ${status} for $1@$2." >&2; exit 2 ;; + esac +} + +function check_changelog() { + scripts/release/changelog-notes.sh "$1" > /dev/null 2>&1 \ + || error "CHANGELOG.md needs a \"## [$1] (MM/DD/YYYY)\" section describing the release (bin/bump opens it)." +} + +# +# Versions must agree everywhere they are written down +# + +NAME="$(json_field package.json 'json.name')" +VERSION="$(json_field package.json 'json.version')" +TAG="v${VERSION}" +readonly NAME VERSION TAG + +semver_compare "${VERSION}" "${VERSION}" > /dev/null || exit 1 + +be_version="$(sed -n 's/^VERSION="\(.*\)"$/\1/p' bin/be)" +[[ "${be_version}" == "${TAG}" ]] \ + || error "bin/be says VERSION=\"${be_version}\" but package.json says ${VERSION}. Run bin/bump ${VERSION}." + +lock_version="$(json_field package-lock.json 'json.version')" +lock_root_version="$(json_field package-lock.json 'json.packages[""].version')" +[[ "${lock_version}" == "${VERSION}" && "${lock_root_version}" == "${VERSION}" ]] \ + || error "package-lock.json says ${lock_version}/${lock_root_version} but package.json says ${VERSION}. Run bin/bump ${VERSION}." + +function verify_pr() { + local base_ref="${1:?usage: verify-version.sh pr []}" + local head_branch="${2:-${HEAD_BRANCH:-$(git branch --show-current)}}" + local base_version + base_version="$(git show "${base_ref}:package.json" | json_field - 'json.version')" + + if [[ "${head_branch}" =~ ^_versions/v(.+)$ && "${BASH_REMATCH[1]}" != "${VERSION}" ]]; then + error "Branch ${head_branch} must cut v${BASH_REMATCH[1]}, but the version is ${VERSION}. Run bin/bump ${BASH_REMATCH[1]}." + fi + + if [[ "${VERSION}" == "${base_version}" ]]; then + if ! git diff --quiet "${base_ref}...HEAD" -- bin/be; then + error "bin/be changed but the version is still ${VERSION}. Run bin/bump patch|minor|major and describe the release in CHANGELOG.md." + fi + finish + notice "No version bump: merging this PR releases nothing." + return + fi + + if [[ "$(semver_compare "${VERSION}" "${base_version}")" != "1" ]]; then + error "The version must move forward: ${VERSION} is not newer than ${base_version} on ${base_ref}." + fi + if [[ -n "$(remote_tag_commit "${TAG}")" ]]; then + error "Tag ${TAG} already exists. Bump to a version that has not been released." + fi + if npm_has_version "${NAME}" "${VERSION}"; then + error "${NAME}@${VERSION} is already on npm. Bump to a version that has not been released." + fi + check_changelog "${TAG}" + finish + notice "Merging this PR releases ${NAME}@${VERSION} (${base_version} → ${VERSION})." +} + +function verify_release() { + finish + + local need_publish=false need_release=false tag_commit dist_tag=latest + npm_has_version "${NAME}" "${VERSION}" || need_publish=true + tag_commit="$(remote_tag_commit "${TAG}")" + [[ -n "${tag_commit}" ]] || need_release=true + [[ "${VERSION}" != *-* ]] || dist_tag=next + + # Publishing must not put a commit's code under a tag that names a different commit. + if [[ "${need_publish}" == "true" && -n "${tag_commit}" && "${tag_commit}" != "$(git rev-parse HEAD)" ]]; then + error "${NAME}@${VERSION} is not on npm, but tag ${TAG} points at ${tag_commit}, not HEAD. Publish from that commit or bump the version." + fi + if [[ "${need_publish}" == "true" || "${need_release}" == "true" ]]; then + check_changelog "${TAG}" + fi + finish + + if [[ "${need_publish}" == "true" || "${need_release}" == "true" ]]; then + notice "Releasing ${NAME}@${VERSION} (publish to npm: ${need_publish}, tag and GitHub Release: ${need_release})." + else + notice "Nothing to release: ${NAME}@${VERSION} is already on npm and tagged ${TAG}." + fi + + { + echo "version=${VERSION}" + echo "tag=${TAG}" + echo "dist_tag=${dist_tag}" + echo "need_publish=${need_publish}" + echo "need_release=${need_release}" + } >> "${GITHUB_OUTPUT:-/dev/stdout}" +} + +case "${1:-}" in + pr) shift; verify_pr "$@" ;; + release) verify_release ;; + *) echo "usage: verify-version.sh pr [] | release" >&2; exit 2 ;; +esac