Skip to content

Commit cf5b033

Browse files
Initial implementation.
0 parents  commit cf5b033

24 files changed

Lines changed: 1366 additions & 0 deletions

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/.bundle/
2+
/.context/
3+
/coverage/
4+
/gems.locked
5+
/pkg/

‎.rubocop.yml‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
plugins:
2+
- rubocop-md
3+
- rubocop-socketry
4+
5+
AllCops:
6+
DisabledByDefault: true
7+
8+
# Socketry specific rules:
9+
10+
Layout/ConsistentBlankLineIndentation:
11+
Enabled: true
12+
13+
Layout/BlockDelimiterSpacing:
14+
Enabled: true
15+
16+
Style/GlobalExceptionVariables:
17+
Enabled: true
18+
19+
# General Layout rules:
20+
21+
Layout/IndentationStyle:
22+
Enabled: true
23+
EnforcedStyle: tabs
24+
25+
Layout/InitialIndentation:
26+
Enabled: true
27+
28+
Layout/IndentationWidth:
29+
Enabled: true
30+
Width: 1
31+
32+
Layout/IndentationConsistency:
33+
Enabled: true
34+
EnforcedStyle: normal
35+
36+
Layout/BlockAlignment:
37+
Enabled: true
38+
39+
Layout/EndAlignment:
40+
Enabled: true
41+
EnforcedStyleAlignWith: start_of_line
42+
43+
Layout/BeginEndAlignment:
44+
Enabled: true
45+
EnforcedStyleAlignWith: start_of_line
46+
47+
Layout/RescueEnsureAlignment:
48+
Enabled: true
49+
50+
Layout/ElseAlignment:
51+
Enabled: true
52+
53+
Layout/DefEndAlignment:
54+
Enabled: true
55+
56+
Layout/CaseIndentation:
57+
Enabled: true
58+
EnforcedStyle: end
59+
60+
Layout/CommentIndentation:
61+
Enabled: true
62+
63+
Layout/FirstHashElementIndentation:
64+
Enabled: true
65+
EnforcedStyle: consistent
66+
67+
Layout/EmptyLinesAroundClassBody:
68+
Enabled: true
69+
70+
Layout/EmptyLinesAroundModuleBody:
71+
Enabled: true
72+
73+
Layout/EmptyLineAfterMagicComment:
74+
Enabled: true
75+
76+
Layout/SpaceInsideBlockBraces:
77+
Enabled: true
78+
EnforcedStyle: no_space
79+
SpaceBeforeBlockParameters: false
80+
81+
Layout/SpaceAroundBlockParameters:
82+
Enabled: true
83+
EnforcedStyleInsidePipes: no_space
84+
85+
Layout/FirstArrayElementIndentation:
86+
Enabled: true
87+
EnforcedStyle: consistent
88+
89+
Layout/ArrayAlignment:
90+
Enabled: true
91+
EnforcedStyle: with_fixed_indentation
92+
93+
Layout/FirstArgumentIndentation:
94+
Enabled: true
95+
EnforcedStyle: consistent
96+
97+
Layout/ArgumentAlignment:
98+
Enabled: true
99+
EnforcedStyle: with_fixed_indentation
100+
101+
Layout/ClosingParenthesisIndentation:
102+
Enabled: true
103+
104+
Style/FrozenStringLiteralComment:
105+
Enabled: true
106+
107+
Style/StringLiterals:
108+
Enabled: true
109+
EnforcedStyle: double_quotes

‎bake-gem-github.gemspec‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "lib/bake/gem/github/version"
4+
5+
Gem::Specification.new do |spec|
6+
spec.name = "bake-gem-github"
7+
spec.version = Bake::Gem::GitHub::VERSION
8+
spec.summary = "Reviewable GitHub releases for Ruby gems."
9+
spec.authors = ["Samuel Williams"]
10+
spec.license = "MIT"
11+
spec.homepage = "https://github.com/socketry/bake-gem-github"
12+
spec.files = Dir.glob(["{bake,context,lib,templates}/**/*", "*.md"], base: __dir__)
13+
spec.required_ruby_version = ">= 3.3"
14+
spec.add_dependency "bake-gem", ">= 0.15.0"
15+
spec.add_dependency "net-http"
16+
spec.add_dependency "bake", "~> 0.25"
17+
end

‎bake.rb‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
# Regenerate documentation when preparing a release.
7+
def after_gem_release_version_increment(version)
8+
context["utopia:project:agent:context:update"].call
9+
end

‎bake/gem/github.rb‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
# Generate local release workflows, policy, and documentation for review.
7+
# @parameter checks [Array(String)] Required CI check names, including matrix entries.
8+
# @parameter repository [String] Canonical owner/repository.
9+
# @parameter branch [String] Default branch name.
10+
# @parameter approvals [Integer] Number of approving reviews.
11+
# @parameter signing [Boolean] Require legacy certificate signing.
12+
# @parameter ruby [String] Ruby version for release workflows.
13+
def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4")
14+
require_relative "../../lib/bake/gem/github/setup"
15+
require "bake/gem/shell"
16+
helper = Object.new.extend(Bake::Gem::Shell)
17+
remote = if repository && branch
18+
{}
19+
else
20+
JSON.parse(helper.readlines("gh", "repo", "view", "--json", "nameWithOwner,defaultBranchRef", chdir: context.root).join)
21+
end
22+
options = {repository: repository || remote.fetch("nameWithOwner"), branch: branch || remote.fetch("defaultBranchRef").fetch("name"), checks: checks, approvals: approvals, ruby: ruby}
23+
options[:signing] = signing unless signing.nil?
24+
Bake::Gem::GitHub::Setup.new(context.root).generate(**options)
25+
end
26+
27+
# Show the desired rules, existing rules, environments, and RubyGems bootstrap values.
28+
def doctor
29+
require_relative "../../lib/bake/gem/github/project"
30+
result = Bake::Gem::GitHub::Project.new(context.root).doctor
31+
puts JSON.pretty_generate(result)
32+
result
33+
end

‎bake/gem/github/release.rb‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require_relative "../../../lib/bake/gem/github/publisher"
7+
8+
# Prepare a patch release and open its PR.
9+
def patch
10+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch")
11+
end
12+
13+
# Prepare a minor release and open its PR.
14+
def minor
15+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor")
16+
end
17+
18+
# Prepare a major release and open its PR.
19+
def major
20+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major")
21+
end
22+
23+
# Resolve and validate a merged PR, emitting a commit output for the publishing job.
24+
def resolve(number: ENV.fetch("RELEASE_PR"))
25+
result = Bake::Gem::GitHub::Project.new(context.root).inspect_release(number)
26+
if path = ENV["GITHUB_OUTPUT"]
27+
File.open(path, "a") do |file|
28+
file.puts "release=#{!result.nil?}"
29+
file.puts "commit=#{result.fetch(:commit)}" if result
30+
end
31+
end
32+
result
33+
end
34+
35+
# Build or restore the exact artifact for a merged release PR.
36+
def build(number: ENV.fetch("RELEASE_PR"))
37+
Bake::Gem::GitHub::Publisher.new(context.root).build(number)
38+
end
39+
40+
# Verify, upload and finalize a merged release using its retained artifact.
41+
def publish(number: ENV.fetch("RELEASE_PR"))
42+
Bake::Gem::GitHub::Publisher.new(context.root).publish(number)
43+
end
44+
45+
# Rerun the original publishing workflow, preserving event identity and artifact bytes.
46+
def resume(run:)
47+
project = Bake::Gem::GitHub::Project.new(context.root)
48+
details = project.api("actions/runs/#{Integer(run)}")
49+
raise "Expected a release-publish workflow run." unless details.fetch("path") == ".github/workflows/release-publish.yaml"
50+
project.system("gh", "run", "rerun", run.to_s, "--repo", project.config.fetch("repository"), chdir: context.root)
51+
end

‎bake/gem/github/setup.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
# Inspect external settings before applying the generated policy.
7+
def plan
8+
context.lookup("gem:github:doctor").call
9+
end
10+
11+
# Apply the four managed rulesets using the current gh administrator credentials.
12+
def apply
13+
require_relative "../../../lib/bake/gem/github/project"
14+
Bake::Gem::GitHub::Project.new(context.root).apply
15+
end

‎context/getting-started.md‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# GitHub Releases
2+
3+
This guide sets up reviewed Ruby gem releases with `bake-gem-github`, native GitHub rules, RubyGems Trusted Publishing, and retained release artifacts.
4+
5+
## Installation
6+
7+
Add `bake-gem-github` and `agent-context` to your maintenance bundle. This companion requires `bake-gem` 0.15 or later for branch preparation and regeneration validation. Install the maintenance group in CI with `BUNDLE_WITH=maintenance`.
8+
9+
Use one gemspec, a stable three-part version in `lib/.../version.rb`, and repeatable `after_gem_release_version_increment` hooks. Hooks run from a clean base during validation. Commit dependency locks when practical; changing generation tools or using live network/time inputs can make old release content fail validation.
10+
11+
## Setup and migration
12+
13+
Run setup in each repository. It discovers the canonical repository and default branch through `gh` and generates reviewable local files. Supply the actual required CI job names, including supported matrix entries:
14+
15+
``` bash
16+
bundle exec bake gem:github:setup checks="Test Ruby 3.3,Test Ruby 3.4,RuboCop"
17+
bundle exec bake agent:context:install
18+
bundle exec bake gem:github:setup:plan
19+
```
20+
21+
Setup adds three pinned workflows, `config/release.yaml`, native ruleset payloads, and `.github/RELEASING.md`. Identical reruns do nothing; conflicting existing files stop before any file is written. For later policy changes, edit the configuration and review the generated workflow/policy changes explicitly. Setup does not replace other publishers: remove conflicting release workflows during migration.
22+
23+
The default is two approvals with explicit administrator bypass, dismissed stale reviews, approval of the last push, strict up-to-date CI, and immutable default-branch history/release tags. These branch rules affect **all PRs** into the default branch. Ordinary administrator reviews count as one review. A human who dispatches a bot-authored PR is not its author under GitHub's native rules.
24+
25+
Review `gem:github:setup:plan`, merge the setup PR, and confirm that **Release validation** and every selected check run. Then apply the four managed rulesets using an administrator's `gh` login:
26+
27+
``` bash
28+
bundle exec bake gem:github:setup:apply
29+
```
30+
31+
This command changes remote rulesets and preserves unrelated rulesets. Existing rulesets with the four managed names are updated. Organization rules and other existing protections still apply. Check names in configuration must exactly match GitHub checks; a partial selection does not mean all CI is required. Keep rebase merging and merge queues disabled for the initial rollout.
32+
33+
Create a `rubygems` GitHub environment restricted to the default branch. Do not add a second routine reviewer gate. On RubyGems, an owner must configure a Trusted Publisher with the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`** shown by `doctor`. Ownership/MFA and environment/signing bootstrap are deliberate manual steps in this first implementation; `doctor` prints desired and observed GitHub settings, not a claim that RubyGems ownership or publisher trust has been verified. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/).
34+
35+
If `release.cert` exists, setup enables legacy signing. Keep this public certificate in Git and install its matching **private key** as the environment secret `GEM_SIGNING_KEY`. The workflow checks the certificate validity, key match, and resulting package signatures. To opt out explicitly, pass `signing=false` during setup. No long-lived RubyGems publishing key is required.
36+
37+
Before enabling releases, confirm two people can administer the repository and recover the RubyGems account/signing key, and enough maintainers can satisfy the review policy. Pilot on one low-risk gem and prove publishing, administrator bypass, fork merges, and recovery before rolling out broadly. No organization-wide migration or live publisher setup is performed by these tasks.
38+
39+
## Request and review
40+
41+
``` bash
42+
# Local branch and commit only:
43+
bundle exec bake gem:release:branch:patch
44+
45+
# From the current default branch: prepare, validate, push and open PR:
46+
bundle exec bake gem:github:release:patch
47+
48+
# Remote request (also available in the Actions UI):
49+
gh workflow run release-prepare.yaml -f bump=patch
50+
```
51+
52+
Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and reports an existing release PR instead of opening another. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed.
53+
54+
All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned.
55+
56+
If regeneration fails, prepare a new branch from the current default branch and review the new diff. Preserve manual release-branch edits separately. Automatic refresh/force-push is not implemented. A failure during preparation leaves the branch and generated changes available for inspection.
57+
58+
## Publish and verify
59+
60+
After merge/squash, the publishing workflow verifies GitHub's merged PR record and ancestry, then checks out the exact merged commit. Later development on the default branch is allowed. It regenerates against the merged commit's **first parent**, builds in a clean worktree, optionally certificate-signs, and creates two attestations over the final bytes:
61+
62+
- A Sigstore bundle submitted explicitly with `gem push --attestation` using RubyGems 4.0.21.
63+
- GitHub SLSA provenance naming the exact checked-out commit, including when the workflow's own default-branch revision is newer.
64+
65+
The workflow retains the gem, receipt and attestations before obtaining RubyGems publishing credentials. It verifies both attestations, checks the uploaded bytes and registry bundle, then pushes the specific version tag and creates the GitHub release. Existing tags/assets are checked and never overwritten. The old `after_gem_release` GitHub hook is not called by this pipeline, so there is one owner for release creation.
66+
67+
``` bash
68+
gh attestation verify example-1.2.3.gem \
69+
--repo OWNER/REPOSITORY \
70+
--source-digest MERGED_SHA \
71+
--signer-workflow OWNER/REPOSITORY/.github/workflows/release-publish.yaml
72+
73+
gem exec sigstore-cli:0.2.3 verify example-1.2.3.gem \
74+
--bundle example-1.2.3.gem.sigstore.json \
75+
--certificate-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \
76+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
77+
```
78+
79+
Native GitHub records the merge and any bypass; the artifact receipt includes the PR and merging actor. This version does not export organization audit-log evidence or infer bypass reasons from review counts.
80+
81+
## Recovery
82+
83+
Use **Re-run all jobs** on the original publishing run, or:
84+
85+
``` bash
86+
bundle exec bake gem:github:release:resume run=RUN_ID
87+
```
88+
89+
Rerunning keeps the original event identity. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. If failure occurs before artifact retention, rebuilding is safe because uploading has not started.
90+
91+
Artifacts are retained for 90 days and copied to GitHub release assets on success. If the workflow artifact expires before finalization, manual recovery from the preserved bytes is required; do not dispatch a replacement build. GitHub concurrency does not guarantee a durable FIFO queue: rerun any publishing run displaced while pending. Resume reruns all jobs, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass.
92+
93+
## Development and current limits
94+
95+
The implementation has local repository and transport-fake tests. A real GitHub/RubyGems pilot remains necessary before enabling it across Socketry. Public single-gem repositories, ordinary stable versions, merge/squash, GitHub-hosted Linux runners, and RubyGems.org are the supported starting point. Native build matrices, reusable publisher workflows, merge queues, automated RubyGems ownership/MFA setup, cross-run artifact recovery, and organization-wide rollout are deferred.
96+
97+
Edit this guide and regenerate `context/` with `bake utopia:project:agent:context:update`. Consumers install the generated guidance using `agent-context`.

‎context/index.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Automatically generated context index for Utopia::Project guides.
2+
# Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3+
---
4+
description: Reviewable GitHub releases for Ruby gems.
5+
metadata: {}
6+
files:
7+
- path: getting-started.md
8+
title: GitHub Releases
9+
description: This guide sets up reviewed Ruby gem releases with `bake-gem-github`,
10+
native GitHub rules, RubyGems Trusted Publishing, and retained release artifacts.

‎gems.rb‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec
6+
7+
group :test do
8+
gem "sus"
9+
gem "rubocop"
10+
gem "rubocop-md"
11+
gem "rubocop-socketry"
12+
end
13+
14+
group :maintenance, optional: true do
15+
gem "agent-context"
16+
gem "utopia-project"
17+
end

0 commit comments

Comments
 (0)