Skip to content

test(schedule): the quick-pick options need a frozen clock, not the runner's #2563

test(schedule): the quick-pick options need a frozen clock, not the runner's

test(schedule): the quick-pick options need a frozen clock, not the runner's #2563

Workflow file for this run

name: CI
on:
push:
# No branch-specific entry here, and it should stay that way. A long-lived
# branch was listed while the Postgres migration trunk existed, because on
# `pull_request` alone a run builds `refs/pull/N/merge`, which GitHub cannot
# create while the PR conflicts — so a conflicted branch dispatches NO run at
# all, which looks exactly like a green one. That branch has merged and been
# deleted; the escape hatch below covers the same failure mode generally.
branches: [main]
pull_request:
branches: [main]
# The escape hatch the comment above is missing. A push that dispatches NO run
# is indistinguishable from one whose run passed, and the three deploy
# workflows key on `workflow_run` of this one — so a skipped dispatch is a
# release that silently never happens, with nothing red to notice. Measured on
# `ea100282`: the only run GitHub recorded for that commit was CodeQL, this
# workflow never dispatched, and the merge sat undeployed with `main` green.
# Without this, the only way to recover is to push another commit.
workflow_dispatch:
permissions:
contents: read
# On a pull request a new push genuinely supersedes the old one, so cancelling
# is right. On main it is backwards: three deploy workflows (deploy-aws,
# deploy-frontends, deploy-mcp-aws) trigger on `workflow_run` of this workflow
# and every one of them requires `conclusion == 'success'`, so CI here is not a
# check — it is the release trigger, and a cancelled run is a release that never
# happens. Merges land faster than CI completes, so `cancel-in-progress: true`
# made the repo deploy LESS the busier it got: six of the last eight runs on
# main were cancelled, one after 54 seconds, leaving production 40+ minutes
# behind main.
#
# Gated on the event rather than on `github.ref != 'refs/heads/main'`. Both are
# correct for the two triggers this workflow declares, but they differ on
# triggers it does not have yet: a merge_group run's ref is
# `refs/heads/gh-readonly-queue/...`, so the ref test would resume cancelling
# the runs that gate a merge, reintroducing exactly this bug. The event test
# fails toward NOT cancelling, which costs runner minutes and never a release.
#
# The group deliberately still collapses every main push into one lane. That is
# what bounds the cost: GitHub cancels a previously *pending* run when a new one
# queues, so main holds at most one running plus one pending run rather than a
# thundering herd, and the pending one that survives is always the newest
# commit. Superseded commits are not stranded — deployment-scope.sh diffs
# against the `deployed/<target>` marker tag, not against the previous commit,
# so the next successful release carries the whole backlog.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
BUN_VERSION: '1.3.14'
NODE_VERSION: '22.17.0'
jobs:
quality:
name: Build, lint and typecheck
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Set up pinned Node runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install exact dependency graph
run: bun install --frozen-lockfile --ignore-scripts
- name: Verify workspace, build, lint and typecheck
run: bun run check
# After `check`, which builds shared-types: the Drizzle schema imports it,
# so a run before that would fail to load and — without the table-count
# floor in the script — would have reported "no changes" as success.
- name: Verify schema and migrations agree
run: bash .github/scripts/check-schema-migration-parity.sh
- name: Verify CI and deployment safeguards
run: |
bash -n .github/scripts/*.sh
bash .github/scripts/test-ci-scope.sh
bash .github/scripts/test-deploy-ecs-image.sh
bash .github/scripts/test-smoke-mention.sh
bash .github/scripts/test-check-schema-migration-parity.sh
bun .github/scripts/test-runtime-image-audit.mjs
bun run --cwd packages/frontend coverage:policy-selftest
# Ported from bluesky-social/social-app 513a188a2 ("Add a GitHub Action
# verifying lockfile against base", MIT (c) 2023-2026 Bluesky Social PBC).
#
# This exists because `bun install --frozen-lockfile`, which every other job
# here runs, cannot be trusted as the gate: bun 1.3.14 fails that flag
# non-deterministically on a clean, unmodified HEAD, so a failure proves
# nothing and gets learned-around. Resolving the base branch's lockfile
# against this revision's manifests and comparing to the committed file makes
# "this lockfile came from a clean resolve" a property of the commit instead.
#
# Kept as its own job rather than a step in `quality`, because it has to
# rewrite bun.lock in the workspace to do its work.
lockfile:
name: Lockfile resolves cleanly from the base
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# fetch-depth: 0 so the base revision's bun.lock is readable from this
# checkout; `git show <base>:bun.lock` is the whole mechanism.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: ${{ env.BUN_VERSION }}
# A pull request compares against the tip of its base branch. A push to
# main compares against what main pointed at beforehand, because this repo
# integrates batches locally and pushes once, so a lockfile change can
# reach main without ever having been in a pull request. A branch's first
# push has no before-commit and nothing to compare against.
- name: Resolve the base revision
id: base
env:
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
if [ -n "$BASE_REF" ]; then
echo "revision=origin/$BASE_REF" >> "$GITHUB_OUTPUT"
elif [ -n "$BEFORE_SHA" ] && git rev-parse --verify --quiet "$BEFORE_SHA^{commit}" >/dev/null; then
echo "revision=$BEFORE_SHA" >> "$GITHUB_OUTPUT"
else
echo "revision=" >> "$GITHUB_OUTPUT"
echo "::notice::no base revision to compare bun.lock against; skipping"
fi
- name: Verify bun.lock against the base revision
if: steps.base.outputs.revision != ''
env:
BASE_REVISION: ${{ steps.base.outputs.revision }}
run: bash .github/scripts/verify-lockfile.sh "$BASE_REVISION"
tests:
name: Test ${{ matrix.package }}
runs-on: ubuntu-latest
timeout-minutes: 35
# PostgreSQL, the backend's only data store. The suite creates ONE
# throwaway, fully-migrated database per run against this server
# (packages/backend/src/db/testDatabase.ts) and drops it afterwards.
#
# PostGIS, not plain Postgres: posts.geo and posts.content_geo are generated
# `geography` columns, so `CREATE EXTENSION postgis` has to succeed before the
# migration that names the type. Pinned on BOTH versions and identical to
# docker-compose.postgres.yml, so a developer's database and CI's cannot
# disagree about what is installed.
#
# Declared for every matrix entry rather than only the backend: a `services:`
# block cannot be made conditional on a matrix value, and an empty `image` is
# a hard error. The cost is one container start on three jobs that never
# connect to it.
services:
postgres:
image: postgis/postgis:17-3.5
env:
POSTGRES_USER: mention
POSTGRES_PASSWORD: mention
POSTGRES_DB: mention_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U mention -d mention_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# The harness creates its own throwaway database on this SERVER; it never
# writes to `mention_ci` itself.
TEST_DATABASE_URL: postgres://mention:mention@127.0.0.1:5432/mention_ci
strategy:
fail-fast: false
matrix:
include:
# Bun's coverage reporter only accounts for loaded modules and does not
# expose a branch-threshold gate. Keep these suites complete rather than
# publishing a misleading shared-types/MCP percentage.
- package: shared-types
command: bun run --cwd packages/shared-types test
coverage: false
# `--reporter=default` is restated because naming any reporter REPLACES
# the default set, and a CI log with only machine JSON in it is a log
# nobody can read. The JSON is what the collection gate below consumes.
- package: backend
command: bun run --cwd packages/backend test:coverage -- --coverage.reportsDirectory="$RUNNER_TEMP/backend-coverage" --reporter=default --reporter=json --outputFile="$RUNNER_TEMP/backend-report.json"
coverage: true
- package: frontend
command: bun run --cwd packages/frontend test:coverage -- --coverageDirectory="$RUNNER_TEMP/frontend-coverage"
coverage: true
- package: mcp
command: bun run --cwd packages/mcp test
coverage: false
steps:
# fetch-depth: 0 because the scope diff below needs both ends of the
# comparison. It used to carry a second, independent reason — a gate that
# resolved deleted Mongoose models out of git history and refused on a
# shallow clone rather than pass vacuously — which retired with the models
# themselves.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# Gating the STEPS rather than the job, deliberately. A `needs:` on a
# scope job measured 8s on this repo's deploy workflow and would sit on
# the critical path of every run, including the ones that skip nothing —
# while a leg that no-ops in its own runner costs nobody anything.
# It also keeps every check reporting `success` instead of `skipped`,
# which is what branch protection wants.
- name: Decide whether this package's suite must run
id: scope
env:
CI_SCOPE_BASE: ${{ github.base_ref && format('origin/{0}', github.base_ref) || github.event.before }}
run: bash .github/scripts/ci-scope.sh ${{ matrix.package }}
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.scope.outputs.run == 'true'
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Set up pinned Node test runtime
if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install exact dependency graph
if: steps.scope.outputs.run == 'true'
run: bun install --frozen-lockfile --ignore-scripts
- name: Build shared runtime package
if: steps.scope.outputs.run == 'true'
run: bun run build:shared-types
- name: Run complete package test suite
if: steps.scope.outputs.run == 'true'
run: ${{ matrix.command }}
# A test file that dies before its first `it()` contributes ZERO tests. It
# has no assertions to fail, so the pass RATIO improves: measured on this
# repository, 502 files / 5610 collected / 6 failed read as 99.9% passing
# while 32 files were dead at load and 296 declared tests never ran. Nothing
# about pass/fail can see that, and the number moves the reassuring way.
#
# `always()` so the gate still reports when the suite step failed — a load
# failure fails the suite too, and this is the step that says WHICH files
# and why the ratio lied. Backend only: it is the one leg emitting the JSON.
- name: Assert the suite collected what it claims to run
if: always() && matrix.package == 'backend' && steps.scope.outputs.run == 'true'
run: bun run check:suite-collection "$RUNNER_TEMP/backend-report.json" packages/backend/src
# The frontend's critical-path floor and no-regression ratchet. Kept out of
# `test:coverage` itself because CI appends `--coverageDirectory` to that
# script, and an appended flag lands on the LAST command of a chain — so
# `jest && policy` would hand the flag to the policy and instrument nothing.
#
# `always()`: jest's own thresholds can fail for a reason the policy would
# explain — which file, and how to reproduce it — and a step that runs only
# after a green suite never gets to say so.
#
# The base revision is the one the scope step diffs against. It is what
# stops a pull request lowering the recorded baseline in the same commit
# that removes the tests; the script REFUSES an unresolvable base rather
# than skipping, so a shallow clone fails loudly instead of vacuously.
- name: Enforce the frontend coverage policy
if: always() && matrix.package == 'frontend' && steps.scope.outputs.run == 'true'
env:
COVERAGE_POLICY_BASE: ${{ github.base_ref && format('origin/{0}', github.base_ref) || github.event.before }}
run: bun run --cwd packages/frontend coverage:check -- --coverage-dir "$RUNNER_TEMP/frontend-coverage"
- name: Upload coverage summary
if: always() && matrix.coverage && steps.scope.outputs.run == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mention-${{ matrix.package }}-coverage-${{ github.sha }}
path: ${{ runner.temp }}/${{ matrix.package }}-coverage/coverage-summary.json
if-no-files-found: error
retention-days: 14
# Populate the baseline from main so every pull request can restore the same
# trusted report instead of each one rebuilding its own. Job shape ported from
# bluesky-social/social-app 783dfe2a1 ("Cache main bundle stats for PR checks",
# MIT (c) 2023-2026 Bluesky Social PBC).
#
# This job and frontend-bundle are mutually exclusive on event type, so a main
# push still gets exactly one export and the absolute budgets stay enforced on
# both paths.
frontend-bundle-baseline:
name: Frontend bundle baseline
# `workflow_dispatch` rides the push path deliberately. A manual run is a
# run of `main`, so it should record a baseline exactly as a push does —
# and `CI complete` requires the bundle to have run on EXACTLY one of the
# two paths, so leaving a third event matching neither made the required
# check fail by construction on every dispatch. Measured: baseline=skipped
# pull-request=skipped on run 31127222964, with every other job green.
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
# fetch-depth: 0 so the scope diff below has both ends of the comparison.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# The bundle is built from packages/frontend, so it answers to the same
# scope question the frontend test leg does.
- name: Decide whether the bundle changed at all
id: scope
env:
CI_SCOPE_BASE: ${{ github.base_ref && format('origin/{0}', github.base_ref) || github.event.before }}
run: bash .github/scripts/ci-scope.sh frontend
# A hit means this exact commit was already measured by an earlier run of
# this job, and the cache is only written after the budgets pass, so
# skipping the rebuild cannot skip a failing gate.
- name: Restore bundle baseline for this commit
id: restore_baseline
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/mention-bundle-report.json
key: mention-bundle-baseline-${{ github.sha }}
# Every commit on main has to remain addressable as a baseline, because
# that is what frontend-bundle restores against — so a commit that changed
# nothing the bundle is built from carries the previous commit's
# measurement forward under its own key rather than being skipped
# outright. Skipping outright would push the cost onto the next pull
# request instead of removing it, by sending it down the fallback path
# that exports the base commit itself.
#
# Nothing here widens what the budgets cover: the scope says frontend when
# anything under packages/frontend or packages/shared-types moved, and its
# catch-all says frontend for the lockfile, the manifests, tsconfig and
# patches too. A miss (an evicted entry, a force push, the first run after
# this landed) falls through to the full export.
- name: Inherit the previous commit's baseline
id: inherit_baseline
if: steps.scope.outputs.run != 'true' && !steps.restore_baseline.outputs.cache-hit
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/mention-bundle-report.json
key: mention-bundle-baseline-${{ github.event.before }}
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Set up pinned Node bundle runtime
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install exact dependency graph
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
run: bun install --frozen-lockfile --ignore-scripts
- name: Build shared runtime package
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
run: bun run build:shared-types
- name: Export production web bundle
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
run: bun run --cwd packages/frontend build:analyze
env:
NODE_ENV: production
EXPO_PUBLIC_ENV: production
EXPO_PUBLIC_API_URL: https://api.mention.earth
- name: Validate static hosting contract
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
run: bun .github/scripts/validate-frontend-static-output.mjs packages/frontend/dist
- name: Enforce bundle budgets
if: ${{ !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit }}
run: >-
bun run --cwd packages/frontend analyze-bundle --
--ci
--json "${{ runner.temp }}/mention-bundle-report.json"
--markdown "${{ runner.temp }}/mention-bundle-summary.md"
- name: Publish bundle summary
if: always() && !steps.restore_baseline.outputs.cache-hit && !steps.inherit_baseline.outputs.cache-hit
run: |
if [ -f "$RUNNER_TEMP/mention-bundle-summary.md" ]; then
cat "$RUNNER_TEMP/mention-bundle-summary.md" >> "$GITHUB_STEP_SUMMARY"
fi
# Saves whichever report is on disk — the one just exported, or the one
# inherited from the previous commit.
- name: Save bundle baseline for this commit
if: ${{ !steps.restore_baseline.outputs.cache-hit }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/mention-bundle-report.json
key: mention-bundle-baseline-${{ github.sha }}
frontend-bundle:
name: Frontend bundle budget
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
# fetch-depth: 0 so origin/<base> and its history are present; the fallback
# baseline build below has to check the base commit out.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Decide whether the bundle changed at all
id: scope
env:
CI_SCOPE_BASE: ${{ github.base_ref && format('origin/{0}', github.base_ref) || github.event.before }}
run: bash .github/scripts/ci-scope.sh frontend
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.scope.outputs.run == 'true'
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Set up pinned Node bundle runtime
if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
# The tip of the base branch, which is the commit frontend-bundle-baseline
# keyed its cache on. Deliberately not the pull request's merge base: a
# stale merge base would compare this build against a baseline nobody on
# main is running.
- name: Resolve baseline commit
id: baseline_commit
if: steps.scope.outputs.run == 'true'
env:
BASE_REF: ${{ github.base_ref }}
run: |
echo "sha=$(git rev-parse "origin/$BASE_REF")" >> "$GITHUB_OUTPUT"
echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Restore bundle baseline from the base branch
id: restore_baseline
if: steps.scope.outputs.run == 'true'
# Restore-only prevents PR-scoped fallback builds from creating caches.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/mention-bundle-baseline.json
key: mention-bundle-baseline-${{ steps.baseline_commit.outputs.sha }}
# Fallback for a cache miss (first run after this job landed, an evicted
# entry, a base branch that never pushed). `git reset --hard` rather than
# the upstream `git reset` + `git restore .`: a mixed reset leaves files
# this branch added on disk as untracked, and the baseline export would
# then bundle them and understate the delta.
- name: Build the baseline bundle at the base commit
if: steps.scope.outputs.run == 'true' && !steps.restore_baseline.outputs.cache-hit
env:
BASELINE_SHA: ${{ steps.baseline_commit.outputs.sha }}
HEAD_SHA: ${{ steps.baseline_commit.outputs.head }}
NODE_ENV: production
EXPO_PUBLIC_ENV: production
EXPO_PUBLIC_API_URL: https://api.mention.earth
run: |
set -euo pipefail
git reset --hard "$BASELINE_SHA"
bun install --frozen-lockfile --ignore-scripts
bun run build:shared-types
bun run --cwd packages/frontend build:analyze
# No --ci: a base branch that already breaches a ceiling is main's
# problem to fix, not a reason to fail this pull request.
bun run --cwd packages/frontend analyze-bundle -- \
--json "$RUNNER_TEMP/mention-bundle-baseline.json"
git reset --hard "$HEAD_SHA"
rm -rf packages/frontend/dist
# This job is the only one that installs TWO dependency graphs into one
# runner: the base commit's, then this revision's. Bun's hoisted linker
# is not guaranteed to converge on the second install — taking the SDK
# 56 -> 57 bump, it left `@expo/cli` resolving the hoisted ws 7 that
# `@react-native/dev-middleware` pins instead of the ws 8 it depends on
# for `WebSocketServer`, and the export died inside @expo/cli. The
# lockfile records the nested copy correctly and a clean install
# produces it, so drop the baseline's tree rather than install over it.
rm -rf node_modules packages/*/node_modules
- name: Install exact dependency graph
if: steps.scope.outputs.run == 'true'
run: bun install --frozen-lockfile --ignore-scripts
- name: Build shared runtime package
if: steps.scope.outputs.run == 'true'
run: bun run build:shared-types
- name: Export production web bundle
if: steps.scope.outputs.run == 'true'
run: bun run --cwd packages/frontend build:analyze
env:
NODE_ENV: production
EXPO_PUBLIC_ENV: production
EXPO_PUBLIC_API_URL: https://api.mention.earth
- name: Validate static hosting contract
if: steps.scope.outputs.run == 'true'
run: bun .github/scripts/validate-frontend-static-output.mjs packages/frontend/dist
# The comparison is advisory and never fails on its own; the absolute
# ceilings in bundle-budgets.json remain the only hard gate.
- name: Enforce bundle budgets and compare against the baseline
if: steps.scope.outputs.run == 'true'
run: >-
bun run --cwd packages/frontend analyze-bundle --
--ci
--json "${{ runner.temp }}/mention-bundle-report.json"
--baseline "${{ runner.temp }}/mention-bundle-baseline.json"
--markdown "${{ runner.temp }}/mention-bundle-summary.md"
# Step summary rather than a pull request comment: this workflow runs on
# `pull_request` with contents: read, a comment needs pull-requests: write
# that forks never receive, and `pull_request_target` would run untrusted
# code with a write token.
- name: Publish bundle summary
if: always() && steps.scope.outputs.run == 'true'
run: |
if [ -f "$RUNNER_TEMP/mention-bundle-summary.md" ]; then
cat "$RUNNER_TEMP/mention-bundle-summary.md" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload bundle report
if: always() && steps.scope.outputs.run == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mention-frontend-bundle-${{ github.sha }}
path: ${{ runner.temp }}/mention-bundle-report.json
if-no-files-found: warn
retention-days: 14
# The single check branch protection should require, so that auto-merge has
# something stable to wait on. Requiring the jobs themselves does not work
# here: frontend-bundle-baseline and frontend-bundle are mutually exclusive on
# event type, so one of the pair reports `skipped` on every run, and a
# required check that reports `skipped` never becomes satisfied.
#
# `if: always()` so this still runs — and still fails — when something above
# it failed or was cancelled.
ci-complete:
name: CI complete
needs: [quality, lockfile, tests, frontend-bundle-baseline, frontend-bundle]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every CI job to have passed
env:
RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$RESULTS"
failures=()
# These three have no `if:` and run on every event, so `skipped` here
# is never legitimate — it means a future edit gated them, and a gate
# that reads "skipped" as "fine" is how a required check stops
# checking. `tests` is a matrix: its result is success only when every
# leg succeeded.
for job in quality lockfile tests; do
result="$(jq -r --arg job "$job" '.[$job].result' <<<"$RESULTS")"
if [ "$result" != success ]; then
failures+=("$job did not pass: $result")
fi
done
# Exactly one of the bundle pair runs, chosen by event type. Asserting
# the pairing rather than tolerating both being skipped is what stops a
# future `if:` edit retiring the bundle budget on both paths at once.
baseline="$(jq -r '."frontend-bundle-baseline".result' <<<"$RESULTS")"
pull_request="$(jq -r '."frontend-bundle".result' <<<"$RESULTS")"
case "$baseline/$pull_request" in
success/skipped | skipped/success) ;;
*)
failures+=("the bundle budget did not run on exactly one path: baseline=$baseline pull-request=$pull_request")
;;
esac
if [ ${#failures[@]} -gt 0 ]; then
for failure in "${failures[@]}"; do
echo "::error::$failure"
done
exit 1
fi
echo 'Every CI job passed, or was skipped by the event type it does not apply to.'