mattermost-mobile-upstream/.github/workflows/e2e-label-manager.yml
yasser khan 3e1331248c
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
ci(e2e): say whether each main-run failure is new, still failing, flaky or a setup problem (#10284)
* ci(e2e): say whether each main-run failure is new, still failing, flaky or a setup problem, from the triage verdict

The channel message judged failures from their error text, beside the E2E triage
jobs that judge the same failures from TSIO history, and the two disagreed: on
main run 40764686027 it listed tests that passed on retry as needing a look. The
message now waits for triage and reads each run's findings. On main, a test that
passed every earlier main run and fails now is new, with the changes merged since
the last main run named (TSIO's previous run, GitHub's compare); a run where most
tests failed at once is called out first. Runs with no triage verdict keep the
error-text judgement.

* ci(e2e): judge from the error text each failed test triage named no verdict for, and say how many were not checked

* ci(e2e): count each test once per run, so a test that failed and then passed its retry is flaky, not a failure

* ci(e2e): a main-run failure triage calls flaky or a test problem is not blamed on new changes; a same-commit re-run says nothing was merged; a long PR list says it is partial

* Fix TSIO current report selection

Co-authored-by: yasserfaraazkhan <15979783+yasserfaraazkhan@users.noreply.github.com>

* Fix lint comment spacing

Co-authored-by: yasserfaraazkhan <15979783+yasserfaraazkhan@users.noreply.github.com>

* ci(e2e): update the TSIO upload action, so a failed screenshot upload no longer drops a shard's results

* ci(e2e): an earlier attempt of the same main run is not the previous run, so a re-run still lists the changes merged since the last run

* ci(e2e): drop the note from the TSIO upload action pin comment

* ci(e2e): a re-run replaces the first attempt's triage findings, so the channel message never posts stale verdicts

* ci(e2e): the main-run message says in a few lines which failures may be product bugs and which are not, and posts nothing when none may be

* ci(e2e): main and CMT call a failure a product bug only when it fails again on a fresh device, and clear what the error text proves is not the app

* ci(e2e): a re-run hands Android Detox and Maestro results to the channel message too

* ci(e2e): CMT re-runs start after a failed lane; a spec that differs from main, or a feature the server has off, is not a product bug

* ci(e2e): the CMT message plans the re-run as the planner job does, leaving out tests that differ from main

* ci(e2e): one channel post per run, and a test's name can never mention the channel

* ci(e2e): a re-run keeps the run's own report and runs only the failed flows

* ci(e2e): a PR run posts to the channel only when something failed

* ci(e2e): an E2E/Override PR keeps its statuses on a new push; a few simpler lines in the channel notify

* ci(e2e): exclude isolated report-problem flows from reruns and prevent label/action concurrency collisions

<!-- coderabbit-ai-generated:v1 -->

* ci(e2e): pass support paths to E2E triage

* ci(e2e): pin TSIO report-upload to release-0.16.0 (main@3212731)

Brings the TSIO action pin up to the latest release, which includes
mattermost-test-system-io#129, #130 and #124. The report-upload action itself
is unchanged between the two pins.

* ci(e2e): re-run E2E jobs once when a lane is red only on infrastructure

Triage (rerun-attempts: 1, with actions: read to read the failed jobs) asks for
one re-run when a lane is red only because jobs died or reports never arrived;
a build or test-server failure holds it. e2e-tests-rerun.yml carries the re-run
out once the run finishes. CMT keeps its own re-run of failed specs.

---------
2026-11-03 07:45:59 +05:30

703 lines
34 KiB
YAML

# Matterwick triggers E2E only on pull_request:labeled (see matterwick/server/pull_request.go).
# synchronize alone does nothing — on push we must cancel stale runs, remove E2E/Run*,
# and re-add E2E/Run so Matterwick receives a fresh labeled webhook.
#
# 1. detect-e2e-impact (opened|synchronize):
# Path-filter; skip E2E when the PR has no app-impacting files.
#
# 2. cancel-stale-e2e-on-push (synchronize):
# Force-cancel in-flight E2E for the previous commit (even on docs-only pushes).
# No-op for E2E/Override PRs on the same push — see refresh-e2e-run-label below.
#
# 3. refresh-e2e-run-label (synchronize, app-impacting):
# Remove + re-add E2E/Run via PAT for Matterwick.
# When E2E/Override is set it instead only strips the run labels (no dispatch)
# and hands off to repost-override-status, which re-posts the bypass on the
# new head SHA — commit statuses are keyed by SHA, so without that re-post the
# pushed commit has no e2e-test/* statuses and required checks would block the
# PR. The re-post is a separate job so PR-authored action code never shares a
# runner with the PAT.
#
# 4. add-e2e-run-label (opened, app-impacting):
# Add E2E/Run unless already present. Skips when E2E/Override is set.
#
# 5. Honor E2E/Override (labeled E2E/Override):
# Removes all E2E/Run* labels so no new run is dispatched, cancels the
# in-flight run, then marks success on the required e2e-test/* contexts that
# are actually blocking — pending, failing, or with no status yet. A platform
# that is genuinely green is left with its own result, and a failing one keeps
# its pass/fail counts plus an "- e2e overridden" suffix, so the checks list
# still shows what really happened.
# The run is cancelled because merging the PR triggers Matterwick's `closed`
# cleanup, which destroys the test servers mid-run — the surviving run would
# report infra teardown as a test failure into TSIO's per-spec history. A
# late post from the cancelled run cannot re-red the check anyway: its
# finalize step re-checks the label (see .github/actions/e2e-override-label)
# and tsio-report-status converts failure → success while it is set.
#
# 6. Honor E2E/Verified (labeled/unlabeled E2E/Verified):
# A maintainer applies this after reading a real E2E failure and judging it
# known flake, not a regression. Marks only the currently-failing e2e-test/*
# contexts success, keeping the real pass/fail description and tagging who
# verified it — passing platforms are left untouched. Removing the label
# resets exactly the contexts this action touched (matched by a marker in
# the description) back to pending, so the waiver never outlives the label
# and never clobbers a platform it didn't verify. Does not carry across a
# push: a new commit gets a fresh run and a fresh human judgment, unlike
# E2E/Override.
#
# Fork PR guard (github.event.pull_request.head.repo.full_name == github.repository)
# is applied per-step. Fork PR contexts only get a read-only GITHUB_TOKEN, so
# addLabels / removeLabel / run cancellation would all 403. Fork PRs are handled by
# e2e-fork-policy.yml.
#
# Base-branch guard: auto E2E/Run refresh only runs for PRs targeting main.
# Stacked PRs (base != main) are skipped so Matterwick is not triggered on every
# intermediate-branch push.
#
name: E2E label manager
on:
pull_request:
types:
- opened
- synchronize
- labeled
- unlabeled
# A push supersedes only an older push, and a label event only an older event for the same label. One group per PR
# let the labels bots set right after a push cancel that push's run, so an E2E/Override PR never got its statuses
# back on the new commit and its required checks waited forever.
concurrency:
group: e2e-label-manager-${{ github.event.pull_request.number }}-${{ github.event.label.name && format('label-{0}', github.event.label.name) || format('action-{0}', github.event.action) }}
cancel-in-progress: true
jobs:
# Skip E2E when PR has no app-impacting files.
detect-e2e-impact:
if: >-
(github.event.action == 'synchronize' || github.event.action == 'opened')
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
outputs:
app_impacting: ${{ steps.filter.outputs.app_impacting }}
steps:
# Need the PR's paths.json so fingerprint + impact filter share one list.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
sparse-checkout: |
.github/actions/compute-e2e-build-fingerprint
# Enterprise action allowlist blocks dorny/paths-filter; use github-script
# (allowlisted) + pulls.listFiles. Same semantics as dorny on pull_request:
# compare the FULL PR file list vs base, not only the latest push.
# Patterns come from compute-e2e-build-fingerprint/paths.json (not inlined)
# so they cannot drift from the S3 build fingerprint.
- name: Detect app-impacting paths
id: filter
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const detect = async () => {
const fs = require('fs');
const path = require('path');
// paths.json is read as DATA, never require()d, and paths.js is never
// loaded here. Everything under the sparse checkout comes from the PR
// head, so require()ing any of it would execute PR-authored JavaScript
// on the runner — arbitrary code with this job's authenticated API
// client. JSON.parse cannot execute. (The job is already restricted to
// same-repo PRs by the head.repo check above, so this is defence in
// depth rather than the only barrier.)
const paths = JSON.parse(fs.readFileSync(
path.join(
process.cwd(),
'.github/actions/compute-e2e-build-fingerprint/paths.json',
),
'utf8',
));
// Mirrors impactGlobs() in compute-e2e-build-fingerprint/paths.js —
// the single rule is "a git ls-tree dir entry (trailing /) becomes
// dir/**". paths.json stays the one source of truth for the list
// itself, which is the drift that actually matters; paths.test.js
// covers the same conversion on the action side.
const toGlob = (entry) => (entry.endsWith('/') ? `${entry}**` : entry);
const fingerprintGlobs = [
...paths.fingerprintShared,
...Object.values(paths.fingerprintByPlatform).flat(),
].map(toGlob);
// Hardcoded, NOT read from paths.json: the pattern list itself comes
// from the PR head, so a PR that trimmed its own paths.json could
// otherwise make that edit look non-impacting and skip E2E.
const ALWAYS_IMPACTING = ['.github/actions/compute-e2e-build-fingerprint/**'];
const patterns = [...new Set([...ALWAYS_IMPACTING, ...fingerprintGlobs, ...paths.triggerExtra])];
// Supports the subset used in paths.json: `**` spans path separators,
// a single `*` does not, everything else is literal. Not a general
// glob engine — brace expansion, `?` and negation are not implemented
// and would match literally, so keep paths.json to that subset.
const globToRegExp = (pattern) => {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '@@GLOBSTAR@@')
.replace(/\*/g, '[^/]*')
.replace(/@@GLOBSTAR@@/g, '.*');
return new RegExp(`^${escaped}$`);
};
const matchers = patterns.map(globToRegExp);
const matches = (filePath) => matchers.some((re) => re.test(filePath));
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const hit = files.find((f) => matches(f.filename) || (f.previous_filename && matches(f.previous_filename)));
return {appImpacting: Boolean(hit), hit, count: files.length};
};
const prNumber = context.payload.pull_request.number;
let result;
try {
result = await detect();
core.info(`PR #${prNumber}: ${result.count} file(s), app_impacting=${result.appImpacting}${result.hit ? ` (hit: ${result.hit.filename})` : ''}`);
} catch (err) {
// Fail closed by running E2E, and do NOT rethrow. Throwing fails this
// job, which gates both the run-E2E path and the skip-status path — the
// PR would then sit with the required e2e-test/* contexts unreported and
// no self-service fix. Treating an unreadable file list as impacting
// costs one E2E run; the alternative silently ships untested code or
// wedges the PR. Realistic trigger is a pulls.listFiles hiccup, since a
// malformed paths.json is caught by paths.test.js before merge.
core.warning(`Impact filter failed, running E2E to be safe: ${err.message}`);
result = {appImpacting: true};
}
core.setOutput('app_impacting', result.appImpacting ? 'true' : 'false');
# Always cancel superseded runs on synchronize — including docs-only pushes —
# so a prior app-impacting run does not keep burning minutes after skip statuses.
# This is NOT covered by e2e-detox-pr.yml's `concurrency:` group: that group only
# fires when a NEW dispatch supersedes an old one, and a docs-only push here
# dispatches nothing at all (post-skip-status handles it instead) — so a prior
# app-impacting run would otherwise run to completion for a result nobody reads.
#
# Inlined rather than reusing a composite action: E2E/Override's cancel-on-apply
# path (.github/actions/cancel-e2e-runs) was removed when Override moved to a
# never-cancel model, and this is now the only remaining caller of the
# "force-cancel stale runs for a branch" behavior. The removed action's
# default-branch guard (no pr_number + branch == default) is not reproduced here
# because it is unreachable for this caller: this job only runs on `pull_request`
# synchronize, which always carries a PR number.
cancel-stale-e2e-on-push:
needs: detect-e2e-impact
if: >-
always()
&& needs.detect-e2e-impact.result == 'success'
&& github.event.action == 'synchronize'
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
permissions:
actions: write
statuses: write
steps:
- name: Force-cancel in-flight E2E runs for the previous commit
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const headBranch = context.payload.pull_request.head.ref;
const excludeHeadSha = context.payload.pull_request.head.sha;
const reason = 'E2E superseded by new commit push';
const ACTIVE_RUN_STATUSES = ['in_progress', 'queued', 'waiting'];
const E2E_STATUS_CONTEXTS = [
'e2e-test/detox-ios',
'e2e-test/detox-android',
'e2e-test/detox-ipad',
'e2e-test/maestro-ios',
'e2e-test/maestro-android',
];
const {data: workflows} = await github.rest.actions.listRepoWorkflows({owner, repo});
const e2eWorkflow = workflows.workflows.find((workflow) => workflow.name === 'E2E');
if (!e2eWorkflow) {
core.info('No E2E workflow found in repository');
return;
}
const runsToCancel = new Map();
for (const status of ACTIVE_RUN_STATUSES) {
const {data} = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: e2eWorkflow.id, status, branch: headBranch,
});
for (const run of data.workflow_runs) {
const matchesPr = !run.pull_requests?.length ||
run.pull_requests.some((pr) => pr.number === prNumber);
if (!matchesPr) {
core.info(`Skipping run ${run.id}: not associated with PR #${prNumber}`);
continue;
}
if (run.head_sha === excludeHeadSha) {
core.info(`Skipping run ${run.id}: already on current commit ${excludeHeadSha}`);
continue;
}
runsToCancel.set(run.id, run);
}
}
if (runsToCancel.size === 0) {
core.info(`No active E2E runs found for PR #${prNumber} on branch ${headBranch}`);
return;
}
const shasToUpdate = new Set();
for (const run of runsToCancel.values()) {
core.info(`Cancelling E2E run ${run.id} (${run.status}, sha ${run.head_sha})`);
try {
await github.rest.actions.cancelWorkflowRun({owner, repo, run_id: run.id});
// The plain cancel above is what stops the expensive build jobs —
// they are `if: !cancelled()`, so cancelling the run ends them.
// What it does NOT stop is anything gated on `always()`, which
// keeps running through a cancellation (tsio-channel-notify, and
// the artifact-upload steps inside the build jobs). Run-level
// force-cancel stops those too.
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', {
owner, repo, run_id: run.id,
});
shasToUpdate.add(run.head_sha);
} catch (err) {
core.warning(`Cancelling run ${run.id}: ${err.message}`);
}
}
// Only reset statuses for runs actually stopped — a run still going
// will keep publishing its own results.
for (const sha of shasToUpdate) {
for (const statusContext of E2E_STATUS_CONTEXTS) {
try {
await github.rest.repos.createCommitStatus({
owner, repo, sha, state: 'pending', context: statusContext,
description: reason.substring(0, 140),
});
} catch (err) {
core.warning(`createCommitStatus ${statusContext} on ${sha}: ${err.message}`);
}
}
}
refresh-e2e-run-label:
needs:
- detect-e2e-impact
- cancel-stale-e2e-on-push
if: >-
always()
&& needs.detect-e2e-impact.result == 'success'
&& needs.detect-e2e-impact.outputs.app_impacting == 'true'
&& github.event.action == 'synchronize'
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
outputs:
e2e_override: ${{ steps.refresh.outputs.e2e_override }}
steps:
# cancel-stale-e2e-on-push (a `needs` of this job) already force-cancels the
# previous commit's run. Repeating it here would double-cancel and post the
# commit status twice. Nothing in this job's script reads repo files, so
# there is no checkout step.
- name: Remove and re-add E2E/Run for Matterwick
id: refresh
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const runLabels = ['E2E/Run', 'E2E/Run-iOS', 'E2E/Run-Android'];
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: prNumber,
});
const labelNames = labels.map((label) => label.name);
if (labelNames.includes('E2E/Override')) {
core.info(`PR #${prNumber} has E2E/Override — stripping run labels only.`);
for (const label of runLabels) {
if (!labelNames.includes(label)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
}
// Signal the re-post step to carry the override onto this new commit,
// whose SHA has no e2e-test/* statuses yet (statuses are keyed by SHA).
core.setOutput('e2e_override', 'true');
return;
}
// Remove first so re-adding always emits pull_request:labeled for Matterwick.
for (const label of runLabels) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
core.info(`Removed ${label} from PR #${prNumber} before refresh.`);
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
}
core.info(`PR #${prNumber} synchronize — re-adding E2E/Run (Matterwick labeled hook).`);
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber,
labels: ['E2E/Run'],
});
# Post success statuses when E2E is skipped so the PR is not blocked. Reuses
# e2e-override-status (a generic "post success with this description on all 5
# required contexts" tool) instead of hardcoding the context list a second time
# — the whole point of single-sourcing E2E_STATUS_CONTEXTS is that this list
# cannot drift from branch protection's required checks.
post-skip-status:
needs: detect-e2e-impact
if: >-
always()
&& needs.detect-e2e-impact.result == 'success'
&& needs.detect-e2e-impact.outputs.app_impacting != 'true'
&& (github.event.action == 'synchronize' || github.event.action == 'opened')
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
permissions:
contents: read
statuses: write
steps:
# Base, not the PR head. `uses: ./.github/actions/...` loads the composite
# action from the checked-out workspace, so a PR-head checkout would run
# PR-authored action code in a job holding write scopes — and the status
# actions also require() detox/utils/build-tsio-job-config.js from it.
# Nothing here needs PR content: the SHAs come from the event payload.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Post skip status for all E2E contexts
uses: ./.github/actions/e2e-override-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
description: E2E skipped — no app-impacting changes (remove+re-add E2E/Run to force)
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Override is SHA-scoped: a commit pushed after the label was applied has no
# e2e-test/* statuses, so a required check would block it. Matterwick is not
# dispatched (run labels were stripped), so re-post the bypass on the new head
# SHA. Idempotent with the label-manager's `labeled` post.
#
# Separate job on purpose. Checking out the PR and running a local action means
# running PR-authored code, so it must not share a runner with the step that
# receives MATTERMOST_BUILD_GH_TOKEN. This job holds no secret and only the two
# permissions the status post needs.
#
# No explicit app_impacting gate needed: this job needs refresh-e2e-run-label,
# and refresh-e2e-run-label's own `if` already requires app_impacting == 'true'.
# GitHub Actions skips a dependent job by default when its `needs` job is
# skipped (no `always()` here), so this is skipped for free on a non-impacting
# push — post-skip-status covers that case instead.
repost-override-status:
needs: refresh-e2e-run-label
if: needs.refresh-e2e-run-label.outputs.e2e_override == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
statuses: write
steps:
# Base, not the PR head. `uses: ./.github/actions/...` loads the composite
# action from the checked-out workspace, so a PR-head checkout would run
# PR-authored action code in a job holding write scopes — and the status
# actions also require() detox/utils/build-tsio-job-config.js from it.
# Nothing here needs PR content: the SHAs come from the event payload.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Re-post E2E/Override success on new commit
uses: ./.github/actions/e2e-override-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
add-e2e-run-label:
needs: detect-e2e-impact
if: >-
always()
&& needs.detect-e2e-impact.result == 'success'
&& needs.detect-e2e-impact.outputs.app_impacting == 'true'
&& github.event.action == 'opened'
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: Add E2E/Run (PAT — emits labeled webhook for Matterwick)
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const runLabels = ['E2E/Run', 'E2E/Run-iOS', 'E2E/Run-Android'];
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: prNumber,
});
const labelNames = labels.map((label) => label.name);
if (labelNames.includes('E2E/Override')) {
core.info(`PR #${prNumber} has E2E/Override label — skipping E2E/Run.`);
for (const label of runLabels) {
if (!labelNames.includes(label)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
core.info(`Removed stale ${label} from PR #${prNumber} (E2E/Override active).`);
} catch (err) {
if (err.status === 404) {
continue;
}
throw err;
}
}
return;
}
if (labelNames.includes('E2E/Run')) {
core.info(`PR #${prNumber} already has E2E/Run on open — skipping add.`);
return;
}
core.info(`PR #${prNumber} opened — adding E2E/Run via PAT (Matterwick labeled hook).`);
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber,
labels: ['E2E/Run'],
});
e2e-label-manager:
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
statuses: write
# Override cancels the in-flight run (see the cancel step below).
actions: write
steps:
# Base, not the PR head. `uses: ./.github/actions/...` loads the composite
# action from the checked-out workspace, so a PR-head checkout would run
# PR-authored action code in a job holding write scopes — and the status
# actions also require() detox/utils/build-tsio-job-config.js from it.
# Nothing here needs PR content: the SHAs come from the event payload.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
# ── Honor E2E/Override: strip run labels, then cancel in-flight E2E ─────
- name: Honor E2E/Override
if: >-
github.event.action == 'labeled'
&& github.event.label.name == 'E2E/Override'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const runLabels = ['E2E/Run', 'E2E/Run-iOS', 'E2E/Run-Android'];
for (const label of runLabels) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
core.info(`Removed ${label} from PR #${prNumber} (E2E/Override applied).`);
} catch (err) {
if (err.status === 404) {
core.info(`${label} was not set on PR #${prNumber}; nothing to remove.`);
continue;
}
throw err;
}
}
# Letting the run finish would not preserve a usable signal: merging the PR
# fires Matterwick's `closed` cleanup (matterwick server/pull_request.go:98 →
# e2e_tests.go:663 → destroyE2EInstances), which deletes the test servers out
# from under a still-running job regardless of whether the run labels were
# removed. The run then reports a red to TSIO that is infra teardown, not a
# test result — and TSIO per-spec history is what engineers read to decide
# whether a spec also fails on main, so recording infra failures there
# degrades flake detection for everyone. Cancel instead.
#
# Runs BEFORE the success post below so our status write lands last. A
# cancelled run's finalize step may still post after us; overrideCommitStatus
# in detox/utils/tsio-report-status.js converts failure → success while the
# label is set, so the check stays green and only the description differs.
- name: Cancel in-flight E2E runs
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.action == 'labeled'
&& github.event.label.name == 'E2E/Override'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const headBranch = context.payload.pull_request.head.ref;
const ACTIVE_RUN_STATUSES = ['in_progress', 'queued', 'waiting'];
const {data: workflows} = await github.rest.actions.listRepoWorkflows({owner, repo});
const e2eWorkflow = workflows.workflows.find((workflow) => workflow.name === 'E2E');
if (!e2eWorkflow) {
core.info('No E2E workflow found in repository');
return;
}
const runsToCancel = new Map();
for (const status of ACTIVE_RUN_STATUSES) {
const {data} = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: e2eWorkflow.id, status, branch: headBranch,
});
for (const run of data.workflow_runs) {
const matchesPr = !run.pull_requests?.length ||
run.pull_requests.some((pr) => pr.number === prNumber);
if (!matchesPr) {
core.info(`Skipping run ${run.id}: not associated with PR #${prNumber}`);
continue;
}
runsToCancel.set(run.id, run);
}
}
if (runsToCancel.size === 0) {
core.info(`No active E2E runs found for PR #${prNumber} on branch ${headBranch}`);
return;
}
for (const run of runsToCancel.values()) {
core.info(`Cancelling E2E run ${run.id} (${run.status}, sha ${run.head_sha}) — E2E/Override applied`);
try {
await github.rest.actions.cancelWorkflowRun({owner, repo, run_id: run.id});
// The plain cancel above is what stops the expensive build jobs —
// they are `if: !cancelled()`, so cancelling the run ends them.
// What it does NOT stop is anything gated on `always()`, which
// keeps running through a cancellation (tsio-channel-notify, and
// the artifact-upload steps inside the build jobs). Run-level
// force-cancel stops those too.
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', {
owner, repo, run_id: run.id,
});
} catch (err) {
core.warning(`Cancelling run ${run.id}: ${err.message}`);
}
}
# Override means "merge without waiting on E2E", so every required context
# that is still blocking has to go green on the PR head SHA — whether E2E
# never ran, already failed, or was just cancelled above. Statuses are keyed
# by SHA, so this also clears earlier failures. Contexts that already pass on
# their own are left alone, so the checks list keeps their real results.
# No statuses are posted from the cancel step; this is the single writer.
- name: Publish success on required E2E contexts
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.action == 'labeled'
&& github.event.label.name == 'E2E/Override'
uses: ./.github/actions/e2e-override-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Taking the escape hatch away must re-block the PR: without this the
# success posted above would linger on the head SHA forever.
- name: Reset required E2E contexts when E2E/Override is removed
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.action == 'unlabeled'
&& github.event.label.name == 'E2E/Override'
uses: ./.github/actions/e2e-override-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
state: pending
description: E2E/Override removed — re-run E2E for a real result
# No target_url on purpose. These contexts are E2E results, and at this
# moment there is no E2E run to link to — pointing Details at this
# label-manager run just lands the reader on a labelling job. The E2E
# run posts its own link over this as soon as it starts.
target_url: ''
# E2E/Verified is a narrower waiver than E2E/Override: a maintainer has read
# a real failure and judged it flaky, so only the currently-failing
# e2e-test/* contexts are marked success — a platform that is actually
# green is left alone.
- name: Publish success on flaky-verified E2E contexts
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.action == 'labeled'
&& github.event.label.name == 'E2E/Verified'
uses: ./.github/actions/e2e-verified-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
mode: apply
actor: ${{ github.event.sender.login }}
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Reset exactly what apply touched (matched by the marker in its own
# description), not every required context — a platform that was never
# verified must not be blanked back to pending just because the label
# was removed.
- name: Reset E2E contexts verified flaky when E2E/Verified is removed
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.action == 'unlabeled'
&& github.event.label.name == 'E2E/Verified'
uses: ./.github/actions/e2e-verified-status
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
mode: reset
# No target_url — same reason as the E2E/Override reset above.
target_url: ''