diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09f4568..a3daa51 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,23 @@ updates: directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 7 + commit-message: + prefix: "chore(deps)" groups: bundler: patterns: - "*" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + commit-message: + prefix: "chore(deps)" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a1490a..30a942c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ name: CI Test -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read jobs: diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 63b0cc2..bc201fc 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,37 +1,123 @@ +# release-please keeps a release PR open that bumps lib/singed/version.rb, +# singed's line in Gemfile.lock, and CHANGELOG.md from the Conventional Commits +# merged to main. Merging it creates the tag and GitHub release, and the release +# job then publishes the gem to RubyGems.org with trusted publishing (OIDC). To +# retry a failed publish, re-run the failed jobs of that workflow run. +# +# Publishing happens in this run rather than on `release: published`, because a +# release created with GITHUB_TOKEN doesn't trigger other workflows. The filename +# and the rubygems.org environment are what the trusted publisher on +# RubyGems.org is registered against, so don't rename either. name: Push Gem on: - workflow_dispatch: + push: + branches: [main] + +permissions: {} -permissions: - contents: read +concurrency: + group: push-gem + cancel-in-progress: false jobs: - push: + release-please: + name: Update the release PR or create a release if: github.repository == 'rubyatscale/singed' runs-on: ubuntu-latest + permissions: + contents: write # create the release tag and GitHub release + issues: write # create the autorelease labels, which pull-requests: write doesn't allow + pull-requests: write # open and update the release PR + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + prs_created: ${{ steps.release.outputs.prs_created }} + pr: ${{ steps.release.outputs.pr }} + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # release-please pushes the release PR with GITHUB_TOKEN, which doesn't trigger + # workflows, so its required checks would never report. GITHUB_TOKEN can + # trigger workflow_dispatch, and the dispatched runs report on the PR's head + # commit. Each workflow listed here needs a workflow_dispatch trigger. + release-pr-checks: + name: Run CI on the release PR + needs: release-please + if: needs.release-please.outputs.prs_created == 'true' + runs-on: ubuntu-latest + permissions: + actions: write # dispatch the CI workflows + steps: + - name: Dispatch CI on the release branch + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ needs.release-please.outputs.pr }} + run: | + # release-please reports PR number 0 when it found nothing to change. + if [ "$(jq -r .number <<<"$PR")" = "0" ]; then + echo "No release PR changes to check." + exit 0 + fi + branch=$(jq -r .headBranchName <<<"$PR") + for workflow in build.yml rubocop.yml sorbet.yml; do + gh workflow run "$workflow" --ref "$branch" + done + + release: + name: Publish to RubyGems + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest environment: name: rubygems.org url: https://rubygems.org/gems/singed permissions: - contents: write - id-token: write + contents: read + id-token: write # trusted publishing steps: - # Set up - name: Harden Runner - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # Build from the release tag, so a re-run publishes the tagged tree even after main has + # moved. `rake release` only pushes the branch and tag when the tag is missing locally, + # and release-gem fetches tags first, so it never pushes and needs no write access. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + # No bundler cache here: a job that publishes the gem shouldn't restore one. - name: Set up Ruby - uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - bundler-cache: true - ruby-version: ruby + ruby-version: "3.4" + # Frozen, so a Gemfile.lock that disagrees with the gemspec fails here with a clear error, + # rather than being rewritten and failing `rake release`'s clean-tree check. + - run: bundle install + env: + BUNDLE_FROZEN: "true" - # Release - - uses: rubygems/release-gem@9e85cb11501bebc2ae661c1500176316d3987059 # v1 + - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 + + notify_on_failure: + name: Notify on failure + needs: [release-please, release-pr-checks, release] + if: failure() + runs-on: ubuntu-latest + steps: + - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: "${{ github.repository }} Push Gem workflow FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index 655752d..24191e6 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -1,6 +1,7 @@ name: RuboCop -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read diff --git a/.github/workflows/sorbet.yml b/.github/workflows/sorbet.yml index 57d4f55..57448d1 100644 --- a/.github/workflows/sorbet.yml +++ b/.github/workflows/sorbet.yml @@ -1,6 +1,7 @@ name: Sorbet -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml new file mode 100644 index 0000000..1e2b13b --- /dev/null +++ b/.github/workflows/validate-pr-title.yml @@ -0,0 +1,41 @@ +# release-please derives the next version and the changelog from squash-merged +# commit titles, which GitHub takes from the PR title. +name: Validate PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: {} + +concurrency: + group: validate-pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: Check Conventional Commits format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + pattern="^(feat|fix|chore|docs|refactor|perf|test|ci|build|revert)(\([^()]+\))?(!)?: .+" + if ! grep -qE "$pattern" <<<"$PR_TITLE"; then + echo "::error::PR title does not follow Conventional Commits format." + echo "" + echo "Expected: (): " + echo "" + echo "Examples:" + echo " feat: add a Sidekiq middleware option for sampling" + echo " fix: stop flamegraph from swallowing exceptions" + echo " chore(deps): bump stackprof" + echo " revert: feat: add a Sidekiq middleware option for sampling" + echo "" + echo "Allowed types: feat, fix, chore, docs, refactor, perf, test, ci, build, revert" + echo "feat bumps the minor version; fix, perf and revert bump the patch version; the other types don't release." + echo "A ! after the type marks a breaking change, which bumps the minor version until 1.0." + exit 1 + fi + echo "PR title is valid: $PR_TITLE" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0ee8c01 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.3.0" +} diff --git a/AGENTS.md b/AGENTS.md index 64dbbf1..439fe6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,7 @@ bin/tapioca gems - `sorbet-runtime` isn't a dependency of the gem, so nothing under `lib/` may reference `T` at runtime. Use the RBS assertion comments (`#: Type`, `#: as !nil`, `#: as Type`, `#: as untyped`, `#: self as Type`) instead of `T.let`, `T.must`, `T.cast`, `T.unsafe` and `T.bind`. - Apps that use Tapioca still run these signatures: Tapioca rewrites RBS comments into runtime-checked `sig`s while it loads the app. A signature must therefore only name constants that are loaded whenever its file is (not `ActiveSupport` in `lib/singed.rb`), and must accept every value an app can pass while booting, such as `flamegraph [:show, :index]` in a controller. Otherwise the app's `tapioca gem` or `tapioca dsl` run errors. The specs here don't load `sorbet-runtime`, so they can't catch this. - Types the generated gem RBIs are missing go in `sorbet/rbi/shims/`. + +## Pull requests + +PR titles must follow Conventional Commits (`feat: ...`, `fix: ...`, `chore: ...`, with `!` for a breaking change). release-please uses the squash-merged title to pick the next version and write the changelog entry. Don't edit `lib/singed/version.rb` or `CHANGELOG.md` by hand; the release PR does that. diff --git a/README.md b/README.md index 029e2f7..df796f4 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,9 @@ The `open` command is expected to be available. - using [rbspy](https://rbspy.github.io/) directly - using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly + +## Releasing + +Releases are automated with [release-please](https://github.com/googleapis/release-please). PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), because the squash-merged title decides the next version and becomes the changelog entry. For example, `feat: ...` bumps the minor version, `fix: ...` bumps the patch version (as do `perf: ...` and `revert: ...`), and `chore: ...` doesn't release anything. A `!` after the type, or a `BREAKING CHANGE:` footer in the squash message, marks a breaking change, which bumps the minor version until 1.0. A check on each PR flags titles that don't follow the format. + +release-please keeps a release PR open that bumps `lib/singed/version.rb` and `CHANGELOG.md`. Merging it tags the release, creates the GitHub release, and publishes the gem to RubyGems.org with trusted publishing. diff --git a/lib/singed.rb b/lib/singed.rb index e3fa6a5..69c07f4 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -3,6 +3,7 @@ require "json" require "stackprof" +require "singed/version" module Singed # Methods defined with plain `def` below are both module methods (Singed.start) and public diff --git a/lib/singed/version.rb b/lib/singed/version.rb new file mode 100644 index 0000000..f959d92 --- /dev/null +++ b/lib/singed/version.rb @@ -0,0 +1,6 @@ +# typed: strict +# frozen_string_literal: true + +module Singed + VERSION = "0.3.0" +end diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..5d3d83a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "ruby", + "package-name": "singed", + "include-component-in-tag": false, + "changelog-path": "CHANGELOG.md", + "version-file": "lib/singed/version.rb", + "bump-minor-pre-major": true + } + } +} diff --git a/singed.gemspec b/singed.gemspec index 050e805..0e60027 100644 --- a/singed.gemspec +++ b/singed.gemspec @@ -1,9 +1,11 @@ # frozen_string_literal: true +require_relative "lib/singed/version" + Gem::Specification.new do |spec| spec.name = "singed" - spec.version = "0.3.0" + spec.version = Singed::VERSION spec.license = "MIT" spec.authors = ["Josh Nichols"] spec.email = ["josh.nichols@gusto.com"]