From 2cc9330149592a15dfc34c66c275c947281342b7 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 14:52:56 +0530 Subject: [PATCH] feat(ci): share the publish between both release pipelines Tagging, the npm publish and GoReleaser live here. Two copies of a publish drift, and the drift only shows up on a release. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- .github/workflows/release-publish.yml | 205 ++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 .github/workflows/release-publish.yml diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..391407f --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,205 @@ +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 + version: + description: Release this version outright, overriding the bump + type: string + default: "" + 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 }} + 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 }} + VERSION: ${{ inputs.version }} + + # 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 + # A pre-release is tagged `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: ${{ needs.tag.outputs.version }} + 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 + + - 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 }}