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
189 changes: 189 additions & 0 deletions .github/workflows/approval-dedupe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Reusable workflow: Approval-based deduplication by PR head SHA
#
# Purpose
# - Gate heavy CI jobs to run only on Approved PR reviews (or manual dispatch in the caller),
# - De-duplicate runs for the same PR head SHA:
# - mode=run: no prior run found → caller should run heavy jobs
# - mode=wait: a prior run is queued/in_progress → this workflow waits and mirrors its result
# - mode=mirror_completed: a prior run already completed → mirror its conclusion immediately
# - mode=noop: not an approved review (and not allowed workflow_dispatch) → do nothing
#
# How to use (in a caller workflow):
# - Ensure the caller triggers on: pull_request_review: [submitted] and/or workflow_dispatch
# - Add a job that calls this workflow:
# jobs:
# approval-dedupe:
# uses: ./.github/workflows/approval-dedupe.yml
# secrets: inherit
# with:
# workflow_filename: tests-e2e.yml # set to the caller's filename
# require_approval: true # only run on Approved reviews
# allow_workflow_dispatch: true # allow manual runs to go to mode=run
# - Gate heavy jobs in the caller with:
# needs: approval-dedupe
# if: needs.approval-dedupe.outputs.mode == 'run'
# - Recommended in caller: set concurrency to key by head SHA and cancel-in-progress: false
#

name: Approval Deduplicate

on:
workflow_call:
inputs:
workflow_filename:
description: The workflow filename to inspect for prior runs (e.g., tests-e2e.yml)
required: true
type: string
require_approval:
description: Require an Approved review to proceed (ignored for workflow_dispatch when allowed)
required: false
type: boolean
default: true
allow_workflow_dispatch:
description: Allow workflow_dispatch to always run heavy jobs
required: false
type: boolean
default: true
head_sha:
description: Optional explicit head SHA to use for de-duplication. Defaults to PR head SHA
required: false
type: string
outputs:
mode:
description: One of run | wait | mirror_completed | noop
value: ${{ jobs.gate.outputs.mode }}
target_run_id:
description: If mode == wait, the prior run id to wait on
value: ${{ jobs.gate.outputs.target_run_id }}
prev_conclusion:
description: If mode == mirror_completed, the conclusion to mirror
value: ${{ jobs.gate.outputs.prev_conclusion }}
prev_run_id:
description: If mode == mirror_completed, the prior run id being mirrored
value: ${{ jobs.gate.outputs.prev_run_id }}

permissions:
actions: read
contents: read

jobs:
gate:
name: Gate and determine mode
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.check.outputs.mode }}
target_run_id: ${{ steps.check.outputs.target_run_id }}
prev_conclusion: ${{ steps.check.outputs.prev_conclusion }}
prev_run_id: ${{ steps.check.outputs.prev_run_id }}
steps:
- name: Determine mode
id: check
uses: actions/github-script@v7
with:
script: |
const requireApproval = core.getInput('require_approval') === 'true'
const allowDispatch = core.getInput('allow_workflow_dispatch') === 'true'
const workflowFilename = core.getInput('workflow_filename')
const explicitSha = (core.getInput('head_sha') || '').trim()

if (context.eventName === 'workflow_dispatch' && allowDispatch) {
core.setOutput('mode', 'run')
return
}

if (requireApproval) {
const approved = (context.eventName === 'pull_request_review') &&
(context.payload.review?.state?.toLowerCase() === 'approved')
if (!approved) {
core.setOutput('mode', 'noop')
return
}
}

const sha = explicitSha || context.payload.pull_request?.head?.sha
if (!sha) {
core.setOutput('mode', 'run')
return
}

const resp = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowFilename,
head_sha: sha,
per_page: 50,
})

const currentRunId = context.runId
const runs = (resp.data.workflow_runs || []).filter(r => r.id !== currentRunId)
const active = runs.find(r => ['queued', 'in_progress'].includes(r.status))
if (active) {
core.setOutput('mode', 'wait')
core.setOutput('target_run_id', String(active.id))
core.setOutput('prev_run_id', String(active.id))
return
}

const completed = runs
.filter(r => r.status === 'completed')
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]
if (completed) {
core.setOutput('mode', 'mirror_completed')
core.setOutput('prev_conclusion', completed.conclusion || '')
core.setOutput('prev_run_id', String(completed.id))
return
}

core.setOutput('mode', 'run')

wait-previous:
name: Wait for previous run
needs: gate
if: needs.gate.outputs.mode == 'wait'
runs-on: ubuntu-latest
steps:
- name: Wait and mirror
uses: actions/github-script@v7
with:
script: |
const MAX_WAIT_MS = 60 * 60 * 1000 // 1 hour
const POLL_INTERVAL_MS = 15 * 1000 // 15 seconds
const runId = Number('${{ needs.gate.outputs.target_run_id }}')
const poll = async () => {
const { data } = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
})
return data
}
let data = await poll()
const started = Date.now()
while (data.status !== 'completed') {
if (Date.now() - started > MAX_WAIT_MS) {
core.setFailed('Timeout waiting for prior run')
return
}
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
data = await poll()
}
const conclusion = data.conclusion || 'failure'
if (conclusion !== 'success') {
core.setFailed(`Mirroring prior run conclusion: ${conclusion}`)
}

mirror-completed:
name: Mirror completed run
needs: gate
if: needs.gate.outputs.mode == 'mirror_completed'
runs-on: ubuntu-latest
steps:
- name: Mirror result
run: |
echo "Previous conclusion: ${{ needs.gate.outputs.prev_conclusion }}"
echo "Mirroring workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ needs.gate.outputs.prev_run_id }}"
if [ "${{ needs.gate.outputs.prev_conclusion }}" != "success" ]; then
echo "Mirroring failure"
exit 1
fi
echo "Mirroring success"
15 changes: 0 additions & 15 deletions .github/workflows/tests-e2e-approve.yml

This file was deleted.

87 changes: 56 additions & 31 deletions .github/workflows/tests-e2e.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
name: ✅ E2E Tests
run-name: >
E2E • ${{ github.event_name == 'pull_request_review'
&& format('PR #{0} {1}', github.event.pull_request.number, github.event.pull_request.title)
|| github.ref_name }} • by ${{ github.actor }}

on:
pull_request:
types: [labeled]
pull_request_review:
types: [submitted]

workflow_dispatch:
inputs:
Expand All @@ -11,71 +15,92 @@ on:
default: false
type: boolean

permissions:
actions: read
contents: read

# Cancel a previous run workflow
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-e2e
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.ref }}-e2e
cancel-in-progress: false

jobs:
# E2E Approve
e2e-approve:
runs-on: ubuntu-latest
if: github.event.action == 'labeled' && contains(github.event.label.name, 'e2e-approved') || github.event_name == 'workflow_dispatch'
name: Approve E2E tests
steps:
- name: Add "e2e-approved" Label
uses: actions-ecosystem/action-add-labels@v1
with:
labels: e2e-approved
approval-dedupe:
uses: ./.github/workflows/approval-dedupe.yml
secrets: inherit
with:
workflow_filename: tests-e2e.yml
require_approval: true
allow_workflow_dispatch: true

# E2E Docker
build-docker:
needs: approval-dedupe
if: needs.approval-dedupe.outputs.mode == 'run'
uses: ./.github/workflows/pipeline-build-docker.yml
needs: e2e-approve
secrets: inherit
with:
debug: ${{ inputs.debug || false }}
for_e2e_tests: true

e2e-docker-tests:
needs: build-docker
uses: ./.github/workflows/tests-e2e-docker.yml
build-appimage:
needs: approval-dedupe
if: needs.approval-dedupe.outputs.mode == 'run'
uses: ./.github/workflows/pipeline-build-linux.yml
secrets: inherit
with:
target: build_linux_appimage_x64
debug: ${{ inputs.debug || false }}

tests-e2e-playwright:
needs: build-docker
uses: ./.github/workflows/tests-e2e-playwright.yml
builds-complete:
needs: [build-docker, build-appimage]
Comment on lines +54 to +55
Copy link
Collaborator Author

@KrumTy KrumTy Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intermediate job to wait for both builds to complete (~5mins) to avoid running tests when the other build has failed and the whole workflow will be marked as failed in the end anyway

runs-on: ubuntu-latest
steps:
- name: Both builds finished
run: echo "Docker and AppImage builds completed"

e2e-docker-tests:
needs: builds-complete
uses: ./.github/workflows/tests-e2e-docker.yml
secrets: inherit
with:
debug: ${{ inputs.debug || false }}

# E2E AppImage
build-appimage:
uses: ./.github/workflows/pipeline-build-linux.yml
needs: e2e-approve
tests-e2e-playwright:
needs: builds-complete
uses: ./.github/workflows/tests-e2e-playwright.yml
secrets: inherit
with:
target: build_linux_appimage_x64
debug: ${{ inputs.debug || false }}

e2e-appimage-tests:
needs: build-appimage
needs: builds-complete
uses: ./.github/workflows/tests-e2e-appimage.yml
secrets: inherit
with:
debug: ${{ inputs.debug || false }}

clean:
uses: ./.github/workflows/clean-deployments.yml
if: always()
needs: [e2e-docker-tests, e2e-appimage-tests, tests-e2e-playwright]
if: always() && needs.approval-dedupe.outputs.mode == 'run'
needs:
[
approval-dedupe,
e2e-docker-tests,
e2e-appimage-tests,
tests-e2e-playwright,
]

# Remove artifacts from github actions
remove-artifacts:
name: Remove artifacts
needs: [e2e-docker-tests, e2e-appimage-tests, tests-e2e-playwright]
if: needs.approval-dedupe.outputs.mode == 'run'
needs:
[
approval-dedupe,
e2e-docker-tests,
e2e-appimage-tests,
tests-e2e-playwright,
]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
Loading