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
13 changes: 13 additions & 0 deletions .github/workflows/android-webview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ concurrency:

jobs:
build:
name: android-webview
if: ${{ github.event.action != 'converted_to_draft' }}
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR running on the
Expand All @@ -49,6 +50,18 @@ jobs:

- name: Set up Android SDK
uses: android-actions/setup-android@v4
with:
# Drop the action's default `tools platform-tools`. `tools` is an
# obsolete SDK package that Google has removed from the repository,
# and it is what broke this job: the runner image ships cmdline-tools
# 12.0, the action decides that is the "wrong version" and downloads
# 20.0, and 20.0 cannot resolve `tools` at all --
# `Warning: Failed to find package 'tools'`, then exit 1. Licence
# acceptance was never the problem; it succeeded on the line above.
#
# `platform-tools` is the half that is real (adb and friends). Gradle
# resolves the build-tools and platform it needs on its own.
packages: platform-tools

- name: Gradle build + unit tests
run: ./gradlew --no-daemon --stacktrace testReleaseUnitTest assembleRelease
2 changes: 1 addition & 1 deletion .github/workflows/changesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defaults:

jobs:
check:
name: check
name: changesets
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ on:
# CI. Only paths that cannot possibly affect this workflow belong below.
#
# Safe against the "required check never reports" trap: the one required
# context on main is `check`, and changesets.yml publishes a job named
# `check` with no path filter at all, so a PR touching only ignored paths
# still gets a `check` and does not sit pending forever.
# context on main is `check`, and required-check.yml publishes it with no
# path filter at all, so a PR touching only ignored paths still gets a
# `check` and does not sit pending forever.
#
# That role used to be filled by changesets.yml, which published a job also
# *named* `check` -- which is precisely why the required context resolved
# from whichever of seven same-named jobs reported first, and why this
# paragraph used to describe the defect as the safeguard.
paths-ignore:
- '.changeset/**' # release metadata; nothing here reads it
- '.github/ISSUE_TEMPLATE/**'
Expand Down Expand Up @@ -51,6 +56,7 @@ defaults:

jobs:
check:
name: cypress
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ios-webview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ concurrency:

jobs:
build:
name: ios-webview
if: ${{ github.event.action != 'converted_to_draft' }}
# Hosted, deliberately: this repository is public, so a fork PR running on
# the self-hosted macOS runner would execute arbitrary code on it.
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ on:
# CI. Only paths that cannot possibly affect this workflow belong below.
#
# Safe against the "required check never reports" trap: the one required
# context on main is `check`, and changesets.yml publishes a job named
# `check` with no path filter at all, so a PR touching only ignored paths
# still gets a `check` and does not sit pending forever.
# context on main is `check`, and required-check.yml publishes it with no
# path filter at all, so a PR touching only ignored paths still gets a
# `check` and does not sit pending forever.
#
# That role used to be filled by changesets.yml, which published a job also
# *named* `check` -- which is precisely why the required context resolved
# from whichever of seven same-named jobs reported first, and why this
# paragraph used to describe the defect as the safeguard.
paths-ignore:
- '.changeset/**' # release metadata; nothing here reads it
- '.github/ISSUE_TEMPLATE/**'
Expand Down Expand Up @@ -46,6 +51,7 @@ defaults:

jobs:
check:
name: lint
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pinned-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defaults:

jobs:
check:
name: pinned-versions
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/provider_image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ defaults:

jobs:
check:
name: provider-image
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# other repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
Expand Down
150 changes: 150 additions & 0 deletions .github/workflows/required-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: required-check

# The single required status context for main, and the only job in the repo
# named `check`.
#
# Before this existed, SEVEN workflows each published a job called `check` --
# changesets, cypress, lint, pinned-versions, provider_image, tests and
# typecheck. GitHub collapses same-named check runs into one context and
# resolves it from whichever reported, so a green `check` from the cheapest of
# the seven satisfied the requirement while the other six were still running.
# The path-filter comments in those workflows even described this as the safety
# mechanism that stopped a docs-only PR hanging: it was, and it was also the
# hole. Another of our repos had the same defect and merged a failing PR through
# it, which is how this was found.
#
# The other seven are now named after their workflow, so each reports its own
# distinct context and nothing can mask anything else. This job takes over the
# `check` name and earns it: it waits for every other check run on the commit
# and fails if any of them failed.
#
# Deliberately NO paths filter. It is the required context, so it MUST report on
# every pull request -- a required context that never reports leaves the PR
# blocked forever, which is exactly the trap that stops the other seven from
# being required directly (they are path-filtered, so a docs-only PR skips them
# entirely and they never report at all).

on:
pull_request:
# `main` and release branches only. `dev` and `staging` are dead -- last
# commits 2024 and 2025, no open PR targets either, and in Protect the
# branches do not exist at all -- so there is nothing to gate there.
branches: [main, release/*]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
# The merge queue is not enabled here today. Supported anyway: inside a queue a
# required context that never reports leaves the entry stalled until it is
# ejected, and that is cheaper to cover now than to debug the first time
# someone turns the queue on.
merge_group:

permissions:
contents: read
checks: read
statuses: read
pull-requests: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash

jobs:
check:
name: check
# Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the
# private repos: this repository is public, so a fork PR would run on the
# self-hosted fleet -- executing arbitrary contributor code on hardware we
# own, where it can also persist and poison later trusted jobs. This job is
# pure API polling and costs seconds of compute either way.
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
timeout-minutes: 90
steps:
- name: Wait for every other check on this commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
run: |
set -euo pipefail

# Poll rather than use `needs:`, because `needs` cannot reach across
# workflows -- and the suites that matter (tests, cypress, typecheck,
# security-scan) each live in their own workflow file.
deadline=$(( SECONDS + 80 * 60 ))
# Give the other workflows a moment to register their check runs, so
# "nothing else has reported yet" is not mistaken for "all done".
grace=$(( SECONDS + 120 ))

# One commit can carry several check runs with the SAME name: a manual
# re-run, or -- far more often -- a run cancelled by its workflow's
# concurrency group the instant a replacement starts. The API's
# `filter=latest` only dedupes within a single check suite, and each
# workflow run is its own suite, so both the dead and the live copy
# come back. Keep the highest id per name; check run ids increase
# monotonically, so that one is the live copy.
#
# This is why no two jobs in this repo may share a display name: the
# dedupe is by name, so a second workflow reusing a name would be
# silently dropped here. `build` was shared by android-webview and
# ios-webview and both are renamed.
latest_runs() {
gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/check-runs?per_page=100" --paginate \
--jq '.check_runs[] | [.id, .status, (.conclusion // "-"), .name] | @tsv' 2>/dev/null \
| awk -F'\t' '
NF>=4 && $4!="check" {
if (!($4 in id) || $1+0 > id[$4]) { id[$4]=$1+0; st[$4]=$2; cc[$4]=$3 }
}
END { for (n in id) printf "%s\t%s\t%s\n", st[n], cc[n], n }'
}

failures() {
awk -F'\t' '$1=="completed" && $2!="success" && $2!="skipped" && $2!="neutral" {print $3"="$2}' | sort -u
}

while :; do
runs=$(latest_runs || true)

total=$(printf '%s' "$runs" | grep -c . || true)
pending=$(printf '%s\n' "$runs" | awk -F'\t' '$1!="completed"' | grep -c . || true)
bad=$(printf '%s\n' "$runs" | failures || true)

echo "checks: ${total} total, ${pending} pending"

# Fail as soon as something has definitively failed -- no point
# holding a runner for another hour to confirm the obvious. But
# re-read once first: a cancel and its replacement are not atomic,
# so a poll landing in that gap sees only the cancelled copy.
if [ -n "$bad" ]; then
sleep 30
bad=$(latest_runs | failures || true)
if [ -n "$bad" ]; then
echo "::error::failing checks: $(printf '%s' "$bad" | tr '\n' ' ')"
exit 1
fi
echo "transient failure cleared on re-read, still waiting"
continue
fi

# `total -eq 0` is a pass once the grace period is over, not a wait.
# Requiring `total > 0` here would loop to the 80 minute deadline and
# then FAIL if no other check ever reported. Nothing reaches that
# state today -- changesets, pinned-versions and provider_image all
# run unfiltered, so something always reports -- but "no other checks
# exist on this commit" is a green condition, and a required gate
# should not be one path filter away from blocking every PR.
if [ "$pending" -eq 0 ] && [ "$SECONDS" -gt "$grace" ]; then
echo "all ${total} checks passed or were skipped"
exit 0
fi

if [ "$SECONDS" -gt "$deadline" ]; then
echo "::error::timed out waiting for checks; ${pending} still pending"
printf '%s\n' "$runs" | awk -F'\t' '$1!="completed"{print " pending: "$3}'
exit 1
fi

sleep 20
done
Loading
Loading