diff --git a/.duel.rb b/.duel.rb new file mode 100644 index 00000000..784bb1b8 --- /dev/null +++ b/.duel.rb @@ -0,0 +1,25 @@ +# 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/ (or the benchmarks, this +# file, or the version files), 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 to the Perf workflow too, which decides how the code + # is measured. (Duel already watches lib/, ext/, the benchmarks, this + # file, and the version files.) + c.watch += %w[.github/workflows/perf.yml] + + # When the workflow comments on a pull request with the report: :always, + # :results (passed or failed), :failures (failed, or couldn't compare), + # or :never. + # c.comment = :failures +end diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 00000000..fbb45e65 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,186 @@ +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 + # Whether the Comment job comments (post, change or none), and the + # run's status (passed, failed or neutral), from Duel. + outputs: + comment: ${{ steps.compare.outputs.comment }} + status: ${{ steps.compare.outputs.status }} + 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, writes it to duel.md for the + # comment, and sets the step's outputs to say whether to comment, as + # c.comment in .duel.rb says, whatever the outcome. + - name: Compare + id: 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 new comment on the pull request, when Duel says to: + # always (post), or only when its last comment's status differs from + # this run's (change), so the latest comment is never stale. It never + # edits or deletes a comment. 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 && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }} + 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 }} + COMMENT: ${{ needs.duel.outputs.comment }} + STATUS: ${{ needs.duel.outputs.status }} + run: | + marker="\")" | tail -n 1) + if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then + { echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md" + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + fi diff --git a/.rubocop.yml b/.rubocop.yml index 022bb32c..76623cb9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,8 +15,13 @@ RBS: inherit_mode: merge: - Exclude + # Merge Include too, so .duel.rb (a dotfile) is added to the defaults + - Include AllCops: + # Duel's configuration is a dotfile, which RuboCop skips unless included + Include: + - .duel.rb DisplayCopNames: true # Display the name of the failing cops Exclude: - 'bin/*' diff --git a/.structuredmerge/kettle-jem.yml b/.structuredmerge/kettle-jem.yml index ed5e8d1a..4b40692e 100644 --- a/.structuredmerge/kettle-jem.yml +++ b/.structuredmerge/kettle-jem.yml @@ -363,6 +363,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 diff --git a/Appraisals b/Appraisals index a2ac81c8..316e12d8 100644 --- a/Appraisals +++ b/Appraisals @@ -188,3 +188,10 @@ appraise "templating" do eval_gemfile "modular/templating.gemfile" eval_gemfile "modular/x_std_libs.gemfile" end + +appraise "perf" do + # Performance regression guards, for .github/workflows/perf.yml + # (Duel requires Ruby 3.4 or later) + eval_gemfile "modular/duel.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f915715..f7959d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ Please file a bug if you notice a violation of semantic versioning. ### Added +- kettle-jem-template-20261025-001 - 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 ### Deprecated diff --git a/Gemfile b/Gemfile index 6b083174..88913d93 100644 --- a/Gemfile +++ b/Gemfile @@ -127,6 +127,9 @@ eval_gemfile "gemfiles/modular/changelog.gemfile" # Optional eval_gemfile "gemfiles/modular/optional.gemfile" +# Performance regression guards (Duel requires Ruby 3.4 or later) +eval_gemfile "gemfiles/modular/duel.gemfile" + ### Std Lib Extracted Gems eval_gemfile "gemfiles/modular/x_std_libs.gemfile" diff --git a/Gemfile.lock b/Gemfile.lock index 807f3d02..0cc14617 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,6 +138,8 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) + duel (0.1.1) + fiddle (~> 1.1) emoji_regex (15.0.0) erb (6.0.7) faraday (2.15.0) @@ -146,6 +148,7 @@ GEM logger faraday-net_http (3.5.0) net-http (~> 0.5) + fiddle (1.1.8) gem_bench (2.0.5) bundler (>= 1.14) version_gem (~> 1.1, >= 1.1.4) @@ -506,6 +509,7 @@ GEM hashie (>= 0.1.0, < 6) version_gem (~> 1.1, >= 1.1.16) sorbet-runtime (0.6.13594) + stackprof (0.2.28) standard (1.57.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -633,6 +637,7 @@ DEPENDENCIES bundler-audit (~> 0.9.3) cgi (~> 0.5) debug (>= 1.11.1) + duel (~> 0.1, >= 0.1.1) erb (~> 6.0, >= 6.0.7) gem_bench (~> 2.0, >= 2.0.5) gitmoji-regex (~> 2.0, >= 2.0.15) @@ -662,6 +667,7 @@ DEPENDENCIES rubocop-packaging! rubocop-ruby2_2 (~> 3.0, >= 3.0.7) ruby-progressbar (~> 1.13) + stackprof (~> 0.2, >= 0.2.28) standard (~> 1.57, >= 1.57.0) stone_checksums (~> 1.0, >= 1.0.9) stringio (>= 3.2.0) @@ -720,10 +726,12 @@ CHECKSUMS dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 + duel (0.1.1) sha256=5f8f42f9a9dd1d3f2925dbb8d8f11cb9f5a8ad05d926097c17ac13a4d907d52e emoji_regex (15.0.0) sha256=a508b2c0bee561169564e0aec96085020c900520d335e5533fd8e78e59493829 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36 + fiddle (1.1.8) sha256=7fa8ee3627271497f3add5503acdbc3f40b32f610fc1cf49634f083ef3f32eee gem_bench (2.0.5) sha256=0dc0fb44a5a5eb7b2f5c1c68a5b0164d72007132822c012bac3abe976b199ead geom2d (0.4.1) sha256=ea0998ea90c4f2752e24fe13d85a4f89bee689d151316140ebcc6369bf634ed9 gitmoji-regex (2.0.15) sha256=c6571bffd757f95c06d84a7d2d0b3f180eca7fe576882c371cee1f1a081cd968 @@ -835,6 +843,7 @@ CHECKSUMS simplecov_lcov_formatter (0.9.0) sha256=4c1839bb31dcf7a8f7a6474c94ed228aa03b994e9dd5e1965e58b4121b049bed snaky_hash (2.0.9) sha256=e93e6ec80632f8c8c4a3fe6e25d51eaef253e0e58dc7100f0f8370320feb3128 sorbet-runtime (0.6.13594) sha256=cf26f625a84c73d0d4d151fc19cafa1a6c44ad1814f2454e545893773eddd5e3 + stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 standard (1.57.0) sha256=4a65ea92421fbaf77fb54da91e16093cf79b335b8f62c35aaa3dd714c8592858 standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 diff --git a/LICENSE.md b/LICENSE.md index 67fba766..94237442 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -7,7 +7,7 @@ Choose the option that best fits your use case: ## Copyright Notice -- Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober +- Copyright (c) 2010-2014, 2016-2017 Erik Berlin - Copyright (c) 2010 Jeremy Kemper - Copyright (c) 2010-2011 Michael Bleigh - Copyright (c) 2010-2011 Paul Walker diff --git a/README.md b/README.md index 3804bb85..0f64502c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # 🔐 OAuth2 -[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license] [![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] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] +[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license] [![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] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] [![Duel][⚔️duel-img]][⚔️duel] `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. @@ -773,7 +773,7 @@ See [LICENSE.md][📄license] for the official copyright notice.
Copyright holders -- Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober +- Copyright (c) 2010-2014, 2016-2017 Erik Berlin - Copyright (c) 2010 Jeremy Kemper - Copyright (c) 2010-2011, 2014 Michael Bleigh - Copyright (c) 2010-2011 Paul Walker @@ -1094,6 +1094,8 @@ Thanks for RTFM. ☺️ [📄license-compat]: https://www.apache.org/legal/resolved.html#category-a [📄license-compat-img]: https://img.shields.io/badge/Apache_Compatible:_Category_A-%E2%9C%93-259D6C.svg?style=flat&logo=Apache +[⚔️duel]: https://github.com/sferik/duel-ruby +[⚔️duel-img]: https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg [📄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/oauth2 diff --git a/Rakefile b/Rakefile index 572e89ec..8e0854c6 100644 --- a/Rakefile +++ b/Rakefile @@ -181,6 +181,20 @@ if kettle_jem_selftest_unavailable_note end end +### PERFORMANCE TASKS +# Duel's performance regression guards: rake duel compares this tree with +# the main branch (see .duel.rb and duel/). Duel requires Ruby 3.4 or +# later, so older Rubies get a stub task. +begin + require "duel/rake_task" + Duel::RakeTask.new +rescue LoadError + desc("(stub) duel is unavailable") + task("duel") do + warn("NOTE: duel isn't installed, or is disabled for #{RUBY_VERSION} in the current environment") + end +end + ### RELEASE TASKS # Setup stone_checksums begin diff --git a/docs/file.LICENSE.html b/docs/file.LICENSE.html index 0ae3cd4c..6b5b130a 100644 --- a/docs/file.LICENSE.html +++ b/docs/file.LICENSE.html @@ -71,7 +71,7 @@