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.
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
- def initialize(runner, results, settings:, baseline:, candidate:)
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 runs none of it.
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 the Comment job comments (post, change or none), and the
21
- # run's status (passed, failed or neutral), from Duel.
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 main branch
44
- # (for a pull request, and run by hand), the commit before a push, or
45
- # on a schedule the last release, the newest tag before HEAD that
46
- # RELEASE_TAGS matches. (If .duel.rb sets c.main_branch or
47
- # c.release_tags, set MAIN_BRANCH or RELEASE_TAGS to match.)
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
- MAIN_BRANCH: origin/${{ github.event.repository.default_branch }}
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) base=$BEFORE ;;
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
- # Against the main branch for a pull request, what was there before
97
- # for a push, and the last release on a schedule (slow creep). Duel
98
- # adds its report to the job's summary, writes it to duel.md for the
99
- # comment, and sets the step's outputs to say whether to comment, as
100
- # c.comment in .duel.rb says, whatever the outcome.
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
- # (A branch's first push, or one that rewrote history, has no
110
- # commit before it here to compare against.)
111
- if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
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.md
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 the pull request, when Duel says to:
127
- # always (post), or only when its last comment's status differs from
128
- # this run's (change), so the latest comment is never stale. It never
129
- # edits or deletes a comment. This job checks nothing out and runs none
130
- # of the branch's code, so it alone can write. (A pull request from a
131
- # fork gets no comment, as its token can't write one: the Duel job's
132
- # summary has the report.)
133
- comment:
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 == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }}
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
- pull-requests: write # to comment on the pull request with the report
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 pull request
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
- # The first line of the last comment Duel posted here: its marker,
158
- # with its status (none in Duel 0.1.0's, which counts as another).
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/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Duel
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -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, for a
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