duel 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/README.md +20 -2
- data/docs/ci.md +495 -61
- data/docs/configuration.md +80 -0
- data/docs/how-it-works.md +4 -1
- data/lib/duel/baseline.rb +30 -4
- data/lib/duel/cli.rb +170 -28
- data/lib/duel/config.rb +71 -0
- data/lib/duel/init.rb +41 -7
- data/lib/duel/runner.rb +1 -1
- data/lib/duel/summary.rb +5 -3
- data/lib/duel/templates/duel.gitlab-ci.yml.erb +148 -0
- data/lib/duel/templates/duel.rb.erb +13 -0
- data/lib/duel/templates/perf-comment.yml.erb +59 -0
- data/lib/duel/templates/perf.yml.erb +116 -46
- data/lib/duel/version.rb +1 -1
- data/sig/duel/baseline.rbs +14 -1
- data/sig/duel/cli.rbs +64 -2
- data/sig/duel/config.rbs +36 -0
- data/sig/duel/init.rbs +13 -0
- data/sig/duel/runner.rbs +15 -3
- data/sig/duel/summary.rbs +3 -1
- metadata +3 -1
data/lib/duel/runner.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Duel
|
|
|
30
30
|
# How a run is set up (see Duel::CLI for where each comes from).
|
|
31
31
|
# @api private
|
|
32
32
|
Settings = Struct.new(:base, :base_dir, :candidate_dir, :filter, :jobs, :processes, :limit, :confidence, :warm, :force,
|
|
33
|
-
:config_path, :timeout, :ruby, :comment) do
|
|
33
|
+
:config_path, :timeout, :ruby, :comment, :fail_on, :pushed, :target) do
|
|
34
34
|
# @type self: singleton(Settings)
|
|
35
35
|
def self.defaults = new(filter: //, jobs: 1, processes: 3, limit: 1.02, confidence: 0.99, warm: nil, force: false,
|
|
36
36
|
timeout: 60.0)
|
data/lib/duel/summary.rb
CHANGED
|
@@ -90,8 +90,10 @@ module Duel
|
|
|
90
90
|
|
|
91
91
|
# +runner+ ran, and made +results+, comparing +baseline+ with
|
|
92
92
|
# +candidate+ (each a description, in Markdown, whose commit stands
|
|
93
|
-
# bare, so GitHub links it) as +settings+ say
|
|
94
|
-
|
|
93
|
+
# bare, so GitHub links it) as +settings+ say, with a +note+ (in
|
|
94
|
+
# Markdown) under the heading, if there's one.
|
|
95
|
+
def initialize(runner, results, settings:, baseline:, candidate:, note: nil)
|
|
96
|
+
@note = note
|
|
95
97
|
@runner = runner
|
|
96
98
|
@results = results
|
|
97
99
|
@settings = settings
|
|
@@ -114,7 +116,7 @@ module Duel
|
|
|
114
116
|
|
|
115
117
|
# The Markdown, with the first +explained+ regressions explained.
|
|
116
118
|
def render(explained)
|
|
117
|
-
[heading, compared, failures, across(confirmed: true), details(explained)].compact.join("\n\n") + "\n"
|
|
119
|
+
[heading, @note, compared, failures, across(confirmed: true), details(explained)].compact.join("\n\n") + "\n"
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
# The verdict: what failed (benchmarks, or a metric across the board),
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Duel on GitLab CI: https://github.com/sferik/duel-ruby/blob/main/docs/ci.md#run-duel-on-gitlab-ci
|
|
2
|
+
#
|
|
3
|
+
# Include it from .gitlab-ci.yml:
|
|
4
|
+
#
|
|
5
|
+
# include:
|
|
6
|
+
# - local: <%= gitlab_file %>
|
|
7
|
+
#
|
|
8
|
+
# It compares a merge request with the branch it targets, a push to the
|
|
9
|
+
# default branch with the commit before it (or as c.push_base in .duel.rb
|
|
10
|
+
# says, the last commit Duel passed), and on a schedule, the last release.
|
|
11
|
+
# The Duel job's log, and its duel.md artifact, have the report. Comments
|
|
12
|
+
# need project access tokens, in masked CI/CD variables:
|
|
13
|
+
#
|
|
14
|
+
# - DUEL_COMMENT_TOKEN (the Reporter role, with the api scope, and not
|
|
15
|
+
# protected), to comment on a merge request. Every job of a merge
|
|
16
|
+
# request's pipeline has it, the Duel job too, which runs the merge
|
|
17
|
+
# request's code, so the token can do no more than comment.
|
|
18
|
+
# - DUEL_TOKEN (the Developer role, with the api scope, and protected, so
|
|
19
|
+
# only the default branch's pipelines have it), to comment on a pushed
|
|
20
|
+
# commit (see c.fail_on), and to mark a push passed (see c.push_base).
|
|
21
|
+
|
|
22
|
+
<%= gitlab_job %>:
|
|
23
|
+
image: ruby:<%= gitlab_ruby %>
|
|
24
|
+
stage: test
|
|
25
|
+
variables:
|
|
26
|
+
GIT_DEPTH: 0 # the baseline comes from history, tags included
|
|
27
|
+
DUEL_OUTPUT: duel.env # whether to comment, and mark a push passed
|
|
28
|
+
rules:
|
|
29
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
30
|
+
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
31
|
+
variables:
|
|
32
|
+
DUEL_BASE: $CI_COMMIT_BEFORE_SHA
|
|
33
|
+
- if: $CI_PIPELINE_SOURCE == "schedule"
|
|
34
|
+
variables:
|
|
35
|
+
DUEL_BASE: last-release
|
|
36
|
+
# (Run by hand, it compares anyway: with nothing changed, the code
|
|
37
|
+
# with itself, which checks the setup.)
|
|
38
|
+
- if: $CI_PIPELINE_SOURCE == "web"
|
|
39
|
+
variables:
|
|
40
|
+
DUEL_FORCE: "1"
|
|
41
|
+
# A regression outside a merge request, where only a merge request
|
|
42
|
+
# fails (c.fail_on in .duel.rb), exits 3: a warning.
|
|
43
|
+
allow_failure:
|
|
44
|
+
exit_codes: [3]
|
|
45
|
+
before_script:
|
|
46
|
+
<% if working_directory -%>
|
|
47
|
+
- cd <%= working_directory %>
|
|
48
|
+
<% end -%>
|
|
49
|
+
# The branch a merge request targets, the last commits Duel passed,
|
|
50
|
+
# and the releases.
|
|
51
|
+
- git fetch --quiet --tags origin "+refs/heads/duel/*:refs/remotes/origin/duel/*"
|
|
52
|
+
- if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then git fetch --quiet origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"; fi
|
|
53
|
+
- bundle install
|
|
54
|
+
script:
|
|
55
|
+
- bundle exec duel --markdown duel.md
|
|
56
|
+
artifacts:
|
|
57
|
+
when: always
|
|
58
|
+
paths:
|
|
59
|
+
- <%= working_directory ? "#{working_directory}/" : "" %>duel.md
|
|
60
|
+
reports:
|
|
61
|
+
dotenv: <%= working_directory ? "#{working_directory}/" : "" %>duel.env
|
|
62
|
+
expire_in: 1 day
|
|
63
|
+
|
|
64
|
+
# The report, as a new comment on the merge request, when Duel says to:
|
|
65
|
+
# always (post), or only when its last comment's status differs from this
|
|
66
|
+
# run's (change), so the latest comment is never stale. It never edits or
|
|
67
|
+
# deletes a comment.
|
|
68
|
+
<%= gitlab_job %>:comment:
|
|
69
|
+
image: alpine:3
|
|
70
|
+
stage: .post
|
|
71
|
+
needs: [<%= gitlab_job %>]
|
|
72
|
+
rules:
|
|
73
|
+
- if: $DUEL_COMMENT_TOKEN && $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
74
|
+
when: always
|
|
75
|
+
variables:
|
|
76
|
+
GIT_STRATEGY: none # it runs none of the code
|
|
77
|
+
script:
|
|
78
|
+
- apk add --no-cache --quiet curl jq
|
|
79
|
+
- |
|
|
80
|
+
case "$DUEL_COMMENT" in post|change) ;; *) exit 0 ;; esac
|
|
81
|
+
report=<%= working_directory ? "#{working_directory}/" : "" %>duel.md
|
|
82
|
+
[ -f "$report" ] || exit 0
|
|
83
|
+
api="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
|
|
84
|
+
token="PRIVATE-TOKEN: $DUEL_COMMENT_TOKEN"
|
|
85
|
+
marker="<!-- <%= marker %>"
|
|
86
|
+
me=$(curl -sf -H "$token" "$CI_API_V4_URL/user" | jq .id)
|
|
87
|
+
# The first line of the last comment Duel posted here: its marker,
|
|
88
|
+
# with its status.
|
|
89
|
+
last=$(curl -sf -H "$token" "$api/merge_requests/$CI_MERGE_REQUEST_IID/notes?sort=desc&order_by=created_at&per_page=100" |
|
|
90
|
+
jq -r --argjson me "$me" --arg marker "$marker status=" '.[] | select(.author.id == $me) | .body | split("\n")[0] | rtrimstr("\r") | select(startswith($marker))' | head -n 1)
|
|
91
|
+
if [ "$DUEL_COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$DUEL_STATUS -->" ]; }; then
|
|
92
|
+
{ echo "$marker status=$DUEL_STATUS -->"; cat "$report"; } | jq -Rs '{body: .}' |
|
|
93
|
+
curl -sf -H "$token" -H "Content-Type: application/json" --data @- "$api/merge_requests/$CI_MERGE_REQUEST_IID/notes" > /dev/null
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# The report, as a new comment on a pushed commit, when Duel says to, as
|
|
97
|
+
# for a merge request: only where a regression in a push passes (see
|
|
98
|
+
# c.fail_on), so the comment says what the check doesn't. Its last comment
|
|
99
|
+
# is the commit before's.
|
|
100
|
+
<%= gitlab_job %>:commit-comment:
|
|
101
|
+
image: alpine:3
|
|
102
|
+
stage: .post
|
|
103
|
+
needs: [<%= gitlab_job %>]
|
|
104
|
+
rules:
|
|
105
|
+
- if: $DUEL_TOKEN && $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
106
|
+
when: always
|
|
107
|
+
variables:
|
|
108
|
+
GIT_STRATEGY: none # it runs none of the code
|
|
109
|
+
script:
|
|
110
|
+
- apk add --no-cache --quiet curl jq
|
|
111
|
+
- |
|
|
112
|
+
case "$DUEL_COMMENT" in post|change) ;; *) exit 0 ;; esac
|
|
113
|
+
report=<%= working_directory ? "#{working_directory}/" : "" %>duel.md
|
|
114
|
+
[ -f "$report" ] || exit 0
|
|
115
|
+
api="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
|
|
116
|
+
token="PRIVATE-TOKEN: $DUEL_TOKEN"
|
|
117
|
+
marker="<!-- <%= marker %>"
|
|
118
|
+
me=$(curl -sf -H "$token" "$CI_API_V4_URL/user" | jq .id)
|
|
119
|
+
last=$(curl -sf -H "$token" "$api/repository/commits/$CI_COMMIT_BEFORE_SHA/comments?per_page=100" |
|
|
120
|
+
jq -r --argjson me "$me" --arg marker "$marker status=" '.[] | select(.author.id == $me) | .note | split("\n")[0] | rtrimstr("\r") | select(startswith($marker))' | tail -n 1)
|
|
121
|
+
if [ "$DUEL_COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$DUEL_STATUS -->" ]; }; then
|
|
122
|
+
{ echo "$marker status=$DUEL_STATUS -->"; cat "$report"; } | jq -Rs '{note: .}' |
|
|
123
|
+
curl -sf -H "$token" -H "Content-Type: application/json" --data @- "$api/repository/commits/$CI_COMMIT_SHA/comments" > /dev/null
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# With c.push_base = :last_passed in .duel.rb, a push is compared with the
|
|
127
|
+
# last commit Duel passed, which a branch marks (<%= passed_branch %>/main for
|
|
128
|
+
# main), so slowdowns too small for any one push to fail add up until they
|
|
129
|
+
# fail one. This job moves the branch to a push that passed (or makes it).
|
|
130
|
+
# To accept a regression, move the branch yourself (for main, git push
|
|
131
|
+
# origin main:<%= passed_branch %>/main).
|
|
132
|
+
<%= gitlab_job %>:passed:
|
|
133
|
+
image: alpine:3
|
|
134
|
+
stage: .post
|
|
135
|
+
needs: [<%= gitlab_job %>]
|
|
136
|
+
rules:
|
|
137
|
+
- if: $DUEL_TOKEN && $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
138
|
+
variables:
|
|
139
|
+
GIT_STRATEGY: none # it runs none of the code
|
|
140
|
+
script:
|
|
141
|
+
- apk add --no-cache --quiet curl
|
|
142
|
+
- |
|
|
143
|
+
[ "$DUEL_PASSED" = true ] || exit 0
|
|
144
|
+
api="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
|
|
145
|
+
token="PRIVATE-TOKEN: $DUEL_TOKEN"
|
|
146
|
+
branch="<%= passed_branch %>/$CI_COMMIT_BRANCH"
|
|
147
|
+
curl -s -o /dev/null -H "$token" -X DELETE "$api/repository/branches/$(echo "$branch" | sed 's|/|%2F|g')"
|
|
148
|
+
curl -sf -o /dev/null -H "$token" --data-urlencode "branch=$branch" --data-urlencode "ref=$CI_COMMIT_SHA" "$api/repository/branches"
|
|
@@ -37,4 +37,17 @@ Duel.configure do |c|
|
|
|
37
37
|
# :results (passed or failed), :failures (failed, or couldn't compare),
|
|
38
38
|
# or :never.
|
|
39
39
|
# c.comment = :failures
|
|
40
|
+
|
|
41
|
+
# Which runs a regression fails: :every_run, or :pull_requests, for a
|
|
42
|
+
# project where people push to main directly (on GitHub Actions, another
|
|
43
|
+
# run warns and passes, and a pushed commit gets the comment instead).
|
|
44
|
+
# c.fail_on = :every_run
|
|
45
|
+
|
|
46
|
+
# A label that accepts a regression in a pull request (nil for none).
|
|
47
|
+
# c.accept_label = "duel: accept"
|
|
48
|
+
|
|
49
|
+
# What the workflow compares a push with: :before (the commit before it),
|
|
50
|
+
# or :last_passed (the last commit Duel passed, which a branch,
|
|
51
|
+
# duel/passed/main, marks), so small slowdowns add up until they fail.
|
|
52
|
+
# c.push_base = :before
|
|
40
53
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: Perf comment<%= " (#{working_directory})" if working_directory %>
|
|
2
|
+
|
|
3
|
+
# Duel's report, as a new comment on the pull request, when Duel says to:
|
|
4
|
+
# always (post), or only when its last comment's status differs from the
|
|
5
|
+
# run's (change), so the latest comment is never stale. It never edits or
|
|
6
|
+
# deletes a comment. It runs once the Perf workflow has, with this
|
|
7
|
+
# repository's own workflow and a token that can write, so a pull request
|
|
8
|
+
# from a fork gets a comment too. It checks nothing out and runs none of
|
|
9
|
+
# the pull request's code, and of what the Duel job kept (where that code
|
|
10
|
+
# ran), it trusts only what that job could have said: the pull request's
|
|
11
|
+
# number, if it names the pull request the run compared, and the comment
|
|
12
|
+
# and the status, if they're ones Duel says. (The workflow must be on the
|
|
13
|
+
# default branch before it runs at all.)
|
|
14
|
+
on:
|
|
15
|
+
workflow_run:
|
|
16
|
+
workflows: ["Perf<%= " (#{working_directory})" if working_directory %>"]
|
|
17
|
+
types: [completed]
|
|
18
|
+
|
|
19
|
+
permissions: {}
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
comment:
|
|
23
|
+
name: Comment
|
|
24
|
+
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }}
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
permissions:
|
|
27
|
+
actions: read # to get the report from the Perf run
|
|
28
|
+
pull-requests: write # to comment on the pull request with the report
|
|
29
|
+
steps:
|
|
30
|
+
- name: Get the report
|
|
31
|
+
id: report
|
|
32
|
+
continue-on-error: true # there's none when the Duel job failed before comparing
|
|
33
|
+
uses: actions/download-artifact@v8
|
|
34
|
+
with:
|
|
35
|
+
name: duel-report
|
|
36
|
+
path: ${{ runner.temp }}/report
|
|
37
|
+
run-id: ${{ github.event.workflow_run.id }}
|
|
38
|
+
github-token: ${{ github.token }}
|
|
39
|
+
- name: Comment on the pull request
|
|
40
|
+
if: steps.report.outcome == 'success'
|
|
41
|
+
env:
|
|
42
|
+
GH_TOKEN: ${{ github.token }}
|
|
43
|
+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
44
|
+
run: |
|
|
45
|
+
said() { grep -xE "$1=($2)" "$RUNNER_TEMP/report/comment.env" | tail -n 1 | cut -d= -f2; }
|
|
46
|
+
PR=$(said pr '[0-9]+')
|
|
47
|
+
COMMENT=$(said comment 'post|change')
|
|
48
|
+
STATUS=$(said status 'passed|failed|neutral')
|
|
49
|
+
[ -n "$PR" ] && [ -n "$COMMENT" ] && [ -n "$STATUS" ] || exit 0
|
|
50
|
+
[ "$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha)" = "$HEAD_SHA" ] || exit 0
|
|
51
|
+
marker="<!-- <%= marker %>"
|
|
52
|
+
# The first line of the last comment Duel posted here: its marker,
|
|
53
|
+
# with its status (none in Duel 0.1.0's, which counts as another).
|
|
54
|
+
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
|
|
55
|
+
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
|
|
56
|
+
if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
|
|
57
|
+
{ echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
|
|
58
|
+
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
|
|
59
|
+
fi
|
|
@@ -1,27 +1,40 @@
|
|
|
1
|
-
name: Perf
|
|
1
|
+
name: Perf<%= " (#{working_directory})" if working_directory %>
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
pull_request:
|
|
5
|
+
merge_group: # (a merge queue's)
|
|
5
6
|
push:
|
|
6
|
-
branches: [main]
|
|
7
|
+
branches: [main] # (and any other branch to guard, a stable one, say)
|
|
7
8
|
schedule:
|
|
8
9
|
- cron: "0 4 * * *"
|
|
9
10
|
workflow_dispatch:
|
|
10
11
|
|
|
12
|
+
# A pull request's newer push cancels its run, if that's still going, as
|
|
13
|
+
# only its newest commit's result counts. Every other run (a push's, say)
|
|
14
|
+
# runs to the end, so that each push is compared.
|
|
15
|
+
concurrency:
|
|
16
|
+
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
|
17
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
18
|
+
|
|
11
19
|
# The Duel job runs the branch's code (its benchmarks too), so it only
|
|
12
|
-
# reads. Commenting is the Comment job's, which
|
|
20
|
+
# reads. Commenting on a commit is the Comment on the commit job's, which
|
|
21
|
+
# runs none of it, and commenting on a pull request is the Perf comment
|
|
22
|
+
# workflow's.
|
|
13
23
|
permissions:
|
|
14
24
|
contents: read
|
|
25
|
+
pull-requests: read # for the pull request's labels (see c.accept_label)
|
|
15
26
|
|
|
16
27
|
jobs:
|
|
17
28
|
duel:
|
|
18
29
|
name: Duel
|
|
19
30
|
runs-on: ubuntu-latest
|
|
20
|
-
# Whether
|
|
21
|
-
#
|
|
31
|
+
# Whether to comment (post, change or none), the run's status (passed,
|
|
32
|
+
# failed or neutral), and whether to mark a push passed (true, or
|
|
33
|
+
# nothing), from Duel.
|
|
22
34
|
outputs:
|
|
23
35
|
comment: ${{ steps.compare.outputs.comment }}
|
|
24
36
|
status: ${{ steps.compare.outputs.status }}
|
|
37
|
+
passed: ${{ steps.compare.outputs.passed }}
|
|
25
38
|
<% if working_directory -%>
|
|
26
39
|
# The project is in a subdirectory of the repository: Duel runs there,
|
|
27
40
|
# and checks out the whole repository for the baseline.
|
|
@@ -40,20 +53,27 @@ jobs:
|
|
|
40
53
|
# .duel.rb). When the baseline's names another Ruby than this code's,
|
|
41
54
|
# that one is installed too, and linked where Duel finds it (as
|
|
42
55
|
# chruby would), before the project's own Ruby, which runs Duel. The
|
|
43
|
-
# baseline is the Compare step's: where HEAD left the
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
56
|
+
# baseline is the Compare step's: where HEAD left the branch a pull
|
|
57
|
+
# request targets (or run by hand, the main branch), a merge queue's
|
|
58
|
+
# base, the commit before a push (or the last commit Duel passed, PASSED, if there is
|
|
59
|
+
# one: see the Mark the commit passed job), or on a schedule the last
|
|
60
|
+
# release, the newest tag before HEAD that RELEASE_TAGS matches. (If
|
|
61
|
+
# .duel.rb sets c.main_branch or c.release_tags, set MAIN_BRANCH or
|
|
62
|
+
# RELEASE_TAGS to match.)
|
|
48
63
|
- name: Find the baseline's Ruby
|
|
49
64
|
id: baseline
|
|
50
65
|
env:
|
|
51
66
|
BEFORE: ${{ github.event.before }}
|
|
52
|
-
|
|
67
|
+
QUEUE_BASE: ${{ github.event.merge_group.base_sha }}
|
|
68
|
+
PASSED: origin/<%= passed_branch %>/${{ github.ref_name }}
|
|
69
|
+
MAIN_BRANCH: origin/${{ github.base_ref || github.event.repository.default_branch }}
|
|
53
70
|
RELEASE_TAGS: "<%= release_tags %>"
|
|
54
71
|
run: |
|
|
55
72
|
case "$GITHUB_EVENT_NAME" in
|
|
56
|
-
push)
|
|
73
|
+
push)
|
|
74
|
+
base=$BEFORE
|
|
75
|
+
if git merge-base --is-ancestor "$PASSED" HEAD 2>/dev/null; then base=$PASSED; fi ;;
|
|
76
|
+
merge_group) base=$QUEUE_BASE ;;
|
|
57
77
|
schedule) base=$(git describe --tags --abbrev=0 --match "$RELEASE_TAGS" HEAD^ 2>/dev/null) || base= ;;
|
|
58
78
|
*) base=$(git merge-base HEAD "$MAIN_BRANCH" 2>/dev/null) || base= ;;
|
|
59
79
|
esac
|
|
@@ -93,50 +113,80 @@ jobs:
|
|
|
93
113
|
ruby-version: "<%= ruby_version %>"
|
|
94
114
|
<% end -%>
|
|
95
115
|
bundler-cache: true
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
# The labels of the pull request (or for a push, of the one that
|
|
117
|
+
# merged it), for c.accept_label in .duel.rb, as a JSON array. They're
|
|
118
|
+
# read as the job runs, so after adding the label, run it again.
|
|
119
|
+
- name: Find the pull request's labels
|
|
120
|
+
id: labels
|
|
121
|
+
if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push'
|
|
122
|
+
continue-on-error: true # (without them, nothing's accepted)
|
|
123
|
+
env:
|
|
124
|
+
GH_TOKEN: ${{ github.token }}
|
|
125
|
+
PR: ${{ github.event.pull_request.number }}
|
|
126
|
+
QUEUED: ${{ github.event.merge_group.head_ref }}
|
|
127
|
+
run: |
|
|
128
|
+
# (In a merge queue, the last pull request queued, which the queue's
|
|
129
|
+
# branch names: gh-readonly-queue/main/pr-123-<sha>.)
|
|
130
|
+
if [ -n "$QUEUED" ]; then
|
|
131
|
+
PR=${QUEUED##*/pr-}
|
|
132
|
+
PR=${PR%%-*}
|
|
133
|
+
fi
|
|
134
|
+
if [ -n "$PR" ]; then
|
|
135
|
+
labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/labels" --jq '[.[].name]')
|
|
136
|
+
else
|
|
137
|
+
labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[0].labels[]?.name]')
|
|
138
|
+
fi
|
|
139
|
+
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
|
140
|
+
# Against the branch it targets for a pull request, the queue's base
|
|
141
|
+
# for a merge queue, what was there before for a push (or as
|
|
142
|
+
# c.push_base in .duel.rb says, the last commit Duel passed), and the
|
|
143
|
+
# last release on a schedule (slow creep). Duel adds its report to
|
|
144
|
+
# the job's summary, writes it to duel.md for the comment, and sets
|
|
145
|
+
# the step's outputs to say whether to comment, as c.comment in
|
|
146
|
+
# .duel.rb says, whatever the outcome.
|
|
101
147
|
- name: Compare
|
|
102
148
|
id: compare
|
|
103
149
|
env:
|
|
104
|
-
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
|
|
150
|
+
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 || '' }}
|
|
105
151
|
# (Run by hand, it compares anyway: with nothing changed, the code
|
|
106
152
|
# with itself, which checks the setup.)
|
|
107
153
|
DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
|
|
154
|
+
DUEL_LABELS: ${{ steps.labels.outputs.labels }}
|
|
155
|
+
run: bundle exec duel --markdown "$RUNNER_TEMP/duel/duel.md"
|
|
156
|
+
# For a pull request, what Duel said, for the Perf comment workflow,
|
|
157
|
+
# which can't see this job's outputs.
|
|
158
|
+
- name: Say whether to comment
|
|
159
|
+
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
|
160
|
+
env:
|
|
161
|
+
PR: ${{ github.event.pull_request.number }}
|
|
162
|
+
COMMENT: ${{ steps.compare.outputs.comment }}
|
|
163
|
+
STATUS: ${{ steps.compare.outputs.status }}
|
|
108
164
|
run: |
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
echo "::notice::No commit before this push to compare against"
|
|
113
|
-
exit 0
|
|
114
|
-
fi
|
|
115
|
-
bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
|
|
116
|
-
# The report, for the Comment job.
|
|
165
|
+
mkdir -p "$RUNNER_TEMP/duel"
|
|
166
|
+
printf 'pr=%s\ncomment=%s\nstatus=%s\n' "$PR" "$COMMENT" "$STATUS" > "$RUNNER_TEMP/duel/comment.env"
|
|
167
|
+
# The report, for the comments.
|
|
117
168
|
- name: Keep the report
|
|
118
|
-
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
|
169
|
+
if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
|
|
119
170
|
uses: actions/upload-artifact@v7
|
|
120
171
|
with:
|
|
121
172
|
name: duel-report
|
|
122
|
-
path: ${{ runner.temp }}/duel
|
|
173
|
+
path: ${{ runner.temp }}/duel/
|
|
123
174
|
if-no-files-found: ignore # none when Duel didn't get to compare
|
|
124
175
|
retention-days: 1
|
|
125
176
|
|
|
126
|
-
# The report, as a new comment on
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
name: Comment
|
|
177
|
+
# The report, as a new comment on a pushed commit, which notifies its
|
|
178
|
+
# author, when Duel says to, as for a pull request (see the Perf comment
|
|
179
|
+
# workflow). It does only where a regression in a push passes, with
|
|
180
|
+
# c.fail_on = :pull_requests in .duel.rb, so the comment says what the
|
|
181
|
+
# check doesn't. Its last comment is the last one on any commit. This
|
|
182
|
+
# job checks nothing out and runs none of the code.
|
|
183
|
+
commit-comment:
|
|
184
|
+
name: Comment on the commit
|
|
135
185
|
needs: duel
|
|
136
|
-
if: ${{ !cancelled() && github.event_name == '
|
|
186
|
+
if: ${{ !cancelled() && github.event_name == 'push' && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }}
|
|
137
187
|
runs-on: ubuntu-latest
|
|
138
188
|
permissions:
|
|
139
|
-
|
|
189
|
+
contents: write # to comment on the commit with the report
|
|
140
190
|
steps:
|
|
141
191
|
- name: Get the report
|
|
142
192
|
id: report
|
|
@@ -145,20 +195,40 @@ jobs:
|
|
|
145
195
|
with:
|
|
146
196
|
name: duel-report
|
|
147
197
|
path: ${{ runner.temp }}/report
|
|
148
|
-
- name: Comment on the
|
|
198
|
+
- name: Comment on the commit
|
|
149
199
|
if: steps.report.outcome == 'success'
|
|
150
200
|
env:
|
|
151
201
|
GH_TOKEN: ${{ github.token }}
|
|
152
|
-
PR: ${{ github.event.pull_request.number }}
|
|
153
202
|
COMMENT: ${{ needs.duel.outputs.comment }}
|
|
154
203
|
STATUS: ${{ needs.duel.outputs.status }}
|
|
155
204
|
run: |
|
|
156
205
|
marker="<!-- <%= marker %>"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
|
|
160
|
-
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
|
|
206
|
+
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/comments" \
|
|
207
|
+
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\"))" | tail -n 1)
|
|
161
208
|
if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
|
|
162
209
|
{ echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
|
|
163
|
-
gh api --method POST "repos/$GITHUB_REPOSITORY/
|
|
210
|
+
gh api --method POST "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
|
|
164
211
|
fi
|
|
212
|
+
|
|
213
|
+
# With c.push_base = :last_passed in .duel.rb, a push is compared with
|
|
214
|
+
# the last commit Duel passed, which a branch marks (each branch pushed
|
|
215
|
+
# has its own), so slowdowns too small for any one push to fail add up
|
|
216
|
+
# until they fail one. This job moves the branch to a push that passed
|
|
217
|
+
# (or makes it). It checks nothing out and runs none of the code. To
|
|
218
|
+
# accept a regression, move the branch yourself (for main, git push
|
|
219
|
+
# origin main:<%= passed_branch %>/main).
|
|
220
|
+
passed:
|
|
221
|
+
name: Mark the commit passed
|
|
222
|
+
needs: duel
|
|
223
|
+
if: ${{ !cancelled() && github.event_name == 'push' && needs.duel.outputs.passed == 'true' }}
|
|
224
|
+
runs-on: ubuntu-latest
|
|
225
|
+
permissions:
|
|
226
|
+
contents: write # to move the branch
|
|
227
|
+
steps:
|
|
228
|
+
- name: Move the branch to the commit
|
|
229
|
+
env:
|
|
230
|
+
GH_TOKEN: ${{ github.token }}
|
|
231
|
+
BRANCH: <%= passed_branch %>/${{ github.ref_name }}
|
|
232
|
+
run: |
|
|
233
|
+
gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/heads/$BRANCH" -f sha="$GITHUB_SHA" -F force=true > /dev/null 2>&1 ||
|
|
234
|
+
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$GITHUB_SHA" > /dev/null
|
data/lib/duel/version.rb
CHANGED
data/sig/duel/baseline.rbs
CHANGED
|
@@ -13,9 +13,17 @@ module Duel
|
|
|
13
13
|
|
|
14
14
|
def initialize: (Config config, Settings settings) -> void
|
|
15
15
|
|
|
16
|
-
# The commit, or nil for "last-release" with no release yet
|
|
16
|
+
# The commit, or nil for "last-release" with no release yet, and for
|
|
17
|
+
# a push with no commit before it here.
|
|
17
18
|
def ref: () -> String?
|
|
18
19
|
|
|
20
|
+
# The branch that marks the last commit Duel passed in a push to the
|
|
21
|
+
# branch pushed.
|
|
22
|
+
def passed_branch: () -> String
|
|
23
|
+
|
|
24
|
+
# The last commit Duel passed in a push, as checked out (on origin).
|
|
25
|
+
def passed: () -> String
|
|
26
|
+
|
|
19
27
|
# Whether nothing #watched changed since the baseline.
|
|
20
28
|
def unchanged?: () -> bool
|
|
21
29
|
|
|
@@ -62,6 +70,11 @@ module Duel
|
|
|
62
70
|
# says why there's none.
|
|
63
71
|
def merge_base: () -> String
|
|
64
72
|
|
|
73
|
+
# A push's baseline: the last commit Duel passed, where
|
|
74
|
+
# Config#push_base says and HEAD has it, or else the commit before the
|
|
75
|
+
# push, or nil where that isn't here.
|
|
76
|
+
def pushed: () -> String?
|
|
77
|
+
|
|
65
78
|
# Whether +dir+ is a checkout of this repository.
|
|
66
79
|
def ours?: (String dir) -> bool
|
|
67
80
|
|
data/sig/duel/cli.rbs
CHANGED
|
@@ -28,6 +28,15 @@ module Duel
|
|
|
28
28
|
# A run's status as the comment's marker has it.
|
|
29
29
|
MARKED: Hash[String, String]
|
|
30
30
|
|
|
31
|
+
# The GitHub Actions events of a pull request.
|
|
32
|
+
PULL_REQUESTS: Array[String]
|
|
33
|
+
|
|
34
|
+
# The exit status of a run that was slower, but warned, on GitLab CI.
|
|
35
|
+
WARNED: Integer
|
|
36
|
+
|
|
37
|
+
# Where else to write the step's outputs, as variables.
|
|
38
|
+
OUTPUT: String
|
|
39
|
+
|
|
31
40
|
# The variables that set options, to their options.
|
|
32
41
|
ENV_OPTIONS: Hash[String, String]
|
|
33
42
|
|
|
@@ -43,6 +52,8 @@ module Duel
|
|
|
43
52
|
@json: String?
|
|
44
53
|
@settings: Settings?
|
|
45
54
|
@config: Config?
|
|
55
|
+
@passed: bool?
|
|
56
|
+
@note: String?
|
|
46
57
|
|
|
47
58
|
def initialize: (Array[String] argv, ?env: _Env, ?out: _Output, ?err: _Output) -> void
|
|
48
59
|
|
|
@@ -61,6 +72,13 @@ module Duel
|
|
|
61
72
|
# The mode --comment names, or an invalid argument.
|
|
62
73
|
def self.comment_mode: (String name) -> Config::comment
|
|
63
74
|
|
|
75
|
+
# The runs --fail-on names, or an invalid argument.
|
|
76
|
+
def self.fail_on: (String name) -> Config::fail_on
|
|
77
|
+
|
|
78
|
+
# Which of +values+ an option's argument names, or an invalid
|
|
79
|
+
# argument.
|
|
80
|
+
def self.named: [T < Symbol] (String name, Array[T] values) -> T
|
|
81
|
+
|
|
64
82
|
def parse: () -> Settings
|
|
65
83
|
|
|
66
84
|
private
|
|
@@ -68,14 +86,58 @@ module Duel
|
|
|
68
86
|
# What #run does, holding the project's lock.
|
|
69
87
|
def compare: (Config config, Settings settings) -> Integer
|
|
70
88
|
|
|
89
|
+
# The exit status of a run that was slower: 1, or 0 where only a pull
|
|
90
|
+
# request fails, outside one.
|
|
91
|
+
def slower: (Config config, Settings settings) -> Integer
|
|
92
|
+
|
|
93
|
+
# Passes a run that was slower, with a warning and a note.
|
|
94
|
+
def passing: (String why, String note, ?Integer exit) -> Integer
|
|
95
|
+
|
|
96
|
+
# Whether the pull request's labels have the one that accepts a
|
|
97
|
+
# regression.
|
|
98
|
+
def accepted?: (Config config) -> bool
|
|
99
|
+
|
|
100
|
+
# The pull request's labels, from DUEL_LABELS.
|
|
101
|
+
def labels: () -> Array[untyped]
|
|
102
|
+
|
|
103
|
+
# Whether the workflow marks a push that passed as the last commit
|
|
104
|
+
# Duel passed.
|
|
105
|
+
def marks_passed?: (Config config, Settings settings) -> bool
|
|
106
|
+
|
|
107
|
+
# What a run on CI is for, as GitHub Actions names it, or nil outside
|
|
108
|
+
# CI.
|
|
109
|
+
def event: () -> String?
|
|
110
|
+
|
|
111
|
+
# Whether this is a run on GitLab CI.
|
|
112
|
+
def gitlab?: () -> bool
|
|
113
|
+
|
|
114
|
+
# The first of the variables +names+ that's set, or nil.
|
|
115
|
+
def variable: (*String names) -> String?
|
|
116
|
+
|
|
117
|
+
# Whether a regression fails only a pull request.
|
|
118
|
+
def pull_requests_only?: (Config config, Settings settings) -> bool
|
|
119
|
+
|
|
120
|
+
# Whether this is a run on GitHub Actions for something other than a
|
|
121
|
+
# pull request.
|
|
122
|
+
def outside_pull_request?: () -> bool
|
|
123
|
+
|
|
124
|
+
# Whether this is a run on GitHub Actions for a pull request.
|
|
125
|
+
def pull_request?: () -> bool
|
|
126
|
+
|
|
127
|
+
# Whether the baseline is a merge queue's base.
|
|
128
|
+
def queued?: (Settings settings) -> bool
|
|
129
|
+
|
|
130
|
+
# Whether this is a run on GitHub Actions for a push.
|
|
131
|
+
def pushed?: () -> bool
|
|
132
|
+
|
|
71
133
|
# Says there's nothing to compare, and why, and passes.
|
|
72
134
|
def nothing: (String title, String why) -> Integer
|
|
73
135
|
|
|
74
136
|
# Reports a run that didn't compare, and why, and returns +exit+.
|
|
75
137
|
def unfinished: (String mark, String title, String status, String why, Integer exit) -> Integer
|
|
76
138
|
|
|
77
|
-
# What the workflow does about its comment on a pull request,
|
|
78
|
-
# run of +status+.
|
|
139
|
+
# What the workflow does about its comment on a pull request, or on a
|
|
140
|
+
# pushed commit, for a run of +status+.
|
|
79
141
|
def commenting: (String status) -> String
|
|
80
142
|
|
|
81
143
|
# Writes the reports where they're asked for, each made only if it's
|