Skip to content

Commit 3125018

Browse files
committed
update e2e trigger, add deduplicate workflow
1 parent 4f21a9d commit 3125018

File tree

2 files changed

+194
-44
lines changed

2 files changed

+194
-44
lines changed

.github/workflows/approval-dedupe.yml

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Reusable workflow: Approval-based deduplication by PR head SHA
2+
#
3+
# Purpose
4+
# - Gate heavy CI jobs to run only on Approved PR reviews (or manual dispatch in the caller),
5+
# - De-duplicate runs for the same PR head SHA:
6+
# - mode=run: no prior run found → caller should run heavy jobs
7+
# - mode=wait: a prior run is queued/in_progress → this workflow waits and mirrors its result
8+
# - mode=mirror_completed: a prior run already completed → mirror its conclusion immediately
9+
# - mode=noop: not an approved review (and not allowed workflow_dispatch) → do nothing
10+
#
11+
# How to use (in a caller workflow):
12+
# - Ensure the caller triggers on: pull_request_review: [submitted] and/or workflow_dispatch
13+
# - Add a job that calls this workflow:
14+
# jobs:
15+
# approval-dedupe:
16+
# uses: ./.github/workflows/approval-dedupe.yml
17+
# secrets: inherit
18+
# with:
19+
# workflow_filename: tests-e2e.yml # set to the caller's filename
20+
# require_approval: true # only run on Approved reviews
21+
# allow_workflow_dispatch: true # allow manual runs to go to mode=run
22+
# - Gate heavy jobs in the caller with:
23+
# needs: approval-dedupe
24+
# if: needs.approval-dedupe.outputs.mode == 'run'
25+
# - Recommended in caller: set concurrency to key by head SHA and cancel-in-progress: false
26+
#
27+
28+
name: Approval Deduplicate
29+
30+
on:
31+
workflow_call:
32+
inputs:
33+
workflow_filename:
34+
description: The workflow filename to inspect for prior runs (e.g., tests-e2e.yml)
35+
required: true
36+
type: string
37+
default: tests-e2e.yml
38+
require_approval:
39+
description: Require an Approved review to proceed (ignored for workflow_dispatch when allowed)
40+
required: false
41+
type: boolean
42+
default: true
43+
allow_workflow_dispatch:
44+
description: Allow workflow_dispatch to always run heavy jobs
45+
required: false
46+
type: boolean
47+
default: true
48+
head_sha:
49+
description: Optional explicit head SHA to use for de-duplication. Defaults to PR head SHA
50+
required: false
51+
type: string
52+
outputs:
53+
mode:
54+
description: One of run | wait | mirror_completed | noop
55+
value: ${{ jobs.gate.outputs.mode }}
56+
target_run_id:
57+
description: If mode == wait, the prior run id to wait on
58+
value: ${{ jobs.gate.outputs.target_run_id }}
59+
prev_conclusion:
60+
description: If mode == mirror_completed, the conclusion to mirror
61+
value: ${{ jobs.gate.outputs.prev_conclusion }}
62+
63+
permissions:
64+
actions: read
65+
contents: read
66+
67+
jobs:
68+
gate:
69+
name: Gate and determine mode
70+
runs-on: ubuntu-latest
71+
outputs:
72+
mode: ${{ steps.check.outputs.mode }}
73+
target_run_id: ${{ steps.check.outputs.target_run_id }}
74+
prev_conclusion: ${{ steps.check.outputs.prev_conclusion }}
75+
steps:
76+
- name: Determine mode
77+
id: check
78+
uses: actions/github-script@v7
79+
with:
80+
script: |
81+
const requireApproval = core.getInput('require_approval') === 'true'
82+
const allowDispatch = core.getInput('allow_workflow_dispatch') === 'true'
83+
const workflowFilename = core.getInput('workflow_filename') || 'tests-e2e.yml'
84+
const explicitSha = (core.getInput('head_sha') || '').trim()
85+
86+
if (context.eventName === 'workflow_dispatch' && allowDispatch) {
87+
core.setOutput('mode', 'run')
88+
return
89+
}
90+
91+
if (requireApproval) {
92+
const approved = (context.eventName === 'pull_request_review') &&
93+
(context.payload.review?.state?.toLowerCase() === 'approved')
94+
if (!approved) {
95+
core.setOutput('mode', 'noop')
96+
return
97+
}
98+
}
99+
100+
const sha = explicitSha || context.payload.pull_request?.head?.sha
101+
if (!sha) {
102+
core.setOutput('mode', 'run')
103+
return
104+
}
105+
106+
const resp = await github.rest.actions.listWorkflowRuns({
107+
owner: context.repo.owner,
108+
repo: context.repo.repo,
109+
workflow_id: workflowFilename,
110+
head_sha: sha,
111+
per_page: 50,
112+
})
113+
114+
const currentRunId = context.runId
115+
const runs = (resp.data.workflow_runs || []).filter(r => r.id !== currentRunId)
116+
const active = runs.find(r => ['queued', 'in_progress'].includes(r.status))
117+
if (active) {
118+
core.setOutput('mode', 'wait')
119+
core.setOutput('target_run_id', String(active.id))
120+
return
121+
}
122+
123+
const completed = runs
124+
.filter(r => r.status === 'completed')
125+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]
126+
if (completed) {
127+
core.setOutput('mode', 'mirror_completed')
128+
core.setOutput('prev_conclusion', completed.conclusion || '')
129+
return
130+
}
131+
132+
core.setOutput('mode', 'run')
133+
134+
wait-previous:
135+
name: Wait for previous run
136+
needs: gate
137+
if: needs.gate.outputs.mode == 'wait'
138+
runs-on: ubuntu-latest
139+
steps:
140+
- name: Wait and mirror
141+
uses: actions/github-script@v7
142+
with:
143+
script: |
144+
const runId = Number('${{ needs.gate.outputs.target_run_id }}')
145+
const poll = async () => {
146+
const { data } = await github.rest.actions.getWorkflowRun({
147+
owner: context.repo.owner,
148+
repo: context.repo.repo,
149+
run_id: runId,
150+
})
151+
return data
152+
}
153+
let data = await poll()
154+
const started = Date.now()
155+
while (data.status !== 'completed') {
156+
if (Date.now() - started > 60 * 60 * 1000) {
157+
core.setFailed('Timeout waiting for prior run')
158+
return
159+
}
160+
await new Promise(r => setTimeout(r, 15000))
161+
data = await poll()
162+
}
163+
const conclusion = data.conclusion || 'failure'
164+
if (conclusion !== 'success') {
165+
core.setFailed(`Mirroring prior run conclusion: ${conclusion}`)
166+
}
167+
168+
mirror-completed:
169+
name: Mirror completed run
170+
needs: gate
171+
if: needs.gate.outputs.mode == 'mirror_completed'
172+
runs-on: ubuntu-latest
173+
steps:
174+
- name: Mirror result
175+
run: |
176+
echo "Previous conclusion: ${{ needs.gate.outputs.prev_conclusion }}"
177+
if [ "${{ needs.gate.outputs.prev_conclusion }}" != "success" ]; then
178+
echo "Mirroring failure"
179+
exit 1
180+
fi
181+
echo "Mirroring success"
182+

.github/workflows/tests-e2e.yml

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,53 +18,21 @@ permissions:
1818
# Cancel a previous run workflow
1919
concurrency:
2020
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.ref }}-e2e
21-
cancel-in-progress: true
21+
cancel-in-progress: false
2222

2323
jobs:
24-
gate:
25-
name: Gate on approval and dedupe
26-
runs-on: ubuntu-latest
27-
outputs:
28-
should_run: ${{ steps.check.outputs.should_run }}
29-
steps:
30-
- name: Check approval and prior successful runs
31-
id: check
32-
uses: actions/github-script@v7
33-
with:
34-
script: |
35-
if (context.eventName === 'workflow_dispatch') {
36-
core.setOutput('should_run', 'true')
37-
return
38-
}
39-
40-
const approved = (context.eventName === 'pull_request_review') &&
41-
(context.payload.review?.state?.toLowerCase() === 'approved')
42-
43-
if (!approved) {
44-
core.setOutput('should_run', 'false')
45-
return
46-
}
47-
48-
const sha = context.payload.pull_request?.head?.sha
49-
if (!sha) {
50-
core.setOutput('should_run', 'true')
51-
return
52-
}
53-
54-
const resp = await github.rest.actions.listWorkflowRuns({
55-
owner: context.repo.owner,
56-
repo: context.repo.repo,
57-
workflow_id: 'tests-e2e.yml',
58-
head_sha: sha,
59-
})
60-
const hasSuccess = resp.data.workflow_runs.some(r => r.conclusion === 'success')
61-
62-
core.setOutput('should_run', hasSuccess ? 'false' : 'true')
24+
approval-dedupe:
25+
uses: ./.github/workflows/approval-dedupe.yml
26+
secrets: inherit
27+
with:
28+
workflow_filename: tests-e2e.yml
29+
require_approval: true
30+
allow_workflow_dispatch: true
6331

6432
# E2E Docker
6533
build-docker:
66-
needs: gate
67-
if: needs.gate.outputs.should_run == 'true'
34+
needs: approval-dedupe
35+
if: needs.approval-dedupe.outputs.mode == 'run'
6836
uses: ./.github/workflows/pipeline-build-docker.yml
6937
secrets: inherit
7038
with:
@@ -87,8 +55,8 @@ jobs:
8755

8856
# E2E AppImage
8957
build-appimage:
90-
needs: gate
91-
if: needs.gate.outputs.should_run == 'true'
58+
needs: approval-dedupe
59+
if: needs.approval-dedupe.outputs.mode == 'run'
9260
uses: ./.github/workflows/pipeline-build-linux.yml
9361
secrets: inherit
9462
with:

0 commit comments

Comments
 (0)