Skip to content

Rehearse every publish path on workflow_dispatch - #673

Merged
jeremy merged 4 commits into
mainfrom
fix/release-workflow-dryrun
Aug 5, 2026
Merged

Rehearse every publish path on workflow_dispatch#673
jeremy merged 4 commits into
mainfrom
fix/release-workflow-dryrun

Conversation

@jeremy

@jeremy jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member

Dispatching the six release-*.yml workflows is meant to be a full rehearsal of a release. Rehearsing them from main at 7085ba3b1 (the 0.13.0 bump) showed only three of the six exercising a publish path, and one that could not be dispatched at all.

A skipped job is not a rehearsal. If the Go tagging step or the Swift version check were broken, nothing would say so until the tag push — and that push is irreversible, so the first evidence of a broken publish path would arrive at the worst possible moment.

What was wrong, per SDK

  • go — the tag job carried a job-level if: github.event_name == 'push' and was skipped entirely.
  • swift — its verification steps were step-gated the same way; a dispatch built and tested but rehearsed nothing about the release itself.
  • python — its only publishing job carries environment: release-pypi, whose deployment branch policy is v*-only. A manual run from a branch is refused before any step executes, so the wheel was never even built in rehearsal.
  • ruby, typescript — same single-job shape as python. They happen to rehearse from main today, but the environment is on the job that does the building, so the rehearsal is hostage to a deployment approval it does not need.
  • kotlin — rehearsed correctly, but did so inside a job holding packages: write.

The GITHUB_REF defect in release-go

The Go tag job could not simply have its job-level if removed. It derives the tag it pushes from

TAG="${GITHUB_REF#refs/tags/}"

On a workflow_dispatch GITHUB_REF is refs/heads/<branch>, not refs/tags/v*, so the prefix strip is a no-op and TAG becomes the literal string refs/heads/main. Ungating it naively would have "rehearsed" creating a tag named go/refs/heads/main.

The dry run therefore reads the version from go/pkg/basecamp/version.go, the same way release-ruby reads version.rb and release-typescript reads package.json. It computes go/vX.Y.Z, checks the remote with git ls-remote, and reports which of the three real branches it would take: create, leave alone, or force-move.

Build outside the environment, publish inside it

For python, ruby and typescript the fix is a split rather than a policy change. No deployment branch policy is widened; release-pypi stays v*-only.

  • An unprotected build job — no environment:, contents: read — runs on both events. It reads the version from that SDK's own source of truth, builds the artifact, prints what it would publish, and uploads the result with actions/upload-artifact.
  • An environment-gated publish jobneeds: build, job-level if: github.event_name == 'push' — downloads that artifact and publishes exactly those bytes. It does not rebuild.

Two things fall out. The environment now gates only real publishes, so a rehearsal needs no deployment approval and works from any ref, including a fork. And what ships is what was built and validated, not a rebuild nobody looked at.

For typescript that means packing a tarball instead of letting npm publish rebuild through prepublishOnly, then publishing the tarball by path. npm pack does not run prepublishOnly, hence the now-explicit npm run build. This is the one place where the tag-push invocation itself changes shape (npm publish <tarball> rather than npm publish <dir>), so the rehearsal runs that exact invocation — see below.

Kotlin is the exception, deliberately. Gradle's publish task builds from the project rather than from a prepared file, so there is no artifact to hand over and no way to give kotlin the build-once property without hand-rolling Maven upload, which would be a larger and riskier change than the gap it closes. Kotlin is therefore the one workflow where the bytes published on a tag are rebuilt by the publish job rather than handed over from the build that was validated. It still gets the least-privilege half of the treatment: its rehearsal moved into its own job so it no longer runs under packages: write.

Rehearsing the real invocation, not an approximation

Each build job now runs whatever native dry run or artifact validation its packaging tool actually offers, so the rehearsal exercises the publish path rather than asserting it should work.

npmnpm publish "$TARBALL" --access public --tag "$NPM_TAG" --dry-run --provenance: the publish job's exact command with --dry-run added. npm resolves the manifest out of the tarball, enumerates the file set it would upload, and negotiates with the registry without writing. A probe run confirmed --provenance is accepted alongside --dry-run on npm 11.16.0 / Node 24.18.0 in a job holding no id-token: write, so the whole invocation is exercised. The attestation itself is minted from the OIDC token at publish time and is the one part a dry run cannot prove.

RubyGemsgem push has no dry run, so the artifact is verified instead. The gemspec collects its file list from git ls-files; a checkout that did not produce one would build a well-formed gem containing no library, which RubyGems accepts and nobody can require. The check reads the packaged spec back out of the .gem and asserts name, version, and the presence of lib/.

PyPItwine check dist/*, the same metadata and long-description validation PyPI applies at upload. Deliberately not --strict: see the finding below.

Finding: the Python package has no long description

twine check --strict failed the first probe run:

Checking dist/basecamp_sdk-0.13.0-py3-none-any.whl: FAILED due to warnings
WARNING  `long_description_content_type` missing. defaulting to `text/x-rst`.
WARNING  `long_description` missing.

python/README.md exists (33 KB) but python/pyproject.toml declares no readme, so the sdist and wheel carry no long description and the PyPI project page for basecamp-sdk renders empty. PyPI accepts this, so it is not an upload blocker and --strict would fail every release over it. That is a one-line pyproject.toml fix, not a workflow fix, and it changes the metadata of the artifact this release ships — so it is reported rather than smuggled in here. The workflow runs plain twine check, which passes with those warnings visible in the log.

Job graph after

workflow job runs on environment: permissions
go test both contents: read
go tag-dry-run dispatch contents: read
go tag push contents: write
swift test (+ dry-run steps) both contents: read
kotlin test both contents: read
kotlin publish-dry-run dispatch contents: read
kotlin publish push contents: read, packages: write
ruby build both contents: read
ruby publish push release-rubygems contents: read, id-token: write
typescript build both contents: read
typescript publish push release-npm contents: read, id-token: write
python build both contents: read
python publish push release-pypi contents: read, id-token: write, attestations: write

Every job that runs on a dispatch holds contents: read and nothing else. Every write permission and every environment: now sits on a job that only runs on a tag push. git tag and git push remain gated on github.event_name == 'push'.

The Verify tag is on main guard moved with the checkout into each build job. It still runs on push and still blocks the publish job through needs:.

The publish job gets this run's artifact, not a rebuild and not a stale one

  publish:
    name: Publish to PyPI
    needs: build
    if: github.event_name == 'push'
    ...
    steps:
      - name: Download distribution
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-dist
          path: python/dist/

Three things pin this down. needs: build makes the publish job run only after the build job in this run succeeded. actions/download-artifact defaults run-id to ${{ github.run_id }} and repository to ${{ github.repository }} — its own action.yml says it "will attempt to download artifacts from the current repository and the current workflow run" when no github-token is given, and no token is given here — so it cannot reach a previous run's artifact. And the publish job has no checkout and no build tooling: there are no sources present to rebuild from even if a step tried. The same wiring holds for ruby (rubygem) and typescript (npm-tarball).

Verification

make lint-actions (actionlint + zizmor) green: No findings to report. Good job! (17 ignored, 34 suppressed), real exit code 0.

All six dispatched against this branch ref at 42c11c848. Every previously-skipped job or step executed, every workflow read 0.13.0 from its own source of truth, and every publish job was skipped:

Release Python SDK      Build distribution: success              Publish to PyPI: skipped
Release Ruby SDK        Build gem: success                       Publish to RubyGems: skipped
Release TypeScript SDK  Pack npm tarball: success                Publish to npm: skipped
Release Kotlin SDK      Publish to GitHub Packages (dry run): success   Publish to GitHub Packages: skipped
Release Go SDK          Create Go module tag (dry run): success  Create Go module tag: skipped
Release Swift SDK       Test Swift SDK: success

Python is the acid test — it built and rehearsed from a branch with release-pypi still v*-only:

Build distribution | Extract version | Rehearsal for version: 0.13.0
Build distribution | Dry-run build verification | ##[notice]Dry run mode - not actually publishing
Build distribution | Dry-run build verification | Would publish: dist/basecamp_sdk-0.13.0-py3-none-any.whl
                                                  dist/basecamp_sdk-0.13.0.tar.gz

Go, previously skipped entirely:

Create Go module tag (dry run) | Rehearsal for version: 0.13.0
Create Go module tag (dry run) | Would create go/v0.13.0 at 42c11c8480c0501f49f14d46dc2341d5cd75ee0e and push it.
Create Go module tag (dry run) | ##[notice]Dry run mode - not actually publishing
Create Go module tag (dry run) | Would publish: Go module tag go/v0.13.0 (proxy.golang.org serves github.com/basecamp/basecamp-sdk/go from it)

Swift, previously skipped:

Test Swift SDK | Extract version | Rehearsal for version: 0.13.0
Test Swift SDK | Dry-run package verification | Package manifest resolves.
Test Swift SDK | Dry-run package verification | Tag v0.13.0 does not exist yet — it is what will trigger the real release run.
Test Swift SDK | Dry-run package verification | ##[notice]Dry run mode - not actually publishing
Test Swift SDK | Dry-run package verification | Would publish: Swift package Basecamp at tag v0.13.0 (resolved by SPM from this repository; no artifact upload)

Ruby — gem push has no dry run, so the built artifact is read back:

Build gem | Verify packaged gem | Packaged basecamp-sdk-0.13.0: 128 files, 119 under lib/
Build gem | Verify packaged gem | Runtime dependencies: faraday (~> 2.0), zeitwerk (~> 2.6)
Build gem | Dry-run report      | Would publish: basecamp-sdk-0.13.0.gem to RubyGems

TypeScript — the publish job's exact invocation, dry:

Pack npm tarball | Dry-run publish | Would publish: @37signals/basecamp@0.13.0 under npm tag latest from 37signals-basecamp-0.13.0.tgz
Pack npm tarball | Dry-run publish | npm notice Tarball Details
Pack npm tarball | Dry-run publish | npm notice package size: 540.3 kB
Pack npm tarball | Dry-run publish | npm notice total files: 503
Pack npm tarball | Dry-run publish | npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access (dry-run)

Python — twine check against the artifact that would be uploaded:

Build distribution | Check distribution metadata | Checking dist/basecamp_sdk-0.13.0-py3-none-any.whl: PASSED with warnings
Build distribution | Check distribution metadata | Checking dist/basecamp_sdk-0.13.0.tar.gz: PASSED with warnings

Kotlin:

Publish to GitHub Packages (dry run) | Rehearsal for version: 0.13.0
Publish to GitHub Packages (dry run) | Would publish: com.basecamp:basecamp-sdk:0.13.0 to GitHub Packages

Nothing was mutated. After all six runs:

$ git ls-remote --tags origin 'v0.13.0' 'go/v0.13.0'
(no output)
pypi 0.13.0 -> 404   rubygems 0.13.0 -> 404   npm 0.13.0 -> 404

The only side effect is the build artifacts attached to the runs themselves, at 7-day retention.

No action SHA pins were changed; actions/upload-artifact reuses the pin already in this repo and actions/download-artifact is pinned to v8.0.1, which shares @actions/artifact ^6.2 with it. release-github.yml is untouched.

Dispatching the six release workflows was supposed to be a full rehearsal
of a release, but only ruby, typescript and kotlin actually exercised
their publish path. The Go tag job carried a job-level
`if: github.event_name == 'push'` and the Swift verification steps were
step-gated the same way, so on a manual run they were simply skipped: a
break in either would first surface during the irreversible tag push.

Go could not just be ungated. The job derives its tag from
`TAG="${GITHUB_REF#refs/tags/}"`, and on a dispatch GITHUB_REF is
`refs/heads/<branch>`, so that expansion yields the literal string
`refs/heads/main`. The rehearsal now reads the version from
go/pkg/basecamp/version.go, the same way release-ruby reads version.rb
and release-typescript reads package.json, computes the `go/vX.Y.Z` tag,
and reports whether it would create it, leave it alone, or move it.

The dry run lives in its own job rather than in the tagging job so that
it holds `contents: read`; only the push path keeps `contents: write`.
`git tag` and `git push` remain gated on the push event and the push
path is otherwise untouched.

Swift distributes through SPM, so the release artifact is the git tag
itself. Its rehearsal reads the version from BasecampConfig.swift,
checks that the package manifest resolves, reports the tag SPM will
resolve from, and builds and tests as before.

Kotlin and TypeScript gain a "Would publish" line so all six workflows
now print the same three things on a dispatch: the version read from
that SDK's own source of truth, exactly what would be published, and an
unmistakable dry-run notice.
Copilot AI balanced review requested due to automatic review settings August 4, 2026 23:20
@jeremy jeremy added the github-actions Pull requests that update GitHub Actions label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/release-go.yml
  • .github/workflows/release-kotlin.yml
  • .github/workflows/release-python.yml
  • .github/workflows/release-ruby.yml
  • .github/workflows/release-swift.yml
  • .github/workflows/release-typescript.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

The dispatch rehearsal for python could not run at all: its only
publishing job carries `environment: release-pypi`, whose deployment
branch policy is `v*`-only, so a manual run from a branch is refused
before any step executes. Ruby and typescript have the same shape and
would be refused the same way from any ref that is not a release tag.
Widening the policy would buy a rehearsal by weakening the thing that
makes the environment worth having.

Split each of those three workflows instead. An unprotected build job
reads the version from that SDK's own source of truth, builds the
artifact, prints what it would publish, and uploads the result. An
environment-gated publish job downloads that artifact and publishes
exactly those bytes. The environment now gates only real publishes, the
rehearsal needs no deployment approval and works from any ref, and the
bytes that ship are the bytes that were built and validated rather than
a rebuild that nobody looked at.

For typescript that means packing a tarball rather than letting
`npm publish` rebuild through prepublishOnly, and publishing the tarball
by path. The build is now explicit because `npm pack` does not run
prepublishOnly.

Kotlin gets the same least-privilege treatment in the shape its
publisher allows. Gradle's publish task builds from the project rather
than from a prepared file, so there is no artifact to hand over; the
rehearsal moves into its own job so that it no longer runs in a job
holding `packages: write`.
Copilot AI review requested due to automatic review settings August 4, 2026 23:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 4, 2026 23:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

The npm dry run now runs the exact command the publish job runs on a
tag, flags and all, with --dry-run added. npm resolves the manifest out
of the tarball, lists the file set it would upload and negotiates with
the registry without writing, which is what proves that publishing a
packed tarball behaves like publishing a directory. A probe run
confirmed --provenance is accepted alongside --dry-run on npm 11.16.0
and Node 24.18.0 even in a job with no id-token permission, so the
invocation is exercised whole; only the attestation, which is minted
from the OIDC token at publish time, is left unproven.

gem push has no dry run, so the gem is verified as an artifact instead.
The gemspec collects its file list from git ls-files, so a checkout that
did not produce one would build a well-formed gem containing no library
— something RubyGems accepts and nobody can require. The check reads the
packaged spec back and asserts name, version and the presence of lib/.

Python gains twine check, the same metadata and long-description
validation PyPI applies at upload. Not --strict: the package ships no
long_description at all today, so --strict would fail every release over
a pyproject.toml defect rather than a workflow one.
Copilot AI review requested due to automatic review settings August 4, 2026 23:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy merged commit 70d576b into main Aug 5, 2026
66 of 67 checks passed
@jeremy
jeremy deleted the fix/release-workflow-dryrun branch August 5, 2026 04:16
jeremy added a commit that referenced this pull request Aug 5, 2026
#673 and #675 landed after the last measurement, so the guide's baseline
moves from 931c36a to 70d576b.

The counting method changes too, because the one committed here was
wrong. `git log v0.12.0..HEAD | wc -l` counts COMMITS, which equals PRs
only while every commit is a squash merge. The release-prep commit is
pushed straight to main and is not a PR, so from the moment the version
is bumped that count runs one high — it reported 59 where 58 PRs had
merged.

That is the third way this number has been wrong, each differently: a
lexicographic string compare on timestamps, then a temporal compare that
credited #556 to this release (its squash commit IS the commit v0.12.0
tags), and now a commit count meeting its first non-PR commit.

The rule that survives all three is the definition itself: reachable
from HEAD and not reachable from v0.12.0, applied to the merge commits
of PRs rather than to commits. Both derivations in the guide now use it,
and both were run to confirm they reproduce the numbers printed beside
them.

Committed directly rather than as a pull request, on purpose: a PR
carrying this number would increment the number it states. #642 shipped
wrong for exactly that reason — a document inside the release cannot
count itself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

github-actions Pull requests that update GitHub Actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants