From f82b4596090db23100aaee96e0deb18af76d6490 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 15:30:17 +0530 Subject: [PATCH] refactor(ci): one workflow publishes, because npm allows one trusted publisher npm revoked every classic token in December 2025 and caps a granular one at 90 days, so a token in CI would expire quarterly. OIDC is the only option left, and it matches a package's single trusted publisher against the filename of the workflow that starts the run. So the release lives in ci.yml and nowhere else: release.yml and release-publish.yml are gone, along with the released_tag the promotion used to re-cut an older commit. Actions -> ci -> Run workflow, promote=minor|major cuts a milestone, and it runs the whole suite first like a merge does. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- .github/scripts/next-version-test.sh | 17 +- .github/scripts/next-version.sh | 15 +- .github/workflows/ci.yml | 213 ++++++++++++++++++++++++-- .github/workflows/release-publish.yml | 200 ------------------------ .github/workflows/release.yml | 40 ----- 5 files changed, 204 insertions(+), 281 deletions(-) delete mode 100644 .github/workflows/release-publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/next-version-test.sh b/.github/scripts/next-version-test.sh index 8ba4851..92820cc 100755 --- a/.github/scripts/next-version-test.sh +++ b/.github/scripts/next-version-test.sh @@ -44,12 +44,6 @@ expect_version() { # [ "$status" = 0 ] || fail "$2: exit $status, want 0" } -# The manual pipeline re-cuts the commit this tag points at, so a wrong answer -# here releases the wrong code under the right version. -expect_released_tag() { # - grep -qxF -- "released_tag=$1" "$outputs" \ - || fail "$2: $(grep '^released_tag=' "$outputs" || echo 'no released_tag'), want released_tag=$1" -} # How far back GoReleaser reaches for the notes. Empty leaves it on its own # default, which is the release immediately before this one. @@ -65,28 +59,23 @@ expect_refused() { # } # A repository with nothing released yet starts the line at 0.0.1 rather than -# reissuing 0.0.0, and has no release to promote or to write notes against. +# reissuing 0.0.0, and has no earlier release to write notes against. resolve first patch expect_version 0.0.1 first -expect_released_tag "" 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, and a candidate is not a release to -# promote. +# 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 -expect_released_tag "" rcs resolve patch patch v1.2.3 expect_version 1.2.4 patch -expect_released_tag v1.2.3 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 -expect_released_tag v1.2.3 minor resolve major major v1.2.3 expect_version 2.0.0 major @@ -95,12 +84,10 @@ expect_version 2.0.0 major # 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 -expect_released_tag v0.10.0 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 -expect_released_tag v1.2.3 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. diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh index 21cf015..6c302b5 100755 --- a/.github/scripts/next-version.sh +++ b/.github/scripts/next-version.sh @@ -4,10 +4,9 @@ # 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`, `released_tag` and -# `previous_tag` to $GITHUB_OUTPUT when it is set. `released_tag` is the release -# this one follows, and is the commit a promotion re-cuts. `previous_tag` is how -# far back the release notes should reach. +# 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}" @@ -22,11 +21,8 @@ releases() { # } stable='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' -released="$(releases "$stable" | tail -1)" -released_tag="" -if [ -n "$released" ]; then released_tag="v$released"; fi - -base="${released:-0.0.0}" +base="$(releases "$stable" | tail -1)" +base="${base:-0.0.0}" IFS=. read -r major minor patch <<<"$base" case "$bump" in @@ -71,7 +67,6 @@ if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "version=$version" echo "tag=$tag" - echo "released_tag=$released_tag" echo "previous_tag=$previous_tag" } >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd6d22..e66949c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,21 @@ on: pull_request: push: branches: [master] + # `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 @@ -433,28 +447,195 @@ jobs: path: runs/ retention-days: 14 - # Every merge to master cuts a patch release: 0.1.4 becomes 0.1.5, published - # to npm and to GitHub Releases. It waits on the device legs as well as the - # checks, so nothing reaches a registry that the emulators and the simulator - # have not agreed on. `release.yml` promotes a run of these to a minor or a - # major by hand, and shares the steps below rather than holding a second copy. - release: - name: Release + # 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.ref == 'refs/heads/master' && github.event_name == 'push' - uses: ./.github/workflows/release-publish.yml + 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 - with: - bump: patch - # The commit that triggered the run is the one to release: master may have - # moved on in the hour the device legs take. - sha: ${{ github.sha }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + 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: 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: + - uses: actions/checkout@v7 + with: + 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 + + - name: Set up Node 22 + uses: actions/setup-node@v7 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + 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 + + # 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: ${{ needs.release-tag.outputs.version }} + + # 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: published + run: | + 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 "publish=true" >> "$GITHUB_OUTPUT" + fi + env: + VERSION: ${{ needs.release-tag.outputs.version }} + + - name: Publish @sanderling/spec to npm + if: steps.published.outputs.publish == 'true' + working-directory: pkg/spec + run: npm publish --access public + + release-cli: + name: Release (cli) + needs: release-tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release-tag.outputs.tag }} + # GoReleaser reads the tag history for its changelog. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Cache Gradle + uses: actions/cache@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - 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: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_PREVIOUS_TAG: ${{ needs.release-tag.outputs.previous_tag }} + + release: + name: Release + if: always() + needs: + - release-npm + - release-cli + runs-on: ubuntu-latest + steps: + - name: Check the group passed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 # The docs used to build only when docs/ or the Makefile changed. A path # filter here would have to sit on the whole workflow, so the site is rebuilt diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml deleted file mode 100644 index 18525c6..0000000 --- a/.github/workflows/release-publish.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: release-publish - -# What both release pipelines do once they know which commit to cut and what to -# call it. ci.yml calls this on every green master run to advance the patch; -# release.yml calls it by hand to promote the last release to a minor or major. -# Neither holds a copy of these steps, because two copies of a publish drift and -# the drift only shows up on a release. -on: - workflow_call: - inputs: - bump: - description: Which part of MAJOR.MINOR.PATCH to advance - type: string - default: patch - sha: - description: >- - The commit to release. Empty means the commit the last release was cut - from, which is what promoting a run of patches to a milestone does. - type: string - default: "" - secrets: - NPM_TOKEN: - required: true - -permissions: - contents: read - -jobs: - # Nothing is published until the tag is pushed, 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. - tag: - name: Tag - runs-on: ubuntu-latest - # The two pipelines count their version off the same tags, so they must not - # resolve one at the same time. This sits on the job rather than on either - # caller because a caller's group covers only its own runs, and ci's release - # runs under ci's group. Queued rather than cancelled: a promotion landing - # first simply means the next merge counts its patch off the milestone. - concurrency: - group: release-tag - cancel-in-progress: false - 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 - - - name: Resolve the version - id: next - run: .github/scripts/next-version.sh - env: - BUMP: ${{ inputs.bump }} - - # A promotion re-cuts the commit that is already released, so it needs no - # ci run of its own: that commit is only tagged because ci went green on - # it. A repository with nothing released yet has nothing to promote, and - # saying so beats tagging whatever master happens to be. - - name: Tag the commit - run: | - target="$SHA" - if [ -z "$target" ]; then - if [ -z "$RELEASED_TAG" ]; then - echo "release: nothing has been released yet, so there is no release to promote" >&2 - exit 1 - fi - target="$RELEASED_TAG^{commit}" - echo "release: promoting $RELEASED_TAG to $TAG" - fi - git -c user.name='github-actions[bot]' \ - -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ - tag -a "$TAG" "$target" -m "$TAG" - git push origin "refs/tags/$TAG" - env: - SHA: ${{ inputs.sha }} - TAG: ${{ steps.next.outputs.tag }} - RELEASED_TAG: ${{ steps.next.outputs.released_tag }} - - npm: - name: Release (npm) - needs: tag - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.tag.outputs.tag }} - # `npm ci` below runs dependency lifecycle scripts, and no step in - # this job needs the git credential afterwards. - persist-credentials: false - - - name: Set up Node 22 - uses: actions/setup-node@v7 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - cache: npm - cache-dependency-path: pkg/spec/package-lock.json - - - name: Install dependencies - working-directory: pkg/spec - run: npm ci - - # 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: ${{ needs.tag.outputs.version }} - - # 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: published - run: | - 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 "publish=true" >> "$GITHUB_OUTPUT" - fi - env: - VERSION: ${{ needs.tag.outputs.version }} - - - name: Publish @sanderling/spec to npm - if: steps.published.outputs.publish == 'true' - working-directory: pkg/spec - run: npm publish --access public - # 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: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - cli: - name: Release (cli) - needs: tag - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.tag.outputs.tag }} - # GoReleaser reads the tag history for its changelog. - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - - name: Set up Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - - - name: Cache Gradle - uses: actions/cache@v6 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - 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: a promotion tags a commit - # that is already tagged, so the notes would cover the single merge that - # produced the last patch. 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: - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_PREVIOUS_TAG: ${{ needs.tag.outputs.previous_tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 82aec9b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: release - -# Promotes the last release to a milestone. ci cuts a patch on every merge, so -# the released versions run 0.1.4, 0.1.5, 0.1.6; this marks the one you have -# been running as 0.2.0 and publishes it under that name. The release notes -# reach back over every patch being consolidated. -# -# It runs no checks of its own and needs none. The commit it releases is the one -# the last release was cut from, and that commit only carries a tag because a -# whole ci run went green on it. -# -# There is no box to type a version into. The two entries below are the only two -# things a promotion can mean, and a version named by hand is the one way to get -# a release that does not follow from the tag before it. -on: - workflow_dispatch: - inputs: - bump: - description: Consolidate the patches since the last release at this level - type: choice - options: - - minor - - major - default: minor - -permissions: - contents: read - -jobs: - release: - name: Release - # No sha: this releases the commit the last release was cut from rather than - # whatever master has drifted to since. - uses: ./.github/workflows/release-publish.yml - permissions: - contents: write - with: - bump: ${{ inputs.bump }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }}