test(schedule): the quick-pick options need a frozen clock, not the runner's #2563
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.' |