From 290474521f9d91a3b0b8ceb3c43efe26c96672e0 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 15:10:50 +0200 Subject: [PATCH] ci(deploy): rake release[X.Y.Z] + OIDC trusted-publishing release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up releases the same way as the sibling gems (daisyui, phlex-reactive, pgbus), ahead of the v1 cut. ## Summary - Rakefile: a `rake release[X.Y.Z]` task (main-only, clean-tree guarded) that bumps lib/docs_kit/version.rb, updates Gemfile.lock + docs/Gemfile.lock, verifies `gem build --strict`, commits, pushes, and creates the GitHub Release. Supports `pre` (pre-release) and `force` (re-cut a tag). Plus a `rake build` gem content-verify task. Drops `bundler/gem_tasks` (its own arg-less `release` task would collide); spec/rubocop/default stay explicit. - .github/workflows/release.yml: on `release: published`, test (3.2/3.3/3.4) → build + content-check → publish-rubygems → upload-release-assets. Publishes over OIDC trusted publishing (rubygems/configure-rubygems-credentials, SHA pinned), Sigstore-signs the gem, and is idempotent (skips a re-push of an already-published version). `rubygems` environment + id-token: write. - README "Releasing (maintainers)": the task usage + the ONE-TIME setup (RubyGems pending trusted publisher + the `rubygems` GitHub environment) without which the first release fails. - .rubocop.yml: exclude Rakefile from Metrics/BlockLength (the release task is a long sequential script by design, matching the sibling gems). - CHANGELOG: Unreleased entry. ## Verification - [x] rake -T lists release[version,force] + build - [x] rake build packs cleanly (--strict); gem contents pass the workflow's unwanted-files guard (no .gemspec/.git*/spec dirs; 79 files) - [x] release.yml is valid YAML; 4 jobs; environment: rubygems; id-token: write - [x] bundle exec rubocop — clean (103 files) - [x] bundle exec rspec — 505 examples, 0 failures Refs #8 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- .github/workflows/release.yml | 183 ++++++++++++++++++++++++++++++++++ .rubocop.yml | 4 + CHANGELOG.md | 8 ++ README.md | 36 +++++++ Rakefile | 156 ++++++++++++++++++++++++++++- 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..46b6266 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,183 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + name: Test before release + strategy: + fail-fast: true + matrix: + ruby: + - "3.2" + - "3.3" + - "3.4" + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake + + build: + needs: test + runs-on: ubuntu-latest + outputs: + gem_name: ${{ steps.build.outputs.gem_name }} + version: ${{ steps.build.outputs.version }} + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Verify tag matches gem version + run: | + GEM_VERSION=$(ruby -r ./lib/docs_kit/version -e "puts DocsKit::VERSION") + TAG_VERSION="${{ github.event.release.tag_name }}" + TAG_VERSION="${TAG_VERSION#v}" + if [ "$GEM_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match gem version ($GEM_VERSION)" + exit 1 + fi + + - name: Build gem + id: build + run: | + gem build docs-kit.gemspec --strict + GEM_NAME=$(ls docs-kit-*.gem) + echo "gem_name=$GEM_NAME" >> "$GITHUB_OUTPUT" + TAG_VERSION="${{ github.event.release.tag_name }}" + echo "version=${TAG_VERSION#v}" >> "$GITHUB_OUTPUT" + + - name: Verify gem contents + run: | + GEM_NAME=$(ls docs-kit-*.gem) + gem unpack "$GEM_NAME" --target /tmp/gem-verify + + echo "=== File count ===" + find /tmp/gem-verify -type f | wc -l + + echo "=== Checking for unwanted files ===" + UNWANTED=$(find /tmp/gem-verify \( -name ".git*" -o -name "*.gemspec" \) -print) + if [ -n "$UNWANTED" ]; then + echo "::error::Unwanted files found in gem:" + echo "$UNWANTED" + exit 1 + fi + + UNWANTED_DIRS=$(find /tmp/gem-verify -type d \( -name "spec" -o -name "test" \) -print) + if [ -n "$UNWANTED_DIRS" ]; then + echo "::error::Unwanted directories found in gem:" + echo "$UNWANTED_DIRS" + exit 1 + fi + + echo "No unwanted files found" + + - name: Generate checksums + run: | + GEM_NAME=$(ls docs-kit-*.gem) + sha256sum "$GEM_NAME" > "$GEM_NAME.sha256" + sha512sum "$GEM_NAME" > "$GEM_NAME.sha512" + echo "=== SHA256 ===" && cat "$GEM_NAME.sha256" + echo "=== SHA512 ===" && cat "$GEM_NAME.sha512" + + - name: Upload gem artifact + uses: actions/upload-artifact@v4 + with: + name: gem + path: | + docs-kit-*.gem + docs-kit-*.gem.sha256 + docs-kit-*.gem.sha512 + if-no-files-found: error + + publish-rubygems: + needs: build + runs-on: ubuntu-latest + environment: rubygems + permissions: + id-token: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - uses: actions/download-artifact@v4 + with: + name: gem + + - name: Verify checksums + run: | + sha256sum -c docs-kit-*.gem.sha256 + sha512sum -c docs-kit-*.gem.sha512 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + + - name: Configure RubyGems trusted publishing credentials + uses: rubygems/configure-rubygems-credentials@bc6dd217f8a4f919d6835fcfefd470ef821f5c44 # v1.0.0 + + - name: Push to RubyGems with Sigstore attestation + run: | + GEM_NAME=$(ls docs-kit-*.gem) + VERSION="${GEM_NAME%.gem}" + VERSION="${VERSION#docs-kit-}" + + if gem info docs-kit --exact --remote 2>/dev/null | grep -q "docs-kit ($VERSION)"; then + echo "::warning::Version $VERSION already published to RubyGems, skipping push" + else + gem exec sigstore-cli sign "$GEM_NAME" \ + --bundle "$GEM_NAME.sigstore.json" + + gem push "$GEM_NAME" --attestation "$GEM_NAME.sigstore.json" + fi + + - name: Upload Sigstore bundle + uses: actions/upload-artifact@v4 + with: + name: sigstore + path: docs-kit-*.gem.sigstore.json + if-no-files-found: error + + upload-release-assets: + needs: [build, publish-rubygems] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v4 + with: + name: gem + + - uses: actions/download-artifact@v4 + with: + name: sigstore + + - name: Upload assets to GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + GEM_NAME="${{ needs.build.outputs.gem_name }}" + gh release upload "${{ github.event.release.tag_name }}" \ + "$GEM_NAME" \ + "$GEM_NAME.sha256" \ + "$GEM_NAME.sha512" \ + "$GEM_NAME.sigstore.json" \ + --repo "${{ github.repository }}" \ + --clobber diff --git a/.rubocop.yml b/.rubocop.yml index 3990e0d..796aafa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -102,6 +102,10 @@ Metrics/BlockLength: - "*.gemspec" # Rails app template — one big after_bundle block by design. - "lib/docs_kit/templates/**/*" + # The `rake release[X.Y.Z]` task is one long sequential release script + # (version bump → build → commit → push → gh release), matching the sibling + # gems (daisyui/phlex-reactive/pgbus). Its length is steps, not complexity. + - "Rakefile" # Generators are procedural wiring — one method per install step — so they're # naturally long and flat, not complex. Configuration is the per-site config diff --git a/CHANGELOG.md b/CHANGELOG.md index 146d622..fa9459a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Added +- Release tooling matching the sibling gems (daisyui/phlex-reactive/pgbus): a + `rake release[X.Y.Z]` task (version bump → lockfile update → build-verify → + commit → push → GitHub Release; `pre`/`force` supported, `main`-only, clean-tree + guard) and `.github/workflows/release.yml`, which on `release: published` runs + the suite, content-checks the built gem, signs it with Sigstore, and publishes + to RubyGems over **OIDC trusted publishing** (no stored API token). See the + README "Releasing (maintainers)" section for the one-time trusted-publisher + + `rubygems` environment setup. - Initial extraction of the shared docs-site chrome into `docs-kit`. - `Docs::*` Phlex component kit: `Shell` (full-document drawer layout), `Sidebar` (config-driven nav), `ThemeSwitcher`, `Icon`, `Code` (Rouge + inline theme), diff --git a/README.md b/README.md index b3d8dc4..c4c7264 100644 --- a/README.md +++ b/README.md @@ -802,6 +802,42 @@ import ReactiveController from "phlex/reactive/reactive_controller" application.register("reactive", ReactiveController) ``` +## Releasing (maintainers) + +Cut a release with the version-bumping Rake task — never `gem push` by hand: + +```bash +rake release[1.0.0] # bump → build-verify → commit → push → GitHub Release +rake release[1.1.0.rc1] # a pre-release (auto-flagged --prerelease) +rake release[1.0.0,force] # delete + re-create an existing tag/release +``` + +The task (on `main`, clean tree only) bumps `lib/docs_kit/version.rb`, updates the +lockfiles (incl. `docs/Gemfile.lock`), verifies `gem build --strict`, commits, +pushes, and creates the GitHub Release. Publishing the tag fires +`.github/workflows/release.yml`, which runs the suite, rebuilds + content-checks +the gem, signs it with Sigstore, and pushes to RubyGems over **OIDC trusted +publishing** (no API token stored anywhere). + +### One-time setup (before the first release) + +Trusted publishing needs two things wired once — the first release fails without +them: + +1. **RubyGems pending trusted publisher.** On [rubygems.org](https://rubygems.org) + → your profile → *Trusted Publishers* → *Create*, add a **pending** publisher + (works for a gem not yet pushed) with: + - Gem name: `docs-kit` + - Repository: `mhenrixon/docs-kit` + - Workflow filename: `release.yml` + - Environment: `rubygems` +2. **GitHub `rubygems` environment.** Repo *Settings → Environments → New + environment* named `rubygems` (the `publish-rubygems` job pins it and requests + `id-token: write`). Add reviewers there if you want a manual gate before a push. + +After the first successful push the pending publisher converts to a normal one; no +further setup is needed for later releases. + ## License MIT. diff --git a/Rakefile b/Rakefile index 24cfc5f..b34f3ad 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "bundler/gem_tasks" require "rspec/core/rake_task" require "rubocop/rake_task" @@ -15,4 +14,159 @@ RuboCop::RakeTask.new do |task| task.patterns = %w[app lib spec Rakefile Gemfile docs-kit.gemspec] end +# Colored status helpers shared by the build/release tasks. +module ReleaseHelpers + def info(msg) = puts "\e[34m→\e[0m #{msg}" + def success(msg) = puts "\e[32m✓\e[0m #{msg}" + def skip(msg) = puts "\e[33m⊘\e[0m #{msg} \e[33m(skipped)\e[0m" + def warn(msg) = puts "\e[33m⚠\e[0m #{msg}" + def error(msg) = puts "\e[31m✗\e[0m #{msg}" + def header(msg) = puts "\n\e[1;36m#{msg}\e[0m\n#{'─' * msg.length}" +end + +desc "Build gem and verify contents" +task :build do + sh("gem build docs-kit.gemspec --strict") + gem_file = Dir["docs-kit-*.gem"].first + abort "Gem file not found after build" unless gem_file + + sh("gem unpack #{gem_file} --target /tmp/gem-verify") + puts "\n=== Gem contents ===" + sh("find /tmp/gem-verify -type f | sort") + sh("rm -rf /tmp/gem-verify #{gem_file}") +end + +desc "Release a new version (rake release[1.2.3] or rake release[pre] or rake release[1.2.3,force])" +task :release, %i[version force] do |_t, args| + include ReleaseHelpers + + require_relative "lib/docs_kit/version" + + new_version = args[:version] + abort "\e[31mUsage: rake release[X.Y.Z] or rake release[X.Y.Z,force]\e[0m" unless new_version + + force = args[:force]&.to_s&.downcase == "force" + + current_branch = `git branch --show-current`.strip + unless current_branch == "main" + abort "\e[31mAborting: must be on main branch to release (currently on #{current_branch})\e[0m" + end + + dirty = `git status --porcelain`.strip + abort "\e[31mAborting: working directory is not clean.\e[0m\n#{dirty}" unless dirty.empty? + + current = DocsKit::VERSION + prerelease = new_version.match?(/alpha|beta|rc|pre/) || new_version == "pre" + + if new_version == "pre" + new_version = current + prerelease = true + end + + tag = "v#{new_version}" + version_file = "lib/docs_kit/version.rb" + + title = "Release #{tag}" + title += " (force)" if force + header title + info "Current version: #{current}" + info "New version: #{new_version}" + info "Pre-release: #{prerelease}" + + # Step 0: Force cleanup — delete existing release and tag + if force + header "Force cleanup" + if system("gh release view #{tag} >/dev/null 2>&1") + sh("gh release delete #{tag} --yes --cleanup-tag") + success "Deleted release and remote tag #{tag}" + else + skip "No release #{tag} to delete" + end + + if system("git rev-parse #{tag} >/dev/null 2>&1") + sh("git tag -d #{tag}") + success "Deleted local tag #{tag}" + else + skip "No local tag #{tag} to delete" + end + end + + # Step 1: Update version file + header "Version" + if new_version == current + skip "Version already #{new_version}" + else + content = File.read(version_file) + content.sub!(/VERSION = ".*"/, "VERSION = \"#{new_version}\"") + File.write(version_file, content) + success "Updated #{version_file}" + end + + # Step 2: Update lockfiles and verify gem builds cleanly. docs/ is the dogfood + # site — bump its lock too so it tracks the new gem version. + header "Build verification" + sh("bundle install --quiet") + success "Gemfile.lock updated" + if File.exist?("docs/Gemfile.lock") + sh("cd docs && bundle install --quiet") + success "docs/Gemfile.lock updated" + end + sh("gem build docs-kit.gemspec --strict") + sh("rm -f docs-kit-*.gem") + success "Gem builds cleanly" + + # Step 3: Commit version bump + header "Git commit" + files_to_stage = [version_file, "Gemfile.lock"] + files_to_stage << "docs/Gemfile.lock" if File.exist?("docs/Gemfile.lock") + version_changed = files_to_stage.any? do |f| + !`git diff #{f}`.strip.empty? || !`git diff --cached #{f}`.strip.empty? + end + if version_changed + sh("git add #{files_to_stage.join(' ')}") + sh("git commit -m 'chore: bump version to #{new_version}'") + success "Committed version bump" + else + skip "No version change to commit" + end + + # Step 4: Push to origin + header "Git push" + local_sha = `git rev-parse HEAD`.strip + remote_sha = `git rev-parse origin/main 2>/dev/null`.strip + if local_sha == remote_sha + skip "origin/main already at #{local_sha[0..6]}" + else + sh("git push origin main") + success "Pushed to origin/main" + end + + # Step 5: Create release (the Release workflow publishes to RubyGems via OIDC + # trusted publishing on `release: published`). + header "Release" + tag_exists = system("git rev-parse #{tag} >/dev/null 2>&1") + release_exists = system("gh release view #{tag} >/dev/null 2>&1") + + if release_exists + skip "Release #{tag} already exists (use force to re-create)" + elsif tag_exists + info "Tag #{tag} exists, creating release from it" + pre_flag = prerelease ? "--prerelease" : "" + sh("gh release create #{tag} --generate-notes #{pre_flag}".strip) + success "Release #{tag} created from existing tag" + else + pre_flag = prerelease ? "--prerelease" : "" + sh("gh release create #{tag} --generate-notes --target main #{pre_flag}".strip) + success "Release #{tag} created" + end + + puts "" + success "\e[1mRelease #{tag} complete!\e[0m CI will handle the rest:" + puts " • Run tests" + puts " • Build + verify gem" + puts " • Sign with Sigstore" + puts " • Publish to RubyGems (trusted publishing)" + puts " • Upload assets to the release" +end + task default: %i[spec rubocop]