Skip to content

Release from GitHub Actions with trusted publishing, and package only tracked files - #142

Merged
lewispb merged 2 commits into
mainfrom
release-pipeline
Sep 3, 2026
Merged

lewispb merged 2 commits into
mainfrom
release-pipeline

Conversation

@lewispb

@lewispb lewispb commented Sep 3, 2026

Copy link
Copy Markdown
Member

Replaces the laptop release flow with a tag-triggered GitHub Actions workflow in the shape RubyGems recommends, and makes the gemspec package only files tracked by git.

Why

bin/release built the gem from the working tree, pushed it to RubyGems with a personal OTP, and only then committed and tagged. A tag and its gem did not have to match, and they did not: v0.1.2 declares 0.1.1 and v0.3.0 declares 0.2.0. The gemspec listed files with a filesystem glob, so anything present under app, config, db, lib or public at build time was packaged. That put the ignored config/credentials/development.key and test.key into the public 0.2.0 and 0.3.0 gems. Treat both keys as disclosed.

What changes

Package only tracked files. The gemspec takes its file list from git ls-files, filtered to the same directories as before. test/packaging_test.rb checks that an ignored config/credentials/test.key cannot enter the package.

Release from CI. .github/workflows/release.yml runs on a v* tag push, as one job in the release-rubygems environment, which requires a reviewer. The job:

  1. Checks that the tag matches Upright::VERSION and that the tagged commit is on origin/main.
  2. Runs rubygems/release-gem, pinned by SHA. It obtains a short-lived credential from RubyGems trusted publishing, runs rake release (which builds the gem and, because the tag already exists, skips tagging and pushing git), pushes the gem with a sigstore attestation, and waits for rubygems.org to serve it.
  3. Creates the GitHub Release for the tag with generated notes and the gem attached.

rake tag creates and pushes the tag after checking that the tree is clean, the branch is main, HEAD equals origin/main, and the tag does not exist. bin/release is removed. RELEASING.md documents the process, verification, recovery, and the one-time setup. CONTRIBUTING.md points at it.

This is the same workflow shape as Shopify's gems, jekyll, faraday, factory_bot and about 1,200 other repositories, and the one the RubyGems trusted publishing guide shows. An earlier revision of this PR carried a six-job pipeline with a registry reconciliation state machine, a recovery workflow and package verification scripts, about 1,950 lines. None of the projects surveyed do any of that, and RubyGems now builds reproducibly by default, so the pipeline was cut to this.

Verification

  • Full suite passes. RuboCop clean. actionlint and zizmor (pedantic) report nothing on the workflow.
  • Two rake build runs of the same commit produce the same SHA-256, and the package contains no key files.

Before the first release through this workflow

The one-time setup in RELEASING.md has not been done: the release-rubygems environment with a required reviewer and a v* tag policy, tag rulesets, a read-only default workflow token, the RubyGems trusted publisher for release.yml in that environment, and removal of long-lived API keys from the owner accounts. The trusted publisher should be added right before tagging 0.4.0.

Copilot AI balanced review requested due to automatic review settings September 3, 2026 10:22

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.

🟡 Changes recommended

Recovery archives the tag without Git metadata, preventing the Git-based manifest from reproducing the published gem.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Moves gem releases to a hardened, tag-triggered GitHub Actions pipeline and restricts packages to Git-tracked files.

Changes:

  • Adds verified publishing, attestation, and recovery workflows.
  • Adds package and registry verification with tests.
  • Replaces laptop releases with rake bump and rake tag.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
.github/workflows/release.yml Adds the automated release pipeline.
.github/workflows/release-recovery.yml Adds failed-release recovery.
upright.gemspec Packages only Git-tracked files.
script/release/package_verification.rb Implements package validation.
script/release/verify_package.rb Exposes package verification CLI.
script/release/registry.rb Implements registry reconciliation.
script/release/registry_check.rb Exposes registry check CLI.
script/release/registry_confirm.rb Exposes publication confirmation CLI.
script/release/registry_download.rb Exposes verified download CLI.
test/release/package_verification_test.rb Tests package verification.
test/release/registry_test.rb Tests registry state handling.
test/packaging_test.rb Tests tracked-file packaging.
Rakefile Adds version bump and tagging tasks.
bin/release Removes laptop-based releases.
RELEASING.md Documents release and recovery procedures.
CONTRIBUTING.md Links contributors to release documentation.
CHANGELOG.md Records the disclosure and release changes.
Review details
  • Files reviewed: 16/17 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/release-recovery.yml Outdated
# tagged-src/ while the recovery helpers (script/release) keep
# running from the revision this workflow was dispatched on.
mkdir tagged-src
git archive "refs/tags/v${VERSION}" | tar --extract --directory tagged-src

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in de8b28d. A strict build from a git archive extraction fails because the gemspec's git ls-files returns nothing there. The verify job now checks the tag out with git worktree add --detach tagged-src, which keeps the index while the recovery helpers still run from the dispatch revision. Verified locally that a worktree build of the same commit produces the same SHA-256 as a build from the checkout. RELEASING.md updated to match.

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.

🔵 Needs a closer look

The security-sensitive publishing pipeline depends on external environment protections and trusted-publisher configuration that have not yet been exercised.

Review details
  • Files reviewed: 16/17 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread script/release/package_verification.rb Outdated
Comment on lines +103 to +107
# The gemspec's Dir globs are evaluated against the working tree, so
# an untracked file dropped into app/ or lib/ during the build would
# be packaged. Filtering the expectation through `git ls-files`
# (the tag's tracked content) makes that contamination a failure
# instead of a silently wider gem.
The gemspec listed files with a filesystem glob, so anything present under
app, config, db, lib or public at build time was packaged, including the
ignored config/credentials/*.key files that reached the public 0.2.0 and
0.3.0 gems. The file list now comes from `git ls-files`, filtered to the same
directories, and test/packaging_test.rb checks that an ignored key file
cannot enter the package.
bin/release built the gem from the working tree, pushed it with a personal
RubyGems OTP, and committed and tagged afterwards, so a tag and its gem did
not have to match. It is replaced by .github/workflows/release.yml, which
follows the shape RubyGems recommends: a v* tag push runs one job in the
reviewer-gated release-rubygems environment. The job checks that the tag
matches Upright::VERSION and is on origin/main, then runs
rubygems/release-gem, which builds the gem, pushes it with short-lived OIDC
credentials and a sigstore attestation, and waits for rubygems.org to serve
it. Finally it creates the GitHub Release with the gem attached. `rake tag`
creates and pushes the tag after checking the tree is clean and main is up to
date. RELEASING.md documents the process, recovery, and the one-time setup.
@lewispb

lewispb commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Rewritten. I surveyed how RubyGems, Rails, Shopify and others release and cut this to the recommended shape: one job, rubygems/release-gem, a version and ancestry guard, and a GitHub Release step. The PR description explains what went and why. The earlier Copilot finding about git archive in the recovery workflow no longer applies because the recovery workflow is gone; recovery is documented in RELEASING.md as manual steps.

@lewispb
lewispb merged commit cce2800 into main Sep 3, 2026
8 checks passed
@lewispb
lewispb deleted the release-pipeline branch September 3, 2026 17:02
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.

2 participants