Skip to content
Open
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
35 changes: 35 additions & 0 deletions .duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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, or
# this file), and releases are tagged v*.
Duel.configure do |c|
# The library each version's workers require (from the gemspec, unless
# this says otherwise).
# c.library = 'grape'

# 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

# Which runs a regression fails: :every_run, or :pull_requests, for a
# project where people push to master directly (on GitHub Actions, another
# run warns and passes, and a pushed commit gets the comment instead, from
# a workflow job written only where this is set: see docs/ci.md).
# c.fail_on = :every_run

# A label that accepts a regression in a pull request, for a change
# that's slower on purpose (nil for none).
c.accept_label = 'duel: accept'

# What the workflow compares a push with: :before (the commit before it),
# or :last_passed (the last commit Duel passed, which a branch,
# duel/passed/master, marks), so small slowdowns add up until they fail.
# (A workflow job moves the branch, written only where this is set: see
# docs/ci.md.)
# c.push_base = :before
end
92 changes: 92 additions & 0 deletions .github/workflows/perf-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Perf comment

# Duel's 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 the
# run's (change), so the latest comment is never stale. It never edits or
# deletes a comment, but hides Duel's earlier ones as outdated, so the
# newest is the one in view. It runs once the Perf workflow has, with
# this repository's own workflow and a token that can write, so a pull
# request from a fork gets a comment too. It checks nothing out and runs
# none of the pull request's code, only `duel comment` (see Duel's
# docs/ci.md), which trusts only what the Duel job could have said in
# what it kept: the pull request's number, if it names the pull request
# the run compared, and the comment and the status, if they're ones Duel
# says. Its report, a fork's too, is the comment, as Markdown.
#
# And when the label that accepts a regression (c.accept_label in
# .duel.rb) is added to a pull request, or taken off, it runs the Duel
# job again, which reads the labels as it runs: a run that failed, once
# the label's added, or one that passed, once it's taken off.
#
# (The workflow must be on the default branch before it runs at all.)
on:
workflow_run:
workflows: ["Perf"]
types: [completed]
# (This repository's own workflow, as for the comment, with a token
# that can run a workflow again: it checks nothing out, and runs none
# of the pull request's code.)
pull_request_target:
types: [labeled, unlabeled]

permissions: {}

jobs:
comment:
name: Comment
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }}
runs-on: ubuntu-latest
permissions:
actions: read # to get the report from the Perf run
pull-requests: write # to comment on the pull request with the report
steps:
- name: Get the report
id: report
continue-on-error: true # there's none when the Duel job failed before comparing
uses: actions/download-artifact@v8
with:
name: duel-report
path: ${{ runner.temp }}/report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- uses: ruby/setup-ruby@v1
if: steps.report.outcome == 'success'
with:
ruby-version: ruby
# Duel's own code, from RubyGems (none of the pull request's), so a
# fix to how it comments comes with a release of Duel.
- name: Comment on the pull request
if: steps.report.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
gem install duel --version '~> 0.1.8' --no-document
duel comment --report "$RUNNER_TEMP/report" --head "$HEAD_SHA" --marker 'duel'

again:
name: Run Duel again
if: >-
github.event_name == 'pull_request_target' && github.event.label.name == 'duel: accept'
runs-on: ubuntu-latest
permissions:
actions: write # to run the Perf workflow's jobs again
steps:
- name: Run Duel again
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
ACTION: ${{ github.event.action }}
run: |
# The newest Perf run of the pull request's head commit, once it's
# done (one that's running may have read the labels already).
id=$(gh run list --workflow "Perf" --event pull_request --commit "$HEAD_SHA" --limit 1 \
--json databaseId --jq '.[0].databaseId // empty')
[ -n "$id" ] || exit 0
gh run watch "$id" --interval 30 > /dev/null || true
conclusion=$(gh run view "$id" --json conclusion --jq .conclusion)
case "$ACTION:$conclusion" in
labeled:failure) gh run rerun "$id" --failed ;;
unlabeled:success) gh run rerun "$id" ;;
esac
122 changes: 122 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: Perf

on:
pull_request:
merge_group: # (a merge queue's)
push:
branches: [master] # (and any other branch to guard, a stable one, say)
schedule:
- cron: "0 4 * * *"
workflow_dispatch:

# A pull request's newer push cancels its run, if that's still going, as
# only its newest commit's result counts. Every other run (a push's, say)
# runs to the end, so that each push is compared.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# The Duel job runs the branch's code (its benchmarks too), so it only
# reads. Commenting on a commit is the Comment on the commit job's, which
# runs none of it, and commenting on a pull request is the Perf comment
# workflow's.
permissions:
contents: read
pull-requests: read # for the pull request's labels (see c.accept_label)

jobs:
duel:
name: Duel
runs-on: ubuntu-latest
# Whether to comment (post, change or none), the run's status (passed,
# failed or neutral), and whether to mark a push passed (true, or
# nothing), from Duel.
outputs:
comment: ${{ steps.compare.outputs.comment }}
status: ${{ steps.compare.outputs.status }}
passed: ${{ steps.compare.outputs.passed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the baseline comes from history, tags included
persist-credentials: false
# The labels of the pull request (or for a push, of the one that
# merged it), for c.accept_label in .duel.rb, as a JSON array. They're
# read as the job runs, and adding the label (or taking it off) runs
# the job again: see the Perf comment workflow.
# On a schedule, the last commit since the last release that merged a
# pull request with the label (ACCEPT_LABEL, as .duel.rb names it):
# what that slowed down was accepted, so it's compared with instead.
- name: Find the pull request's labels
id: labels
if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' || github.event_name == 'schedule'
continue-on-error: true # (without them, nothing's accepted)
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
QUEUED: ${{ github.event.merge_group.head_ref }}
ACCEPT_LABEL: 'duel: accept'
RELEASE_TAGS: "v*"
run: |
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
tag=$(git describe --tags --abbrev=0 --match "$RELEASE_TAGS" HEAD^ 2>/dev/null) || exit 0
since=$(TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%cd "$tag")
merged=$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged --label "$ACCEPT_LABEL" --search "merged:>=$since" --limit 1000 \
--json mergeCommit --jq '.[].mergeCommit.oid')
accepted=$(git rev-list "$tag..HEAD" | grep -Fx -m 1 -f <(echo "$merged")) || accepted=
echo "accepted=$accepted" >> "$GITHUB_OUTPUT"
exit 0
fi
# (In a merge queue, the last pull request queued, which the queue's
# branch names: gh-readonly-queue/master/pr-123-<sha>.)
if [ -n "$QUEUED" ]; then
PR=${QUEUED##*/pr-}
PR=${PR%%-*}
fi
if [ -n "$PR" ]; then
labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/labels" --jq '[.[].name]')
else
labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[0].labels[]?.name]')
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
- uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0" # the newest Ruby, which those who care most about speed run
bundler-cache: true
# Against the branch it targets for a pull request, the queue's base
# for a merge queue, what was there before for a push (or as
# c.push_base in .duel.rb says, the last commit Duel passed), 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 || github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }}
# (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' || '' }}
DUEL_LABELS: ${{ steps.labels.outputs.labels }}
DUEL_ACCEPTED: ${{ steps.labels.outputs.accepted }}
run: bundle exec duel --markdown "$RUNNER_TEMP/duel/duel.md"
# For a pull request, what Duel said, for the Perf comment workflow,
# which can't see this job's outputs.
- name: Say whether to comment
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
PR: ${{ github.event.pull_request.number }}
COMMENT: ${{ steps.compare.outputs.comment }}
STATUS: ${{ steps.compare.outputs.status }}
run: |
mkdir -p "$RUNNER_TEMP/duel"
printf 'pr=%s\ncomment=%s\nstatus=%s\n' "$PR" "$COMMENT" "$STATUS" > "$RUNNER_TEMP/duel/comment.env"
# The report, for the comments.
- name: Keep the report
if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
uses: actions/upload-artifact@v7
with:
name: duel-report
path: ${{ runner.temp }}/duel/
if-no-files-found: ignore # none when Duel didn't get to compare
retention-days: 1
10 changes: 10 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ AllCops:
NewCops: enable
TargetRubyVersion: 3.3
SuggestExtensions: false
Include:
- .duel.rb
Exclude:
- vendor/**/*
- bin/**/*
- benchmark/**/*
- tmp/**/*

inherit_mode:
merge:
- Include

plugins:
- rubocop-exception_messages
- rubocop-performance
Expand All @@ -32,6 +38,10 @@ Style/MultilineIfModifier:
Style/MutableConstant:
Recursive: true

Style/OneClassPerFile:
Exclude:
- duel/**/*.rb

Style/RaiseArgs:
Enabled: false

Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ end
group :development do
gem 'benchmark-ips'
gem 'benchmark-memory'
# Duel requires Ruby 3.4 or later
gem 'duel', '~> 0.1.8' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.4')
gem 'irb'
gem 'stackprof', platforms: :mri
end

group :test do
Expand Down
6 changes: 6 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ require('rubocop/rake_task')
RuboCop::RakeTask.new

task(default: %i[rubocop spec])

# Duel requires Ruby 3.4 or later
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.4')
require('duel/rake_task')
Duel::RakeTask.new
end
67 changes: 67 additions & 0 deletions duel/callbacks_duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

require_relative 'duel_helper'

# Callbacks and helpers: an endpoint runs the before, validation, after
# and finally callbacks of every namespace it's in, and its block calls
# helpers, reads headers, and sets the status, headers and cookies.

module AuthHelpers
def current_user
@current_user ||= { id: 1, name: 'Ada', token: headers['Authorization'].to_s.delete_prefix('Bearer ') }
end

def authenticate!
error!('Unauthorized', 401) unless current_user[:token] == 's3cret'
end
end

class CallbacksAPI < Grape::API
format :json
helpers AuthHelpers

helpers do
def request_id
headers['X-Request-Id'] || 'none'
end

params :pagination do
optional :page, type: Integer, default: 1
optional :per_page, type: Integer, default: 25
end
end

before { header 'X-Request-Id', request_id }
before { authenticate! }
after { header 'X-Runtime', '0.001' }
finally { @finished = true }

namespace :admin do
before { header 'X-Admin', 'true' }
before_validation { @validating = true }
after_validation { @validated = true }
after { header 'Cache-Control', 'no-store' }

params do
use :pagination
end
get :reports do
status 200
header 'X-Total-Count', '3'
cookies[:last_seen] = { value: 'reports', path: '/admin', httponly: true }
{ user: current_user[:name], page: params[:page], request_id: }
end
end
end

AUTHORIZED = { 'Authorization' => 'Bearer s3cret', 'X-Request-Id' => '0f8fad5b-d9cb-469f-a165-70867728950e' }.freeze

duel 'callbacks: before, validation, after and finally callbacks, with helpers' do |n|
once { prepare(CallbacksAPI, rack_env('/admin/reports?page=2', headers: AUTHORIZED), 200, 'Ada') }
serve(@app, @env, n)
end

duel 'callbacks: a before callback that halts with error!' do |n|
once { prepare(CallbacksAPI, rack_env('/admin/reports', headers: { 'Authorization' => 'Bearer wrong' }), 401, 'Unauthorized') }
serve(@app, @env, n)
end
Loading