Skip to content

Cut releases from one dispatch that tags what it published - #37

Merged
16francej merged 2 commits into
mainfrom
releases
Jul 30, 2026
Merged

Cut releases from one dispatch that tags what it published#37
16francej merged 2 commits into
mainfrom
releases

Conversation

@16francej

@16francej 16francej commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Why

Publishing is two manual workflow_dispatch runs an operator has to order by hand — build signed images at a SHA, then run publish-cli with images_ref set to that same SHA — and nothing records the result. The repo has no tags and no releases, so the pair a version actually denotes (an npm version plus the six GHCR digests baked into cli/manifest.json) exists only inside a published tarball, and the sidebar reads "No releases published".

What

.github/workflows/release.yml becomes the single entry point: Actions → Release → Run workflow, from main.

  1. Preflight — refuses to run off main, resolves v<cli/package.json version>, and refuses if that tag already exists.
  2. Images — calls release-package.yml as a reusable workflow.
  3. CLI — calls publish-cli.yml, which pins the just-built digests and publishes.
  4. Release — creates the tag atomically at the dispatched SHA via the git refs API, then gh release create --verify-tag --generate-notes, attaching the resolved digests as images.json so the digest set stays recoverable from the tag.

Both existing workflows stay independently dispatchable; they only gain a workflow_call trigger.

Design notes

  • Calling, not merging, the two workflows keeps the pinned cosign certificate identity (…/release-package.yml@refs/heads/main) verifying unchanged, because a reusable workflow's OIDC identity comes from job_workflow_ref — its own ref, not the caller's. A test asserts the release never triggers from a tag ref so this cannot silently regress.
  • Dispatch-then-tag, not tag-then-build. Triggering from a tag push would both break that cosign identity and leave a dangling tag plus a release pointing at images that never published whenever a build failed.
  • Atomic tagging. --target only names a commit when gh creates the tag itself. Creating the ref first means a tag another actor raced in during the several minutes after preflight fails the run instead of being silently adopted.
  • Fails loudly off main. A job-level if would have skipped every job, and an all-skipped workflow reports success — green, with nothing released.
  • Resumable, but only when it's really the same release. If npm already has the version, the publish is skipped only after packing the published tarball and confirming it pins the same digests; a mismatch fails. So a run that died after npm publish but before tagging resumes, while a re-dispatch at a different commit can't tag a version npm serves differently.
  • contents: write is confined to the tagging job — asserted by test.

Behavior change worth a second look

The new cli-version CI job fails a PR that changes what the package ships (cli/bin, cli/src, cli/templates, cli/manifest.json, cli/package.json, cli/package-lock.json, cli/README.md, cli/LICENSE, cli/tsconfig*.json) without a strictly-greater semver bump in cli/package.json. Without it the tag names nothing in particular.

This is a real change in practice: cli/package.json has moved 3 times ever, while cli/src changes in nearly every PR. Concurrent PRs will now conflict on the version line — usually self-resolving, since both edit it. The alternative is dropping the job and bumping once in a release PR; the preflight tag gate prevents a double release either way.

The guard diffs against the merge base, not the base branch head, so unrelated main commits don't trip it.

Known limits (deliberate, not oversights)

  • publish-cli re-resolves the mutable :<SHA> tag rather than carrying forward the exact digest release-package signed. Aggregating digests out of a matrix job needs a collector job; worth doing, but a separate change.
  • gh release create is not atomic across its own tag/asset/publish API calls. A failure mid-way leaves a tag that preflight will then refuse, so recovery is manual.
  • Actions keeps only one pending run per concurrency group, so a burst of three dispatches drops the middle one. Harmless here — re-dispatch.
  • If the repo ever moves from NPM_TOKEN to npm trusted publishing, the publisher must be configured against release.yml (the caller), not publish-cli.yml, because npm validates GITHUB_WORKFLOW_REF.

Verification

  • Workflow YAML parses; every run block passes bash -n.
  • test/release-workflows.test.ts — 13/13, covering job ordering, both preflight gates, atomic tagging, the resume comparison, contents: write confinement, and the cosign-identity invariant.
  • test/removed-features.test.ts green (it asserts release-package.yml survives).
  • npm run typecheck, eslint, oxlint, prettier --check clean.
  • Replayed the version guard against real history: fails 9723930 (cli/src, no bump), passes d98a914 (0.1.0 → 0.1.1), ignores 2c660da (non-CLI). Bump logic checked across upgrade / no-op / downgrade / 0.9.00.10.0 / non-semver / prerelease.
  • Exercised the npm resume path against the live registry: packed the real @yc-software/qm@0.1.1, extracted its manifest.json, and confirmed the comparison correctly refuses on a digest mismatch.

An independent adversarial review (Codex) ran against this diff; the four findings it raised that held up are fixed in 01a9da8, and the four that were design tradeoffs are the known limits above.

Version bumped to 0.1.2 because this PR edits cli/README.md, which ships in the package — so the guard is dogfooded here.

No front-end surface changes, so no screenshots.

Publishing was two manual workflow_dispatch runs an operator had to order by
hand — build signed images at a SHA, then publish the CLI with images_ref set
to that same SHA — and nothing recorded the result. The repository has no tags
and no releases, so the pair a version actually denotes (an npm version and the
six GHCR digests baked into cli/manifest.json) existed only inside a published
tarball.

release.yml is now the single entry point: Actions -> Release -> Run workflow,
from main. It refuses a tag it has already published, calls the two existing
workflows as reusable workflows in order, then tags v<version> and creates the
GitHub release with the resolved digests attached as an asset, so the digest set
stays recoverable from the tag. Calling rather than merging the two workflows
keeps each dispatchable on its own and, because a reusable workflow's OIDC
identity is its own ref, leaves the pinned cosign certificate identity
verifying unchanged. Driving this from a tag push instead would have broken
that identity, and would have left a dangling tag whenever a build failed.

Publishing skips a version npm already has, so a run that died after npm and
before the tag resumes instead of failing on a 403. The version is
cli/package.json's, which CI now requires a pull request to bump whenever it
touches what the package ships — without that the tag names nothing in
particular.
Four fixes from an independent review of the release workflow.

The tag is now created through the git refs API before gh release create,
which then runs with --verify-tag instead of --target. --target only names a
commit when gh creates the tag itself: if another actor raced the same tag in
during the several minutes between preflight and tagging, gh would silently
adopt it and the release would point away from the commit whose images and
package were just published. Creating the ref is atomic and fails on a
duplicate.

A non-main dispatch failed by skipping every job, and a workflow whose jobs all
skip reports success — an operator could read green and believe a release
happened. Preflight now runs everywhere and exits non-zero off main.

Skipping the publish because npm already has the version assumed that version
came from this release. A direct publish-cli dispatch, or a re-dispatch at a
different commit, could leave npm serving a tarball pinning digests the new tag
claims to name. The skip now packs the published version, compares its pinned
manifest to the digests just resolved, and only holds when they match.

The version guard missed cli/LICENSE, cli/tsconfig*.json and
cli/package-lock.json, all of which change the tarball or the emitted dist, and
accepted a downgrade or a non-semver string as a bump. It now covers those paths
and requires a strictly greater semver.

Left as known limits: publish-cli still re-resolves the mutable SHA tag rather
than carrying the exact digest release-package signed, gh release create is not
atomic across its own tag/asset/publish calls, and Actions keeps only one
pending run per concurrency group.
@16francej
16francej merged commit b80f9c6 into main Jul 30, 2026
16 checks passed
@16francej
16francej deleted the releases branch July 30, 2026 23:50
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