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
34 changes: 34 additions & 0 deletions .duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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*.
Duel.configure do |c|
# The library each version's workers require (from the gemspec, unless
# this says otherwise).
# c.library = "http/form_data"

# Compare a change to only the Perf workflow too.
c.watch += %w[.github/workflows/perf.yml]

# A regression fails a pull request (or a merge queue), but in a push
# to the master branch, the schedule, or a run by hand, it only warns,
# and the workflow comments on the pushed commit with the report,
# which notifies its author.
c.fail_on = :pull_requests

# A pull request with this label passes with a regression, which the
# report says the label accepted (after adding the label, run the
# workflow again).
c.accept_label = "duel: accept"

# A push is compared with the last commit Duel passed, which a branch
# marks (duel/passed/master for master), not the commit before it, so
# slowdowns too small for any one push to fail add up until they fail
# one. To accept a regression after it's pushed, move that branch:
# git push origin master:duel/passed/master.
c.push_base = :last_passed
end
59 changes: 59 additions & 0 deletions .github/workflows/perf-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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. 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, and of what the Duel job kept (where that code
# ran), it trusts only what that job could have said: 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. (The workflow must be on the
# default branch before it runs at all.)
on:
workflow_run:
workflows: ["Perf"]
types: [completed]

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 }}
- 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: |
said() { grep -xE "$1=($2)" "$RUNNER_TEMP/report/comment.env" | tail -n 1 | cut -d= -f2; }
PR=$(said pr '[0-9]+')
COMMENT=$(said comment 'post|change')
STATUS=$(said status 'passed|failed|neutral')
[ -n "$PR" ] && [ -n "$COMMENT" ] && [ -n "$STATUS" ] || exit 0
[ "$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha)" = "$HEAD_SHA" ] || exit 0
marker="<!-- duel"
# The first line of the last comment Duel posted here: its marker,
# with its status (none in Duel 0.1.0's, which counts as another).
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$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
169 changes: 169 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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)

env:
BUNDLE_WITHOUT: "sig"

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
# (And whatever else your tests need set up: packages, services.)
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
# 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, so after adding the label, run it again.
- name: Find the pull request's labels
id: labels
if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push'
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 }}
run: |
# (In a merge queue, the last pull request queued, which the queue's
# branch names: gh-readonly-queue/main/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"
# 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 }}
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

# The report, as a new comment on a pushed commit, which notifies its
# author, when Duel says to, as for a pull request (see the Perf comment
# workflow). It does only where a regression in a push passes, with
# c.fail_on = :pull_requests in .duel.rb, so the comment says what the
# check doesn't. Its last comment is the last one on any commit. This
# job checks nothing out and runs none of the code.
commit-comment:
name: Comment on the commit
needs: duel
if: ${{ !cancelled() && github.event_name == 'push' && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }}
runs-on: ubuntu-latest
permissions:
contents: write # to comment on the commit 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
- name: Comment on the commit
if: steps.report.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
COMMENT: ${{ needs.duel.outputs.comment }}
STATUS: ${{ needs.duel.outputs.status }}
run: |
marker="<!-- duel"
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\"))" | 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/commits/$GITHUB_SHA/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
fi

# With c.push_base = :last_passed in .duel.rb, a push is compared with
# the last commit Duel passed, which a branch marks (each branch pushed
# has its own), so slowdowns too small for any one push to fail add up
# until they fail one. This job moves the branch to a push that passed
# (or makes it). It checks nothing out and runs none of the code. To
# accept a regression, move the branch yourself (for main, git push
# origin main:duel/passed/main).
passed:
name: Mark the commit passed
needs: duel
if: ${{ !cancelled() && github.event_name == 'push' && needs.duel.outputs.passed == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write # to move the branch
steps:
- name: Move the branch to the commit
env:
GH_TOKEN: ${{ github.token }}
BRANCH: duel/passed/${{ github.ref_name }}
run: |
gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/heads/$BRANCH" -f sha="$GITHUB_SHA" -F force=true > /dev/null 2>&1 ||
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$GITHUB_SHA" > /dev/null
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ inherit_from:
- .rubocop/metrics.yml
- .rubocop/style.yml

inherit_mode:
merge:
- Include

AllCops:
Include:
- .duel.rb
DisplayCopNames: true
DisplayStyleGuide: true
NewCops: enable
Expand Down
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ source "https://rubygems.org"

gem "rake"

group :development do
# Duel requires Ruby 3.4 or later
gem "duel", ">= 0.1.2" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4")
gem "stackprof", platforms: :mri
end

group :test do
gem "minitest", "~> 6.0"

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[![Type Check](https://github.com/httprb/form_data/actions/workflows/typecheck.yml/badge.svg)](https://github.com/httprb/form_data/actions/workflows/typecheck.yml)
[![Mutant](https://github.com/httprb/form_data/actions/workflows/mutant.yml/badge.svg)](https://github.com/httprb/form_data/actions/workflows/mutant.yml)
[![Docs](https://github.com/httprb/form_data/actions/workflows/docs.yml/badge.svg)](https://github.com/httprb/form_data/actions/workflows/docs.yml)
[![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)

Utility-belt to build form data request bodies.

Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@ task :mutant do
end

task default: %i[test rubocop verify_measurements steep]

begin
require "duel/rake_task"
Duel::RakeTask.new
rescue LoadError
# Duel requires Ruby 3.4 or later
end
54 changes: 54 additions & 0 deletions duel/duel_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

require "pathname" # (FormData::File checks for a Pathname, but doesn't require it)
require "stringio"

# A fixed boundary, so that every iteration (and both versions) builds
# the same body. Multipart.generate_boundary would make a random one.
BOUNDARY = ("-" * 21) + ("0123456789abcdef" * 3)[0, 42]

# http.rb streams a body with IO.copy_stream, which reads it 16 KB at a
# time into one buffer (HTTP::Connection::BUFFER_SIZE).
CHUNK_SIZE = 16_384

# A login form's fields.
def small_params
{ "username" => "ixti", "password" => "s3cr3t p@ss", "remember" => "1" }
end

# A form of `count` fields, with values that need escaping.
def large_params(count)
Array.new(count) { |i| ["field_#{i}", "value #{i} & more/stuff=#{'x' * (i % 17)}"] }.to_h
end

# Nested hashes and arrays, as Rails-style params.
NESTED_PARAMS = {
"user" => {
"name" => "Aleksey Zapparov",
"email" => "ixti@example.com",
"address" => { "street" => "1 Main St", "city" => "Springfield", "zip" => "12345" },
"roles" => %w[admin editor viewer],
"preferences" => { "theme" => "dark", "languages" => %w[en ru uk], "notify" => nil }
},
"items" => Array.new(20) { |i| { "id" => i.to_s, "qty" => (i * 3).to_s, "tags" => %w[a b] } },
"q" => "search terms with spaces & symbols"
}.freeze

# `size` bytes of binary data, the same every time.
def binary_data(size)
Random.new(42).bytes(size)
end

# A file part from memory, with its filename and content type given, so
# that its name doesn't come from the IO's object_id.
def file_part(data, filename: "upload.bin", content_type: "application/octet-stream")
HTTP::FormData::File.new(StringIO.new(data), filename: filename, content_type: content_type)
end

# Reads the whole body as http.rb does, 16 KB at a time into one buffer.
def read_in_chunks(body, buffer)
body.rewind
bytes = 0
bytes += buffer.bytesize while body.read(CHUNK_SIZE, buffer)
bytes
end
Loading
Loading