diff --git a/.github/scripts/next-version-test.sh b/.github/scripts/next-version-test.sh new file mode 100755 index 0000000..92820cc --- /dev/null +++ b/.github/scripts/next-version-test.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Drives next-version.sh against repositories whose tags are planted by hand. +# Run under the flags GitHub Actions uses for a `run:` block, because that is +# where a swallowed failure hides. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$here/next-version.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +failed=0 +outputs="" +status=0 +stderr="" + +resolve() { # ... + local name="$1" bump="$2" + shift 2 + local repo="$work/$name" + rm -rf "$repo" + mkdir -p "$repo" + git -C "$repo" init -q + git -C "$repo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base + local tag + for tag in "$@"; do git -C "$repo" tag "$tag"; done + outputs="$work/$name.out" + stderr="$work/$name.err" + : > "$outputs" + status=0 + (cd "$repo" && BUMP="$bump" GITHUB_OUTPUT="$outputs" \ + bash -eo pipefail "$script") >/dev/null 2>"$stderr" || status=$? +} + +fail() { + echo "FAIL: $*" >&2 + failed=1 +} + +expect_version() { # + local want="version=$1" + grep -qxF -- "$want" "$outputs" || fail "$2: resolved $(tr '\n' ' ' <"$outputs"), want $want" + grep -qxF -- "tag=v$1" "$outputs" || fail "$2: tag does not match the version it resolved" + [ "$status" = 0 ] || fail "$2: exit $status, want 0" +} + + +# How far back GoReleaser reaches for the notes. Empty leaves it on its own +# default, which is the release immediately before this one. +expect_previous_tag() { # + grep -qxF -- "previous_tag=$1" "$outputs" \ + || fail "$2: $(grep '^previous_tag=' "$outputs" || echo 'no previous_tag'), want previous_tag=$1" +} + +expect_refused() { # + [ "$status" != 0 ] || fail "$1: exit 0, want a refusal" + grep -q -- "$2" "$stderr" || fail "$1: refused with '$(cat "$stderr")', want it to mention '$2'" + [ ! -s "$outputs" ] || fail "$1: refused but still wrote an output" +} + +# A repository with nothing released yet starts the line at 0.0.1 rather than +# reissuing 0.0.0, and has no earlier release to write notes against. +resolve first patch +expect_version 0.0.1 first +expect_previous_tag "" first + +# The rc tags this repository carries are candidates for 0.0.1, so the first +# stable release is 0.0.1 and not 0.0.2. +resolve rcs patch v0.0.1-rc1 v0.0.1-rc4 +expect_version 0.0.1 rcs + +resolve patch patch v1.2.3 +expect_version 1.2.4 patch +# A patch already follows the release before it, so GoReleaser is left alone. +expect_previous_tag "" patch + +resolve minor minor v1.2.3 +expect_version 1.3.0 minor + +resolve major major v1.2.3 +expect_version 2.0.0 major + +# Lexically 0.9.0 sorts above 0.10.0, so a version-blind sort would count the +# next patch off the wrong release and hand back 0.9.1. +resolve ordering patch v0.9.0 v0.10.0 +expect_version 0.10.1 ordering + +# A tag that is not a release is not a base to count from. +resolve noise patch v1.2.3 nightly v2.0.0-rc1 vfoo +expect_version 1.2.4 noise + +# A bump counts off the highest release, so releasing twice in a row advances +# twice rather than landing on the tag the first one just cut. +resolve consecutive patch v1.2.3 v1.2.4 +expect_version 1.2.5 consecutive + +resolve bad-bump sideways v1.2.3 +expect_refused bad-bump "is not a bump" + +# --- how far back a milestone's notes reach ---------------------------------- +# The whole point of consolidating: 0.2.0's notes have to cover every patch +# since 0.1.0, not just the merge that happened to be last before it. +resolve minor-notes minor v0.1.0 v0.1.1 v0.1.2 +expect_version 0.2.0 minor-notes +expect_previous_tag v0.1.0 minor-notes + +# The last release at this level, not the first one ever seen at it. +resolve minor-notes-latest minor v0.1.0 v0.2.0 v0.2.1 +expect_version 0.3.0 minor-notes-latest +expect_previous_tag v0.2.0 minor-notes-latest + +# A major counts as a milestone for a minor's notes: 1.0.0 is where the patches +# being consolidated started. +resolve minor-notes-major minor v0.9.0 v1.0.0 v1.0.1 +expect_version 1.1.0 minor-notes-major +expect_previous_tag v1.0.0 minor-notes-major + +# The same version-aware ordering the base needs. +resolve minor-notes-ordering minor v0.9.0 v0.10.0 v0.10.1 +expect_version 0.11.0 minor-notes-ordering +expect_previous_tag v0.10.0 minor-notes-ordering + +# A major reaches back to the last major, not to the last minor. +resolve major-notes major v1.0.0 v1.1.0 v1.1.3 +expect_version 2.0.0 major-notes +expect_previous_tag v1.0.0 major-notes + +# The first milestone of its kind has nothing at its own level to reach back to, +# so it reaches back to the first release there has ever been. +resolve minor-notes-firstever minor v0.0.1 v0.0.2 v0.0.3 +expect_version 0.1.0 minor-notes-firstever +expect_previous_tag v0.0.1 minor-notes-firstever + +resolve major-notes-firstever major v0.1.0 v0.2.0 v0.2.1 +expect_version 1.0.0 major-notes-firstever +expect_previous_tag v0.1.0 major-notes-firstever + +if [ "$failed" = 0 ]; then + echo "next-version-test: ok" +else + echo "next-version-test: failures above" >&2 + exit 1 +fi diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh new file mode 100755 index 0000000..6c302b5 --- /dev/null +++ b/.github/scripts/next-version.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Resolves the version a release is cutting. The tags this repo carries are the +# record of what has been released, so the version is counted off them and +# nothing in the tree holds it: no commit has to land on master to advance a +# version, and a release cannot disagree with a package.json someone edited. +# +# BUMP is major, minor or patch. Writes `version`, `tag` and `previous_tag` to +# $GITHUB_OUTPUT when it is set. `previous_tag` is how far back the release +# notes should reach. +set -euo pipefail + +bump="${BUMP:-patch}" + +# Only a stable tag counts as a release. v0.0.1-rc4 is a candidate for 0.0.1, so +# counting a patch off it would skip the very version it was a candidate for. +# `sort -V` puts 0.10.0 above 0.9.0, which a lexical sort does not, and +# `sed -n p` reports no matches as an empty line rather than as the failure +# `grep` would return under pipefail. +releases() { # + git tag -l 'v*' | sed -n "$1" | sort -V +} + +stable='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' +base="$(releases "$stable" | tail -1)" +base="${base:-0.0.0}" +IFS=. read -r major minor patch <<<"$base" + +case "$bump" in + major) + version="$((major + 1)).0.0" + level='s/^v\([0-9][0-9]*\.0\.0\)$/\1/p' + ;; + minor) + version="$major.$((minor + 1)).0" + level='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.0\)$/\1/p' + ;; + patch) + version="$major.$minor.$((patch + 1))" + level="" + ;; + *) + echo "next-version: '$bump' is not a bump; use major, minor or patch" >&2 + exit 1 + ;; +esac + +# A patch follows the release before it, which is what GoReleaser assumes on its +# own, so it is left alone to assume it. A milestone consolidates every patch +# since the last release at its own level, and its notes have to reach back that +# far or they describe the one merge that happened to be last. With nothing at +# that level yet, they reach back to the first release there has ever been. +previous_tag="" +if [ -n "$level" ]; then + previous="$(releases "$level" | tail -1)" + previous="${previous:-$(releases "$stable" | head -1)}" + if [ -n "$previous" ]; then previous_tag="v$previous"; fi +fi + +tag="v$version" + +echo "next-version: releasing $version, a $bump off $base" +if [ -n "$previous_tag" ]; then + echo "next-version: the notes reach back to $previous_tag" +fi + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "version=$version" + echo "tag=$tag" + echo "previous_tag=$previous_tag" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.github/scripts/workflow-refs.sh b/.github/scripts/workflow-refs.sh index 546d223..5fc1d87 100755 --- a/.github/scripts/workflow-refs.sh +++ b/.github/scripts/workflow-refs.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Checks that everything the workflow names actually exists: composite actions, -# make targets, and the scripts a run: block invokes. Then checks that no run: -# block interpolates a `${{ }}`. +# reusable workflows, make targets, and the scripts a run: block invokes. Then +# checks that no run: block interpolates a `${{ }}`. # # This is the class actionlint does not cover. `uses: ./.github/actions/typo` # lints clean and fails only when the job runs, and the folio jobs and the @@ -38,8 +38,8 @@ def rel(path): return os.path.relpath(path, root) -# --- composite actions ------------------------------------------------------- -print("local action references:") +# --- composite actions and reusable workflows -------------------------------- +print("local action and reusable workflow references:") local_refs = 0 for path in workflow_files(): # Comments are not references. They mention paths as examples, and a version @@ -48,14 +48,19 @@ for path in workflow_files(): found = re.findall(r"^\s*-?\s*uses:\s*(\./\S+)\s*$", body, re.M) local_refs += len(found) for ref in found: - target = os.path.join(root, ref[2:], "action.yml") + # A reusable workflow is named by its own file. A composite action is + # named by the directory holding it, and the file inside is action.yml. + target = os.path.join(root, ref[2:]) + if not target.endswith((".yml", ".yaml")): + target = os.path.join(target, "action.yml") report(os.path.isfile(target), ref, " (from %s)" % rel(path)) # A checker that silently matches nothing reports a safety it never looked # for. If the file names a local action in a form the pattern above does not # read, that is a broken checker, not a clean file. - mentions = len(re.findall(r"\./\.github/actions/", body)) + mentions = len(re.findall(r"\./\.github/(?:actions|workflows)/", body)) if mentions > len(found): - sys.exit("workflow-refs: %s mentions ./.github/actions/ %d time(s) but this " + sys.exit("workflow-refs: %s mentions ./.github/actions/ or ./.github/workflows/ " + "%d time(s) but this " "check only parsed %d `uses:` reference(s) out of it, so it is not " "reading the file it claims to read" % (rel(path), mentions, len(found))) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3ab1e..e66949c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,21 @@ on: pull_request: push: branches: [master] - tags: ["v*"] + # `none` is an ordinary ci run. minor and major consolidate every patch + # released since the last milestone into one, and run the whole suite first: + # a release that skipped the device legs would be the only release nobody + # checked. The default is what stops a dispatch meant to re-run the tests + # from cutting a release by accident. workflow_dispatch: + inputs: + promote: + description: Consolidate the released patches into a milestone + type: choice + options: + - none + - minor + - major + default: none permissions: contents: read @@ -434,45 +447,72 @@ jobs: path: runs/ retention-days: 14 - # On master this publishes @sanderling/spec, and only when - # pkg/spec/package.json carries a version npm does not have yet. On a tag it - # publishes the version the tag names. + # Every merge to master cuts a patch: 0.1.4 becomes 0.1.5. A dispatch with + # `promote` set cuts the milestone that consolidates them instead. Both wait on + # the device legs as well as the checks, so nothing reaches a registry that the + # emulators and the simulator have not agreed on, and both release the commit + # this run tested rather than whatever master drifted to while it ran. + # + # These jobs live here rather than in a workflow of their own because npm + # matches a package's one trusted publisher against the filename of the + # workflow that starts the run. See docs/development/ci.md. + release-tag: + name: Tag + needs: + - checks + - folio + - replay-ui + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/master') || + (github.event_name == 'workflow_dispatch' && inputs.promote != 'none') + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.next.outputs.version }} + tag: ${{ steps.next.outputs.tag }} + previous_tag: ${{ steps.next.outputs.previous_tag }} + steps: + - uses: actions/checkout@v7 + with: + # The version is counted off the tags, so the tags have to be here. + fetch-depth: 0 + + # `inputs` is empty on a push, which leaves the resolver on its default of + # a patch: that is the bump a merge cuts. + - name: Resolve the version + id: next + run: .github/scripts/next-version.sh + env: + BUMP: ${{ inputs.promote }} + + # Nothing is published until this lands, so a version that cannot be + # tagged never reaches a registry. npm is the half of a release that + # cannot be taken back and a tag is the half that can. + - name: Tag the commit + run: | + git -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + tag -a "$TAG" -m "$TAG" + git push origin "refs/tags/$TAG" + env: + TAG: ${{ steps.next.outputs.tag }} + release-npm: name: Release (npm) - needs: checks - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + needs: release-tag runs-on: ubuntu-latest permissions: contents: read + # npm authenticates this publish over OIDC against the trusted publisher + # configured for @sanderling/spec, so the job holds no token and there is + # none to expire. npm revoked every classic token in December 2025 and + # caps a granular one at 90 days, so a token here would break quarterly. + id-token: write steps: - # A refname is attacker-controlled text and git permits backtick, `$`, - # `(`, `;`, `&` and `|` in it, so it goes through env: a `${{ }}` is - # substituted before bash ever sees the line. Every step below reads these - # outputs rather than the refname, and nothing reaches a shell before it - # has matched the pattern. The pattern is anchored and admits no newline, - # which is what stops the value below forging a second $GITHUB_OUTPUT key. - # Release (cli) validates the same way, from its own copy: the two jobs - # hold different permissions and neither should wait on the other. - - name: Validate the tag - id: tag - if: startsWith(github.ref, 'refs/tags/v') - run: | - pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' - if [[ ! "$TAG" =~ $pattern ]]; then - echo "release: refusing to publish from '$TAG'" >&2 - echo "release: a release tag is vMAJOR.MINOR.PATCH with an optional -prerelease, e.g. v0.1.0 or v0.0.1-rc1" >&2 - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - env: - TAG: ${{ github.ref_name }} - - uses: actions/checkout@v7 with: - # Empty on master, where the commit that triggered the run is the one - # to publish and master may have moved on since. - ref: ${{ steps.tag.outputs.tag || github.sha }} + ref: ${{ needs.release-tag.outputs.tag }} # `npm ci` below runs dependency lifecycle scripts, and no step in # this job needs the git credential afterwards. persist-credentials: false @@ -485,92 +525,60 @@ jobs: cache: npm cache-dependency-path: pkg/spec/package-lock.json + # registry-url above writes an `_authToken=${NODE_AUTH_TOKEN}` line into + # .npmrc whether or not a token exists, and an npm older than 11.5.1 reads + # that empty line as "auth is configured" and never asks for an OIDC + # token, so the publish fails needing auth. Node 22 ships npm 10. + - name: Install an npm that can publish over OIDC + run: npm install -g npm@latest + - name: Install dependencies working-directory: pkg/spec run: npm ci - - name: Stamp version - if: startsWith(github.ref, 'refs/tags/v') + # The repo keeps package.json at 0.0.0-dev. The tags are the record of + # what has been released, and a version committed to master would be a + # second record to hold in step with them. + - name: Stamp the version working-directory: pkg/spec run: npm version "$VERSION" --no-git-tag-version --allow-same-version env: - VERSION: ${{ steps.tag.outputs.version }} + VERSION: ${{ needs.release-tag.outputs.version }} - # npm refuses a version it already has, so most merges to master have - # nothing to publish and must not be red for it. The registry is asked - # rather than the diff of package.json: that answer is still right after a - # revert, after a merge that publishes nothing, and after a publish that - # failed halfway. Only stdout decides, because `npm view` on a version - # that does not exist is empty on some npm releases and an error on - # others, and an unreachable registry must end in a publish that fails - # loudly rather than a skip that looks like success. + # A publish that landed and then failed on its way out leaves npm holding + # the version, and re-running the job must not be red for it. The registry + # is asked rather than the tags: only npm knows what npm has. Only stdout + # decides, because `npm view` on a version that does not exist is empty on + # some npm releases and an error on others, and an unreachable registry + # must end in a publish that fails loudly rather than a skip that reads as + # success. - name: Ask npm whether this version is already published - id: version - working-directory: pkg/spec + id: published run: | - version="$(node -p 'require("./package.json").version')" - # Held to the pattern the tag is held to above, and for the same - # reason: a value with a newline in it would forge a second key. - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then - echo "release: pkg/spec/package.json carries '$version', which is not a version this publishes" >&2 - exit 1 - fi - published="$(npm view "@sanderling/spec@$version" version 2>/dev/null || true)" - if [ -n "$published" ]; then - echo "npm already has @sanderling/spec@$version, nothing to publish" + if [ -n "$(npm view "@sanderling/spec@$VERSION" version 2>/dev/null || true)" ]; then + echo "npm already has @sanderling/spec@$VERSION, nothing to publish" echo "publish=false" >> "$GITHUB_OUTPUT" else - echo "publishing @sanderling/spec@$version" echo "publish=true" >> "$GITHUB_OUTPUT" fi - echo "version=$version" >> "$GITHUB_OUTPUT" + env: + VERSION: ${{ needs.release-tag.outputs.version }} - name: Publish @sanderling/spec to npm - if: steps.version.outputs.publish == 'true' + if: steps.published.outputs.publish == 'true' working-directory: pkg/spec - # npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec - # keeps resolving the latest stable. - run: | - if [[ "$VERSION" == *-* ]]; then - npm publish --access public --tag next - else - npm publish --access public - fi - # The publish credential is scoped to the one step that publishes rather - # than to the job, so no other step runs with it in reach. - env: - VERSION: ${{ steps.version.outputs.version }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public - # Tags only: there is no CLI to cut on a merge. This is the job that holds - # contents: write, and it holds no publish credential of its own. release-cli: name: Release (cli) - needs: checks - if: startsWith(github.ref, 'refs/tags/v') + needs: release-tag runs-on: ubuntu-latest permissions: contents: write steps: - # The same validation Release (npm) runs, on the same pattern, for the - # same reason. Both copies must stay identical. - - name: Validate the tag - id: tag - run: | - pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' - if [[ ! "$TAG" =~ $pattern ]]; then - echo "release: refusing to publish from '$TAG'" >&2 - echo "release: a release tag is vMAJOR.MINOR.PATCH with an optional -prerelease, e.g. v0.1.0 or v0.0.1-rc1" >&2 - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - env: - TAG: ${{ github.ref_name }} - - uses: actions/checkout@v7 with: - ref: ${{ steps.tag.outputs.tag }} + ref: ${{ needs.release-tag.outputs.tag }} # GoReleaser reads the tag history for its changelog. fetch-depth: 0 @@ -602,6 +610,12 @@ jobs: - name: Build sidecar JAR run: make sidecar + # GoReleaser reaches back to the release before this one on its own, which + # is right for a patch and wrong for a milestone: the notes on a 0.2.0 + # consolidating six patches would cover the last merge only. + # GORELEASER_PREVIOUS_TAG moves that boundary back to the last release at + # this one's level, and an empty value leaves GoReleaser on its own + # default, which is what a patch passes. - name: Publish the sanderling CLI to GitHub Releases uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -609,6 +623,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_PREVIOUS_TAG: ${{ needs.release-tag.outputs.previous_tag }} release: name: Release @@ -629,7 +644,7 @@ jobs: docs: name: Docs needs: checks - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index 6735d52..60badb2 100644 --- a/Makefile +++ b/Makefile @@ -158,6 +158,7 @@ test-folio: test-ci-scripts: .github/scripts/replay-ui-summary-test.sh .github/scripts/folio-run-test.sh + .github/scripts/next-version-test.sh test-spec-api: cd pkg/spec && npm test --silent diff --git a/docs/development/ci.md b/docs/development/ci.md index 17ec926..e723a4e 100644 --- a/docs/development/ci.md +++ b/docs/development/ci.md @@ -4,22 +4,18 @@ title: CI # CI -`ci.yml` runs on every pull request: it builds, unit-tests, and drives three -small web fixtures through headless Chrome (`test/browser/testdata`). It never -runs sanderling against a real app. +`ci.yml` runs on every pull request and every push to master. The `Check` +jobs build, unit-test, and drive three small web fixtures through headless +Chrome (`test/browser/testdata`). The `Folio` and `Replay UI` jobs in the same +workflow do run sanderling against real apps, on emulators and simulators, and +they are what make a run take the better part of an hour. -Two other workflows do, and both are `workflow_dispatch` only. They boot devices, -build apps and take minutes, which is not what you want on every push, and -neither is a merge gate. +`All checks passed` is the one status check to point branch protection at, and +it is what gates a release: `release.yml` cuts one only after a whole ci run +went green. See [Releases](#releases) at the bottom. ## folio -Actions -> folio -> Run workflow. Inputs pick the legs (`all`, `android`, `ios`, -`web`), and override the seed, the step budget and the wall-clock budget. Seed -and step budget take `0` to mean "use the calibrated value in the workflow"; the -wall-clock budget has no such sentinel and is passed through as written, so -every leg gets whatever you type there. - Each leg builds `examples/folio` for its platform, builds the CLI with only the tags that platform needs (`make sanderling-android` and friends), and runs `examples/folio/sanderling/spec.ts` through `.github/scripts/folio-run.sh`. That @@ -181,7 +177,7 @@ run's automation session bound to a bundle the simulator no longer knows. ## replay-ui -Actions -> replay-ui -> Run workflow. This one is dogfooding: it records a trace +This one is dogfooding: it records a trace from `test/browser/testdata/throwing` (violations and uncaught exceptions, so every panel has something to render), serves it with `sanderling replay`, and fuzzes that UI with `replay-ui/sanderling/spec.ts`. @@ -253,3 +249,91 @@ because the window the counting invariant had to judge it in was 117 steps wide. Sweeping selects for a seed that reaches the bug. It cannot select for one whose walk also closes the window, so when a property needs a window, check what the window looked like and not only that the conviction happened. + + +## Releases + +**Every merge to master cuts a patch.** The `Tag`, `Release (npm)` and +`Release (cli)` jobs sit in `ci.yml` alongside everything else, waiting on +`Checks`, `Folio` and `Replay UI`, so nothing reaches a registry that the +emulators and the simulator have not agreed on. `0.1.4` becomes `0.1.5`: +published to npm, and to GitHub Releases with the CLI binaries. + +**A milestone consolidates them.** Actions -> ci -> Run workflow, set `promote` +to `minor` or `major`, and the patches you have been shipping become `0.2.0`. +Leaving `promote` on `none` is an ordinary ci run that publishes nothing, which +is what stops a dispatch meant to re-run the tests from cutting a release. + +A promotion runs the whole suite, device legs included. It is the same pipeline +either way, and a release that skipped the checks would be the only release +nobody checked. Both paths release the commit the run tested rather than +whatever master drifted to while it ran. Afterwards the patch line continues +from the milestone: the next merge counts off `0.2.0` and cuts `0.2.1`. + +**The tags are the version.** Nothing in the tree holds it: +`pkg/spec/package.json` stays at `0.0.0-dev` and CI stamps the real version in +before it publishes. So there is no version-bump commit to land on master, +nothing to conflict on, and no second record to hold in step with the tags. +`.github/scripts/next-version.sh` is the whole rule, and it counts off stable +tags only, because `v0.0.1-rc4` is a candidate for `0.0.1` and a patch counted +off it would skip the version it was a candidate for. Run it anywhere to see +what the next release would be: + +``` +BUMP=minor .github/scripts/next-version.sh +``` + +The tag is pushed before anything is published, because npm is the half of a +release that cannot be taken back and a tag is the half that can. + +### How far back the notes reach + +GoReleaser builds its changelog from the commits between the previous tag and +this one, and works that previous tag out on its own. For a patch that is +exactly right. For a milestone it is not: the notes on a `0.2.0` consolidating +six patches would describe the one merge that happened to be last. + +So the resolver also emits `previous_tag`, which the release passes as +`GORELEASER_PREVIOUS_TAG`: the last release at the level being cut. A minor +reaches back to the last `vX.Y.0`, counting a major as one, and a major reaches +back to the last `vX.0.0`. The first milestone of its kind has nothing at its own +level, so it reaches back to the first release there has ever been. A patch emits +nothing, and an empty value leaves GoReleaser on the default that was already +right for it. + +The boundary is exclusive, the way a changelog always is: the notes cover what +landed *after* that tag. So the one release this shortchanges is the first +milestone of its kind, whose notes start after the first release rather than at +it. That is one merge, once, and it is not worth a special case. + +### Why the release is not its own workflow + +It reads like it should be. The reason it is not is npm. + +npm publishes over OIDC here, against a trusted publisher configured for +`@sanderling/spec`, so CI holds no npm credential at all. That is not a +preference: npm disabled classic token creation in November 2025, revoked every +classic token on 9 December 2025, and caps a granular token at 90 days. A token +in CI would now expire quarterly, which is exactly the failure this replaced. +The August 2026 outage was an expired granular token, and npm answers a publish +it will not authorise with `404`, so it read as "package does not exist" while +`@sanderling/spec` sat in the registry the whole time. + +A package carries exactly one trusted publisher, and npm matches it against the +filename of the workflow that *starts* the run. A reusable workflow does not +help, because npm sees the caller's name, not the callee's. So every publish has +to enter through one file, and since a merge's release has to run inside ci, that +file is `ci.yml`. + +Setting it up again, or moving the package, means npmjs.com -> the package -> +trusted publisher: repository `priyanshujain/sanderling`, workflow `ci.yml`. Or +from a shell, which needs an interactive 2FA challenge: + +``` +npm trust github @sanderling/spec --file ci.yml --repo priyanshujain/sanderling --allow-publish +npm trust list @sanderling/spec +``` + +The job installs npm 11.5.1 or newer before publishing, because +`actions/setup-node` writes an empty `_authToken` line into `.npmrc` and an older +npm reads that as "auth is configured" and never asks for an OIDC token.