Skip to content

chore: add a dry_run input so both release workflows can be rehearsed before the first publish - #7

Merged
dmccoystephenson merged 1 commit into
mainfrom
chore/rehearsable-releases
Sep 7, 2026
Merged

dmccoystephenson merged 1 commit into
mainfrom
chore/rehearsable-releases

Conversation

@dmccoystephenson

Copy link
Copy Markdown
Contributor

Why

Both release workflows here are workflow_dispatch only, and neither has ever been run. Neither takes a dry_run input, so the moment the npm secret and the PyPI publisher are configured, the next dispatch uploads for real. This is the only library in the set with no rehearsal.

That matters most on PyPI, where a version number can never be reused: a 0.1.0 uploaded by mistake cannot be replaced, only yanked, and 0.1.1 becomes the first usable version. Tracked in #6, which proposes this as a prerequisite rather than a nice-to-have, and referenced from #5.

kingdom-community/session-client has the same two-registry shape and both of its release workflows already take a dry_run boolean defaulting to true. That pattern is adopted here rather than a new one.

What changed

A dry_run input, type: boolean, default: true, is added to release-npm.yml and release-pypi.yml. A dispatch with the defaults builds, tests, packs or builds the distributions, checks them, and stops short of the registry.

Each workflow is split into a build job and a publish job. The publish job carries needs: build, if: ${{ !inputs.dry_run }}, the deployment environment, and id-token: write.

Why the split on both, rather than gating the publish step in place

session-client uses both shapes, and the difference between them tracks exactly one thing: whether an environment is declared. Its release-npm.yml has no environment and gates the step in place; its release-pypi.yml declares environment: pypi and splits the job. Both workflows in this repository declare an environment — environment: npm at release-npm.yml and environment: pypi at release-pypi.yml — so both take the split shape.

Gating in place would have left the job-level environment: on the only job, so a dry run would enter and touch the npm or pypi deployment environment, create a deployment record, and — once those environments exist and carry reviewers — sit waiting on an approval for a run that publishes nothing. That defeats the point of a rehearsal. Following the sibling repository's own rule rather than one of its two files verbatim keeps this consistent with it: no third pattern is introduced.

A side effect worth noting: id-token: write was declared at the top level of both workflows and now sits on the publish job only, so the build job — the job a dry run actually runs — no longer holds a token exchangeable for publishing rights.

npm

  • build: checkout, setup-node, npm ci, typecheck, test, build, then npm pack --dry-run, which prints the exact file list a publish would upload. A Report (dry run) step then states plainly that nothing was published and names the dist-tag that would have been used.
  • publish: unchanged in substance. npm publish --provenance --access public --tag "${{ inputs.tag }}" is byte-identical to the command on main, with the same NODE_AUTH_TOKEN secret. The tag input is preserved, including its latest default.
  • The publish job re-runs npm ci and npm run build rather than consuming an artifact from the build job. That repeats one build, but it keeps the publish command and its workspace context exactly as they are today, which seemed the right trade in a workflow that cannot be executed to prove a change. The pack step above is the rehearsal.

PyPI

  • build: the existing steps, plus python -m twine check dist/* and an upload-artifact of python/dist/, mirroring session-client. The dry run now checks the distributions it builds rather than only building them.
  • publish: downloads that artifact to dist/ and runs pypa/gh-action-pypi-publish@release/v1. The publish job consumes the distributions that were built and checked, rather than rebuilding them — on PyPI, what is checked and what is uploaded should be the same files.
  • Trusted publishing is unchanged, and the comment describing it is preserved.

On packages-dir: python/dist

That path was checked rather than assumed, and it was correct as written. defaults.run.working-directory: python does not apply to a uses: step, so packages-dir was being resolved against the workspace root; python -m build runs under the python working directory and writes to python/dist, so python/dist from the workspace root was the right value. It was not a latent bug.

It is nonetheless gone from this version, because the split changes where the files are. The publish job downloads the artifact into dist/ at the workspace root, which is the action's own default, so the input is no longer needed. Confirmed locally that a build from python/ produces python/dist/github_docs-0.1.0.tar.gz and github_docs-0.1.0-py3-none-any.whl.

What was verified, and what was not

Verified locally:

Check Result
actionlint v1.7.7 on both changed workflows plus ci.yml clean, exit 0 — this parses the YAML and validates the if: expressions, the inputs.dry_run context and the action inputs
actionlint on session-client's two release workflows, as a comparison baseline also clean, exit 0
js: npm ci, npm run typecheck, npm test 65 tests in 4 files, all pass, before and after the change
js: npm run build then npm pack --dry-run succeeds; 27 files, @kingdom-community/github-docs@0.1.0, 22.6 kB
python: pip install . then python -m unittest discover -s tests 31 tests, all pass, before and after
python: python -m build then python -m twine check dist/* both the wheel and the sdist PASSED — the newly added twine check step is known to pass on this tree
git status only the two workflow files are modified; no build output is tracked

Not verified, and not claimed to be: neither workflow was executed. Publishing is blocked on a missing organisation secret and an unconfigured PyPI publisher, and firing a release workflow was out of scope regardless — an accidental dispatch is precisely the irreversible event this change exists to prevent. So nothing here demonstrates the dry-run path end to end on a runner, that the publish job is skipped as intended on a dry run, that the artifact hand-off between the two PyPI jobs works on Actions, or that the publish path still works after the split. Those become observable on the first real dispatch, which is what the dry_run default now makes safe to attempt.

The Python tests were run under Python 3.11 rather than the 3.12 the workflow pins, because 3.12 is not available on this machine; the system interpreter is 3.8, below the project's requires-python = ">=3.9". ci.yml already exercises the supported matrix.

This adds a safety input and relocates the publish steps. No version was bumped, no tag was created, no workflow was dispatched.

Refs #6, #5.


drafted by Claude on behalf of Daniel Stephenson

🤖 Generated with Claude Code

https://claude.ai/code/session_01TEPCT55iN4DzyuibCoQCa2

A `dry_run` input, defaulting to true, is added to `release-npm.yml` and
`release-pypi.yml`, matching the pattern already established in
`kingdom-community/session-client`. Neither workflow has ever been
dispatched, so without this the first dispatch after the credentials and
the publisher are configured would upload for real. On PyPI that is
irreversible: a version number can never be reused, so a wrong 0.1.0
could only be yanked, never replaced.

Both workflows are split into a build job and a publish job, with the
`if: ${{ !inputs.dry_run }}` gate and the deployment environment placed
on the publish job. This is `session-client`'s `release-pypi.yml` shape,
applied here to both files because both declare an environment: gating in
place would leave a dry run entering the `npm` or `pypi` environment for a
run that publishes nothing. `id-token: write` moves down with it, so the
build job no longer holds a publishing token.

The `tag` input on the npm workflow, the publish commands and the
trusted-publishing arrangement are unchanged. A `twine check` step is
added so the PyPI dry run checks the distributions it builds, as
`session-client` does, and the built distributions are handed to the
publish job as an artifact rather than rebuilt.

drafted by Claude on behalf of Daniel Stephenson

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEPCT55iN4DzyuibCoQCa2
@dmccoystephenson

Copy link
Copy Markdown
Contributor Author

Self-review (carried-over PR, adopted this cycle)

This pull request was opened by a previous cycle and no self-review had been recorded against it, so the rubric was run now, against head e675d97.

Rubric

  • Scope: PASS — git diff --name-only origin/main... lists exactly .github/workflows/release-npm.yml and .github/workflows/release-pypi.yml; 84 insertions, 11 deletions, no source, manifest, or ci.yml change, and no version bump in either manifest.
  • Tests-new: NOT APPLICABLE — no public function or method is added; the change is workflow configuration only.
  • Tests-fix: NOT APPLICABLE — this is not a bug fix. Nothing in the diff corrects incorrect behaviour, so the regression gate has nothing to establish. Note that the packages-dir: python/dist removal is explicitly not a bug fix: it is removed because the split relocates the distributions, and the PR body already records that the old value was correct as written.
  • Sibling structure: PASS — no new file is created; both workflows keep the existing name / rationale comment / on / permissions / jobs ordering used by ci.yml.
  • Sibling renames: PASS — the publish → build + publish split is applied to both release workflows in the same commit, so the parallel pair does not drift.
  • Docs: PASS — the Phase 7 sources-of-truth table was walked. Neither release workflow is documented in README.md, js/README.md, or python/README.md; a grep for release, workflow_dispatch, and dry run across all three returns only unrelated prose (js/README.md:176 releasesUrl, and the three MIT licence sentences). No documentation claim is contradicted by this change, so no documentation update is owed.
  • Issue resolution: PASS — no Closes #N is claimed. Refs #6, #5 is the correct relationship: PyPI trusted publishing is unconfigured, and this is the one release workflow with no dry_run to rehearse it #6 additionally requires a pending publisher registered on PyPI and the pypi/npm environments created, and 0.1.0 is unpublished on npm and PyPI, so 505 lines of forked GitHub-content code stay duplicated across two orgs #5 additionally requires the dispatch itself. Both remain open, correctly.
  • CI: PASS, with a stated coverage gap — all eight legs are green on e675d97 (js 18/20/22, python 3.9–3.13). main is not structurally red; ci.yml has succeeded on main for every prior merge. However, ci.yml does not exercise the release workflows at all — they are workflow_dispatch-only and have never been run — so green CI verifies that the unchanged packages still build and pass, not that this change is correct. That verification gap is the reason for the hand-off below, and it is the reason the review that follows is a hand read of the YAML rather than a test result.
  • No-leak (js) / No-leak (python) / Result-not-exception / Stdlib-only / No runtime dependency (js) / Export parity / Support-matrix parity / Sentinel intact: NOT APPLICABLE — nothing under js/src, js/test, python/src, python/tests, either manifest, or the CI matrix is touched by the diff.

Local verification performed this cycle

The external anchor is not applicable in the ordinary sense, since no anchor-relevant file is changed. What could be checked mechanically was checked:

  • Both workflows parse as valid YAML, and the parsed job graph matches what the PR body describes: build unguarded, publish carrying needs: build and if: ${{ !inputs.dry_run }}, the deployment environment present on publish only, and id-token: write present on publish only.
  • actionlint is not installed in this environment, so the expression-level validation quoted in the PR body was not independently reproduced here.

Findings

Three observations are raised. None is judged blocking, and none was fixed in place, because each is either a disclosed trade-off or a latent condition rather than a defect in the current dispatch-only shape.

  • .github/workflows/release-npm.yml:57 and .github/workflows/release-pypi.yml:54 — !inputs.dry_run is correct for a typed boolean under workflow_dispatch, where inputs.dry_run is a real boolean rather than the string github.event.inputs.dry_run would yield. It is worth recording that the guard is safe only while these workflows stay dispatch-only: under any additional trigger the inputs context is null, !null evaluates true, and the publish job would run unguarded. The "Manual only" rationale at line 3 of each file is what holds that in place today, so the two comments are now load-bearing for more than documentation.
  • .github/workflows/release-pypi.yml:61 — the publish job declares permissions: id-token: write only, which replaces the top-level contents: read rather than extending it. That is correct least privilege for a job with no checkout step, and actions/download-artifact@v4 reads a same-run artifact through the Actions runtime token rather than GITHUB_TOKEN, so no actions: read should be required. This cannot be proven without a dispatch. The failure mode is benign if the reasoning is wrong: the download would fail before pypa/gh-action-pypi-publish is reached, so no partial upload is possible.
  • .github/workflows/release-npm.yml:76 — the tarball inspected by npm pack --dry-run in the build job is not the tarball published here; the publish job rebuilds from a fresh checkout. The PR body discloses this and gives the reason, and the trade is accepted. It does mean the npm rehearsal proves the artefact shape rather than the exact bytes that ship, which differs from the PyPI half, where the checked distributions are the uploaded ones.

Backlog deferred this cycle

The cycle was scoped to this carried-over pull request, so no new work was selected. Both open issues are left untouched for reasons outside the repository rather than by preference: #6 is blocked on a pending publisher being registered on PyPI and on the pypi and npm environments being created, and #5 is blocked on the same credentials plus the naming and scope decisions its own open-questions section raises. Neither is actionable from a pull request.

Merge disposition

Not merged autonomously. .github/workflows/* is on this loop's do-not-auto-merge list precisely because a change there is not covered by CI and decides what a published consumer receives, and both matching paths are the entirety of this diff. Owner review is requested before dispatch.

This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).


drafted by Claude on behalf of Daniel Stephenson

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant