mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 18:38:54 +00:00
Based on the later commits (after the earlier experimentation and reversions), the meaningful end state of the PR is: **Summary:** Improve E2E CI reliability, performance, and security by introducing robust build caching with safe fingerprinting and cache isolation, duration-based test sharding with graceful fallbacks, more reliable E2E impact detection and status handling, stronger cancellation/concurrency behavior, multiple iOS Detox stability fixes, and hardening GitHub Actions to avoid executing PR-authored code. Also includes CI fixes for iOS builds and a temporary Android E2E test skip for a known regression. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mattermost Build <build@mattermost.com>
700 lines
34 KiB
YAML
700 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. Mandatory E2E for fork
|
|
# PRs is enforced separately.
|
|
#
|
|
# 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
|
|
|
|
concurrency:
|
|
group: e2e-label-manager-${{ github.event.pull_request.number }}
|
|
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: ''
|