Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gems/kettle-jem/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Please file a bug if you notice a violation of semantic versioning.
### Added

- kettle-jem: corporate sponsor configuration now accepts the explicit target_href and image_src keys alongside legacy url and img_src, preserving existing configs while supporting direct, date-bounded static sponsors.
- kettle-jem: opt-in Duel performance regression guards (https://github.com/sferik/duel-ruby), off by default. With `workflows.duel: true`, the full template generates the Perf workflow (`.github/workflows/perf.yml`), `gemfiles/modular/duel.gemfile` (Duel and StackProf on Ruby 3.4 or later only), a `perf` appraisal, the root Gemfile wiring, a `rake duel` task with a stub for older Rubies, the RuboCop include for `.duel.rb`, and the Duel README badge. It creates `.duel.rb` only when it is missing. While Duel is off, a project's own `.duel.rb`, `perf.yml`, and `duel.gemfile` are neither written nor removed.
- kettle-jem: `actions/download-artifact` joins the template workflow pins, for the Perf workflow's Comment job.

### Changed

Expand Down
33 changes: 33 additions & 0 deletions gems/kettle-jem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,39 @@ workflows:
Do not use freeze markers for any of these declarations. The configuration is
the source of truth, and templating keeps the root bundle synchronized with it.

### Duel Performance Regression Guards

[Duel](https://github.com/sferik/duel-ruby) runs a gem's benchmarks on the
baseline and on the change side by side, and fails if the change is slower.
It is off by default. To opt in, set:

```yaml
workflows:
duel: true
```

With Duel on, the full template generates:

| File | What it holds |
|------|---------------|
| `.github/workflows/perf.yml` | A read-only Duel job that compares each pull request with the main branch, each push to `main` with the commit before it, and, on a schedule, the last release. A separate Comment job, with only `pull-requests: write` and none of the branch's code, posts the report on the pull request. When the baseline's version file names another Ruby, the workflow installs that Ruby too. |
| `gemfiles/modular/duel.gemfile` | Duel and StackProf, on Ruby 3.4 or later only, so older Rubies install neither. |
| `Appraisals` | A `perf` appraisal, whose generated `gemfiles/perf.gemfile` the Perf workflow bundles. |
| `Gemfile` | `eval_gemfile "gemfiles/modular/duel.gemfile"`, for `rake duel` in local development. |
| `Rakefile` | `rake duel`, with a stub task that only warns where Duel isn't installed. |
| `.rubocop.yml` | `.duel.rb` in `AllCops/Include` (RuboCop skips dotfiles otherwise), merged with RuboCop's own patterns. |
| `README.md` | The Duel badge, at the end of the badge row. |
| `.duel.rb` | Duel's configuration, created only when it is missing. It runs each version on the Ruby that its own version file names, and watches the benchmarks, `.duel.rb`, `mise.toml`, and the Perf workflow as well as `lib/` and `ext/`. After that it is the project's own. |

The benchmarks in `duel/` are the project's to write: see
[Write a benchmark](https://github.com/sferik/duel-ruby#write-a-benchmark).
The template has already done what `duel init` would, so there is no need to
run it.

While Duel is off, kettle-jem neither writes nor removes `.duel.rb`,
`.github/workflows/perf.yml`, or `gemfiles/modular/duel.gemfile`, so a
project that has its own keeps them as they are.

### Strategies

| Strategy | Behavior |
Expand Down
222 changes: 207 additions & 15 deletions gems/kettle-jem/lib/kettle/jem.rb

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions gems/kettle-jem/lib/kettle/jem/templates/.duel.rb.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md
#
# Duel follows conventions, so set only what differs: benchmarks are in
# duel/**/*_duel.rb, each version's lib/ is first on the load path, a
# change is compared when it touches lib/ or ext/, and releases are
# tagged v*.
#
# kettle-jem creates this file when it is missing, and leaves it alone
# after that, so it is this project's to change.
Duel.configure do |c|
# Each version runs on the Ruby that its own version file names, so a
# branch that upgrades Ruby is compared on each version's own Ruby.
c.ruby = :version_file
# Compare a change that touches what's measured or how, not only lib/ and
# ext/: the benchmarks and this file, the Ruby that mise.toml names, and
# the Perf workflow. (Each version runs the candidate's benchmarks.)
c.watch = %w[lib ext duel .duel.rb mise.toml .github/workflows/perf.yml]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: Perf

# Performance regression guards: Duel runs the benchmarks in duel/ on the
# baseline and on this code side by side, and fails if this code is slower.
# See .duel.rb and https://github.com/sferik/duel-ruby

# The Duel job runs this branch's code, so it only reads. Commenting is a
# job of its own, which runs none of it.
permissions:
contents: read

on:
pull_request:
push:
branches:
- 'main'
schedule:
- cron: "0 4 * * *"
# Allow manually triggering the workflow.
workflow_dispatch:

# Cancels a pull request's previous runs that have not yet completed. Every
# other run is a group of its own, so none is cancelled: each push to main
# is compared with the commit before it, and a cancelled run would skip
# that comparison.
concurrency:
group: "${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}"
cancel-in-progress: true

jobs:
duel:
if: "github.event_name != 'push' || (!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]'))"
name: Duel
runs-on: ubuntu-latest
env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps
# The perf appraisal: the gemspec's dependencies and Duel (Ruby 3.4 or later)
BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/perf.gemfile
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # the baseline comes from history, tags included
persist-credentials: false

# .duel.rb sets c.ruby = :version_file, so each version runs on the Ruby
# that its own version file names (.ruby-version, .tool-versions, or
# mise.toml, the first that names one, as Duel reads them). When the
# baseline's names another Ruby than this one's, install that Ruby too,
# and link it where Duel finds it. The baseline is the one the Compare
# step uses: the main branch for a pull request, the commit before a
# push, the last release on a schedule (the newest v* tag before HEAD,
# as Duel finds it), and where HEAD left the default branch when run by
# hand.
- name: Find the baseline's Ruby
id: baseline
env:
BEFORE: ${{ github.event.before }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
case "$GITHUB_EVENT_NAME" in
pull_request) base="origin/$GITHUB_BASE_REF" ;;
push) base="$BEFORE" ;;
schedule) base=$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null) || base= ;;
*) base=$(git merge-base HEAD "origin/$DEFAULT_BRANCH" 2>/dev/null) || base= ;;
esac
ruby_of() {
for file in .ruby-version .tool-versions mise.toml .mise.toml; do
text=$(git show "$1:$file" 2>/dev/null) || continue
case "$file" in
.ruby-version) version=$(printf '%s\n' "$text" | awk 'NF { print $1; exit }') ;;
.tool-versions) version=$(printf '%s\n' "$text" | awk '$1 == "ruby" { print $2; exit }') ;;
*) version=$(printf '%s\n' "$text" | awk '/^\[/ { tools = ($0 ~ /^\[tools\]/) } tools && match($0, /^[ \t]*"?ruby"?[ \t]*=[ \t]*[[{]?[ \t]*(version[ \t]*=[ \t]*)?"[^"]+"/) { s = substr($0, RSTART, RLENGTH); sub(/"$/, "", s); sub(/.*"/, "", s); print s; exit }') ;;
esac
if [ -n "$version" ]; then
echo "$version"
return
fi
done
}
ruby=
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
ruby=$(ruby_of "$base")
[ "$ruby" = "$(ruby_of HEAD)" ] && ruby=
fi
echo "ruby=$ruby" >> "$GITHUB_OUTPUT"

- name: Setup the baseline's Ruby
id: baseline-ruby
if: steps.baseline.outputs.ruby != ''
uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
with:
ruby-version: ${{ steps.baseline.outputs.ruby }}
# The bundle for this Ruby too, which the baseline's workers load.
# (Gems installed for one Ruby version don't load on another.)
bundler-cache: true

- name: Link the baseline's Ruby where Duel finds it
if: steps.baseline.outputs.ruby != ''
env:
PREFIX: ${{ steps.baseline-ruby.outputs.ruby-prefix }}
run: |
mkdir -p ~/.rubies
ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')"

# The candidate's Ruby, from its version file (the same one Duel reads)
- name: Setup Ruby & RubyGems
uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
with:
bundler-cache: true

# Against the main branch for a pull request, what was there before
# for a push, and the last release on a schedule (slow creep). Duel
# adds its report to the job's summary, and writes it to duel.md for
# the comment.
- name: Compare
env:
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
# (Run by hand, it compares anyway: with nothing changed, the code
# with itself, which checks the setup.)
DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
run: |
# (A branch's first push, or one that rewrote history, has no
# commit before it here to compare against.)
if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
echo "::notice::No commit before this push to compare against"
exit 0
fi
bundle exec duel --markdown "$RUNNER_TEMP/duel.md"

# The report, for the Comment job
- name: Keep the report
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: duel-report
path: ${{ runner.temp }}/duel.md
if-no-files-found: ignore # none when the comparison didn't run
retention-days: 1

# The report, as a comment on the pull request: one comment, updated on
# every push. This job checks nothing out and runs none of the branch's
# code, so it alone can write. (A pull request from a fork gets no
# comment, as its token can't write one: the Duel job's summary has the
# report.)
comment:
name: Comment
needs: duel
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
permissions:
pull-requests: write # to comment on the pull request with the report
steps:
- name: Get the report
id: report
continue-on-error: true # the Duel job keeps no report when it fails before comparing
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: duel-report
path: ${{ runner.temp }}/report

- name: Comment on the pull request
if: steps.report.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
marker="<!-- duel -->"
{ echo "$marker"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
if [ -n "$ids" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
else
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
fi
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,17 @@ workflows:
# Include VCR/WebMock recording modular gemfiles in generated Appraisals.
# Enable only for projects that actually require recording support.
recording: false
# Duel performance regression guards (https://github.com/sferik/duel-ruby).
# Off by default. Set `duel: true` to generate the Perf workflow
# (.github/workflows/perf.yml), gemfiles/modular/duel.gemfile (loaded by
# the root Gemfile and a `perf` appraisal), the `rake duel` task, the
# RuboCop include for .duel.rb, and the Duel README badge. .duel.rb is
# created only when it is missing, and the benchmarks in duel/ are the
# project's own to write. Duel requires Ruby 3.4 or later: on older
# Rubies it is not installed, and `rake duel` only warns. While Duel is
# off, kettle-jem neither writes nor removes .duel.rb, perf.yml, or
# duel.gemfile, so a project's own copies stay as they are.
# duel: true
# preset: framework
# framework_matrix:
# dimension: activerecord
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Available fields are `profile`, `topology`, `ruby.min`, `engine.jruby`,
`feature.rubyforum`, `feature.rubyforum_project_tag`,
`feature.corporate_sponsors`, `feature.organization_logo`,
`feature.structuredmerge_driver`, `feature.dedicated_version_gem`,
`workflow.dep_heads`, `workflow.jruby_94`, and `version_gem.mode`. Boolean
`workflow.dep_heads`, `workflow.jruby_94`, `workflow.duel`, and
`version_gem.mode`. Boolean
fields accept `true` or `false`; `ruby.min` accepts normal version comparisons;
the other fields accept `=` or `!=`.

Expand All @@ -46,6 +47,10 @@ the other fields accept `=` or `!=`.
`engines:` and has no template-managed modular home (e.g. `sqlite3` on
`jruby`). Review each entry and pick a resolution per the project's own
engine support needs.
- kettle-jem-template-20261025-001 [if workflow.duel=true] - Duel performance regression guards: the
Perf workflow compares each pull request, each push to main, and, on a
schedule, the last release, and `rake duel` compares local changes, with
the benchmarks in `duel/`.

## Changed

Expand Down
3 changes: 2 additions & 1 deletion gems/kettle-jem/lib/kettle/jem/templates/README.md.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# {KJ|PROJECT_EMOJI} {KJ|README:TITLE}

[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] {KJ|README:LICENSE_BADGE} [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] {KJ|README:LICENSE_EYE_WORKFLOW_BADGE} {KJ|README:FOSSA_BADGE}
[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] {KJ|README:LICENSE_BADGE} [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] {KJ|README:LICENSE_EYE_WORKFLOW_BADGE} {KJ|README:FOSSA_BADGE} {KJ|README:DUEL_BADGE}

`if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know on Discord][✉️discord-invite] or [RubyForum][✉️ruby-forum], as I may have missed the notification.

Expand Down Expand Up @@ -511,6 +511,7 @@ Thanks for RTFM. ☺️
[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
{KJ|README:LICENSE_REFS}
{KJ|README:FOSSA_REFS}
{KJ|README:DUEL_REFS}
[📄ilo-declaration]: https://www.ilo.org/declaration/lang--en/index.htm
[📄ilo-declaration-img]: https://img.shields.io/badge/ILO_Fundamental_Principles-✓-259D6C.svg?style=flat
[🚎yard-current]: http://rubydoc.info/gems/{KJ|GEM_NAME}
Expand Down
1 change: 1 addition & 0 deletions gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ if kettle_jem_selftest_unavailable_note
end
end

{KJ|RAKE:DUEL_TASK}
### RELEASE TASKS
# Setup stone_checksums
begin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

# To retain during kettle-jem templating:
# {KJ|FREEZE_TOKEN}:freeze
# # ... your code
# {KJ|FREEZE_TOKEN}:unfreeze
#

# Performance regression guards (see .duel.rb and duel/)
# Duel requires Ruby 3.4 or later, so older Rubies install neither gem.
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4")
gem "duel", "~> 0.1", ">= 0.1.0" # ruby >= 3.4

# For profiles of regressions (StackProf runs on CRuby only)
platform :mri do
gem "stackprof", "~> 0.2", ">= 0.2.28"
end
end
Loading