From a5c89035331790f5b817d5aa564e0e332ddc790d Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 10:41:05 +0100 Subject: [PATCH 1/5] chore(ci): adopt gt repo governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboards this repository onto gt's governance subsystem (pedromvgomes/gt#31). A committed .gt-repo.yaml is the source of truth and `gt repo sync` renders the files from it. Must not merge before gt v1 is cut: every rendered caller pins pedromvgomes/gt@v1, which does not exist yet. There is no .github/dependabot.yml here today, so this repository gains dependency updates for the first time — npm at the workspace root and github-actions — plus the daily auto-merge batch for patch and minor bumps. The npm entry is deliberately one entry at the ROOT. This is a pnpm workspace with a single pnpm-lock.yaml, so per-member entries for /page and /worker would edit only those manifests, leave the root lockfile stale, and fail every frozen-lockfile install. gt got this wrong on first detection — it decided workspace-root-ness from package.json's workspaces field, which pnpm does not use — and pedromvgomes/gt#31 now recognises pnpm-workspace.yaml. The reasoning is recorded as a note so a future sync cannot quietly re-split it. bulwark is on: no security scanning here today, so gt's stage is an addition rather than a duplicate. CD is off, and here it is a design question rather than a migration: deploy.yml already ships from pushes to main, so delivery is not tag-shaped at all. Turning CD on means deciding whether that changes. The ci-* stages land as empty no-ops and end2end is omitted. ci.yml, deploy.yml and validate-topology.yml keep running, and branch protection is unchanged. Claude-Session: https://claude.ai/code/session_01PvwKxJ5vnqa9h9XXyTEj43 --- .bulwark.yml | 20 +++ .github/dependabot.yml | 45 +++++ .github/workflows/ci-build.yml | 23 +++ .github/workflows/ci-orchestration.yml | 172 ++++++++++++++++++++ .github/workflows/ci-preflight.yml | 38 +++++ .github/workflows/ci-test.yml | 29 ++++ .github/workflows/dependabot-auto-merge.yml | 28 ++++ .github/workflows/gt-sync.yml | 25 +++ .gt-repo.yaml | 83 ++++++++++ 9 files changed, 463 insertions(+) create mode 100644 .bulwark.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci-build.yml create mode 100644 .github/workflows/ci-orchestration.yml create mode 100644 .github/workflows/ci-preflight.yml create mode 100644 .github/workflows/ci-test.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 .github/workflows/gt-sync.yml create mode 100644 .gt-repo.yaml diff --git a/.bulwark.yml b/.bulwark.yml new file mode 100644 index 0000000..4ad8e2d --- /dev/null +++ b/.bulwark.yml @@ -0,0 +1,20 @@ +# .bulwark.yml — this file is yours. +# +# gt created it once and will never modify or delete it again. bulwark owns +# what goes in it; gt only makes sure it exists, because the one setting below +# is load-bearing for the pipeline and silently wrong by default. +# +# `coverage.source` says who produces the coverage bulwark gates on. In this +# pipeline ci-test produces it and uploads it as the `gt-coverage` artifact, +# which the bulwark stage extracts before bulwark runs — so `report` is +# correct. Leaving it unset means `run`, and bulwark would execute your suite a +# second time without saying so. +# +# A stage skipped by ci-preflight simply produces no report, which bulwark +# treats as no coverage for that ecosystem rather than an error. +# +# Everything else bulwark supports — per-language enablement, coverage +# tolerances, patch-coverage opt-outs, toolchain overrides — belongs here too. +# See bulwark's README. +coverage: + source: report diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6f9e625 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +# Managed by gt — edit .gt-repo.yaml, then run `gt repo sync`. +# +# Shared policy (cooldown, commit-message prefix, schedule) lives in gt's +# template, not here, so changing it for every repo is a one-line edit in gt. +# +# The 7-day cooldown is the supply-chain guard: by the time a PR exists, +# the upstream release has been in the wild long enough to surface yanks and +# compromised publishers before an auto-merge-eligible PR lands on the default +# branch. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + # Dependabot's commit message is also its PR title, and PRs are + # squash-merged, so the prefix is what keeps dependency updates inside + # Conventional Commits. `include: scope` appends the dependency scope, + # producing e.g. `ci(deps): bump …`. + commit-message: + prefix: "ci" + include: scope + cooldown: + default-days: 7 + open-pull-requests-limit: 25 + + # A pnpm workspace (pnpm-workspace.yaml lists worker and page) with a + # SINGLE pnpm-lock.yaml at the root. This must stay one entry at the + # workspace ROOT: per-member entries for /page and /worker would edit only + # those package.json files, leave the root lockfile stale, and every PR + # would fail a frozen-lockfile install. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + # Dependabot's commit message is also its PR title, and PRs are + # squash-merged, so the prefix is what keeps dependency updates inside + # Conventional Commits. `include: scope` appends the dependency scope, + # producing e.g. `build(deps): bump …`. + commit-message: + prefix: "build" + include: scope + cooldown: + default-days: 7 + open-pull-requests-limit: 25 diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..8892a9f --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,23 @@ +# ci-build — this file is yours. +# +# gt created it once and will never modify or delete it again. Put this +# repository's build steps here; the orchestration around it stays gt's. +# +# It is called by ci-orchestration.yml and must keep `workflow_call`, or the +# orchestrator loses the stage. +name: ci-build + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + # A no-op so the stage is green from the first run. Replace it. + - name: Nothing to do yet + run: echo "No build steps defined. Add them in .github/workflows/ci-build.yml" diff --git a/.github/workflows/ci-orchestration.yml b/.github/workflows/ci-orchestration.yml new file mode 100644 index 0000000..1e9bd0a --- /dev/null +++ b/.github/workflows/ci-orchestration.yml @@ -0,0 +1,172 @@ +# Managed by gt — edit .gt-repo.yaml, then run `gt repo sync`. +# +# gt owns the orchestration; the ci-* stages it calls are yours. Because every +# stage is a job in this one workflow, `ci-gate` aggregates them with +# `needs:` rather than polling the checks API — no timeout, and no way to +# confuse "absent" with "not started yet". +# +# Branch protection requires exactly one check: "ci-gate". +# +# Named "gt CI" rather than "CI" for the same reason the file is not ci.yml: +# every repository already has a workflow called CI, and the stages move into +# this one a few at a time, so the two coexist for a long while. Two workflows +# sharing a display name make the Actions list ambiguous and every check read +# as "CI / …" from one of two places. The `gt ` prefix matches the reusable +# workflows this calls — gt attest, gt bulwark, gt sync. +name: gt CI + +on: + # No `branches:` filter: a PR stacked onto a feature branch must run CI too. + pull_request: + # `edited` is load-bearing: without it, correcting a rejected title leaves + # the check red until an unrelated push. + types: [opened, synchronize, reopened, edited, ready_for_review] + # On the default branch an already-validated tree skips every stage. + push: + branches: [main] + workflow_dispatch: + +# Superseded pushes to the same PR are cancelled; runs on the default branch and +# in a merge queue are not. Cancelling a PR run costs nothing — a newer commit +# is about to be validated anyway — whereas cancelling a default-branch run +# discards the attestation it was about to record, and cancelling a queued +# merge would drop it from the queue. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + # Reports whether this exact tree already passed the gate, so a push that + # merely squashed an already-validated PR does not run everything again. + attest: + uses: pedromvgomes/gt/.github/workflows/reusable-attest.yml@v1 + # A called workflow may only narrow these. Granting less than + # reusable-attest declares fails the run at parse time, before any job + # starts — so ci-gate never reports and every PR blocks. + permissions: + contents: read + statuses: read + pull-requests: read + + preflight: + needs: [attest] + if: needs.attest.outputs.validated != 'true' + uses: ./.github/workflows/ci-preflight.yml + secrets: inherit + # packages: read because several repositories resolve private @scope + # dependencies from GitHub Packages during install, and a called workflow + # can only narrow what the caller grants — a stage cannot ask for it back. + # Read-only, so granting it where it is unused costs nothing. A stage + # needing more than this is a gt change. + permissions: + contents: read + packages: read + + build: + needs: [attest, preflight] + if: needs.attest.outputs.validated != 'true' && needs.preflight.outputs.run-build != 'false' + uses: ./.github/workflows/ci-build.yml + secrets: inherit + # packages: read because several repositories resolve private @scope + # dependencies from GitHub Packages during install, and a called workflow + # can only narrow what the caller grants — a stage cannot ask for it back. + # Read-only, so granting it where it is unused costs nothing. A stage + # needing more than this is a gt change. + permissions: + contents: read + packages: read + + test: + needs: [attest, build, preflight] + if: needs.attest.outputs.validated != 'true' && needs.preflight.outputs.run-test != 'false' + uses: ./.github/workflows/ci-test.yml + secrets: inherit + # packages: read because several repositories resolve private @scope + # dependencies from GitHub Packages during install, and a called workflow + # can only narrow what the caller grants — a stage cannot ask for it back. + # Read-only, so granting it where it is unused costs nothing. A stage + # needing more than this is a gt change. + permissions: + contents: read + packages: read + + conventional-commits: + needs: [attest] + if: needs.attest.outputs.validated != 'true' + uses: pedromvgomes/gt/.github/workflows/reusable-conventional-commits.yml@v1 + permissions: + contents: read + pull-requests: read + + governance: + needs: [attest] + if: needs.attest.outputs.validated != 'true' + uses: pedromvgomes/gt/.github/workflows/reusable-governance.yml@v1 + + bulwark: + needs: [attest, test] + # `!cancelled()` because a job whose needs were skipped is skipped too, and + # ci-gate counts skipped as a pass. Without it a preflight that skips tests + # would silently disable the security gate while the required check stayed + # green. bulwark still runs *after* tests, to consume their coverage. + if: "!cancelled() && needs.attest.outputs.validated != 'true'" + uses: pedromvgomes/gt/.github/workflows/reusable-bulwark.yml@v1 + secrets: inherit + permissions: + contents: write + pull-requests: write + + # The single required check. `if: always()` so it still reports when a stage + # was skipped, and a skipped stage passes — that is the point of preflight. + ci-gate: + name: ci-gate + needs: [attest, preflight, build, test, conventional-commits, governance, bulwark] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + statuses: write + steps: + - name: Verify every stage succeeded or was legitimately skipped + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + set -euo pipefail + echo "stage results: ${RESULTS}" + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "::error::a required stage failed" + exit 1 + fi + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "::error::a required stage was cancelled" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Records the tree this run validated, so a later push or tag can prove + # the same content already passed rather than re-running to find out. + - name: Attest the validated tree + # Read-only GITHUB_TOKEN (fork PRs, and every Dependabot event) cannot + # POST a status. Losing the attestation only costs a re-run on the + # default branch; failing the gate would block the PR entirely. + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + # For a pull_request event HEAD is refs/pull/N/merge, so this is the + # tree of the merged result — which is exactly what a squash merge + # puts on the default branch. + tree=$(git rev-parse "HEAD^{tree}") + gh api "repos/${REPO}/statuses/${SHA}" \ + -f state=success \ + -f context=gt/validated-tree \ + -f description="$tree" >/dev/null + echo "attested tree ${tree} on ${SHA}" diff --git a/.github/workflows/ci-preflight.yml b/.github/workflows/ci-preflight.yml new file mode 100644 index 0000000..f01800f --- /dev/null +++ b/.github/workflows/ci-preflight.yml @@ -0,0 +1,38 @@ +# ci-preflight — this file is yours. +# +# gt created it once and will never modify or delete it again. +# +# This stage decides which later stages run. Emit `false` for a stage to skip +# it; anything else — including nothing at all, as below — runs it. That is why +# the stub is a no-op: out of the box every stage runs, and change detection is +# something you opt into rather than out of. +# +# A typical implementation sets these from a paths filter, so an untouched area +# is not rebuilt. A skipped stage still passes the gate. +name: ci-preflight + +on: + workflow_call: + outputs: + run-build: + description: Set to "false" to skip the build stage. + value: ${{ jobs.preflight.outputs.run-build }} + run-test: + description: Set to "false" to skip the test stage. + value: ${{ jobs.preflight.outputs.run-test }} + +permissions: + contents: read + +jobs: + preflight: + name: preflight + runs-on: ubuntu-latest + outputs: + run-build: ${{ steps.decide.outputs.run-build }} + run-test: ${{ steps.decide.outputs.run-test }} + steps: + - name: Decide which stages to run + id: decide + # Emitting nothing runs everything. Replace with change detection. + run: echo "every stage runs by default" diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml new file mode 100644 index 0000000..eb3b526 --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,29 @@ +# ci-test — this file is yours. +# +# gt created it once and will never modify or delete it again. Put this +# repository's test suite here; the orchestration around it stays gt's. +# +# Upload coverage as an artifact named `gt-coverage` and the bulwark +# stage will consume it instead of running the suite a second time. Without it +# bulwark falls back to running the tests itself, which is correct but slower. +# +# - uses: actions/upload-artifact@ +# with: +# name: gt-coverage +# path: coverage.out +name: ci-test + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + # A no-op so the stage is green from the first run. Replace it. + - name: Nothing to do yet + run: echo "No test steps defined. Add them in .github/workflows/ci-test.yml" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..3ba236d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,28 @@ +# Managed by gt — edit .gt-repo.yaml, then run `gt repo sync`. +# +# Daily batch window for merging Dependabot's eligible bumps. The Dependabot +# cooldown (set in .github/dependabot.yml) is the supply-chain guard; this is +# the merge executor on top of it. +# +# Bumps above `minor` are left for human review. +# +# Known gap, not a bug: Dependabot PRs touching .github/workflows/** can never +# be merged here. There is no permissions: key granting GITHUB_TOKEN the +# `workflow` scope, so the github-actions ecosystem always needs a human or +# `gt repo fleet merge-pending`, which runs with your own credentials. +name: Dependabot auto-merge + +on: + schedule: + - cron: "0 1 * * *" + workflow_dispatch: + +# A called reusable workflow can only narrow the caller's token, never widen +# it, so the writes the merge job needs have to be granted here. +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + uses: pedromvgomes/gt/.github/workflows/reusable-dependabot-auto-merge.yml@v1 diff --git a/.github/workflows/gt-sync.yml b/.github/workflows/gt-sync.yml new file mode 100644 index 0000000..a13d05f --- /dev/null +++ b/.github/workflows/gt-sync.yml @@ -0,0 +1,25 @@ +# Managed by gt — edit .gt-repo.yaml, then run `gt repo sync`. +# +# Weekly drift check and repair. Runs `gt repo sync`, and opens a PR only if +# something changed — it never pushes to main, so gt's own updates are +# reviewed by the gate like any other change. +# +# Workflow files are deliberately excluded: GITHUB_TOKEN cannot create or +# update anything under .github/workflows/**. When one of them has drifted the +# job reports it and asks for a local `gt repo fleet sync`, which runs with +# your own credentials. Because the callers pin the moving v1 tag, this is +# rare — gate logic changes need no file change at all. +name: gt sync + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + uses: pedromvgomes/gt/.github/workflows/reusable-sync.yml@v1 diff --git a/.gt-repo.yaml b/.gt-repo.yaml new file mode 100644 index 0000000..2ac324e --- /dev/null +++ b/.gt-repo.yaml @@ -0,0 +1,83 @@ +# Repository governance for gt. This file is the source of truth; run +# 'gt repo sync' to render it, and 'gt repo check' to verify. +# +# Shared policy (Dependabot cooldown, commit-message prefixes, the weekly sync +# schedule) lives in gt's templates, not here, so it stays consistent across +# every governed repo. +# +# The pipeline stages below become jobs in ci-orchestration.yml, each calling a +# ci-*/cd-* workflow that belongs to this repository: gt creates those once and +# never touches them again. Branch protection needs exactly one check, ci-gate, +# which waits on all of them. + +gt_version: v1.0.0 +dependabot: + - ecosystem: github-actions + directory: / + - ecosystem: npm + directory: / + note: | + A pnpm workspace (pnpm-workspace.yaml lists worker and page) with a + SINGLE pnpm-lock.yaml at the root. This must stay one entry at the + workspace ROOT: per-member entries for /page and /worker would edit only + those package.json files, leave the root lockfile stale, and every PR + would fail a frozen-lockfile install. +dependabot_auto_merge: + enabled: true + schedule: 0 1 * * * + max_bump: minor + delete_branch: true +bulwark: + # On. There is no security scanning here today, so gt's stage is an addition + # rather than a duplicate. Expect findings on the first run; nothing gates on + # them while ci-gate is not the required check. + enabled: true +pipeline: + ci: + enabled: true + stages: + - preflight + - build + - test + merge_queue: false + # Off. deploy.yml already ships from pushes to main rather than from tags, so + # delivery here is not tag-shaped at all — turning CD on means deciding + # whether that changes, not just moving jobs. verify-attestation also runs + # with require: true, and no tree carries an attestation yet. + cd: + enabled: false + stages: + - preflight + - publish + - deploy + - verify + tags: + - v*.*.* +conventional_commits: + enabled: true + scope: pr_title + types: + - feat + - fix + - docs + - style + - refactor + - perf + - test + - build + - ci + - chore + - revert +settings: + merge: + squash: true + merge_commit: false + rebase: false + delete_branch_on_merge: true + branch_protection: + branch: main + required_approvals: 0 + require_up_to_date: false +files: + - sync + - dependabot-auto-merge From 41b7c804575dbe89f5e0a01bea1ae6a1d079d61b Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 11:06:40 +0100 Subject: [PATCH 2/5] chore(ci): justify secrets: inherit in the gt orchestrator Picks up pedromvgomes/gt#31's annotation. semgrep's secrets-inherit rule flagged the rendered orchestrator; gt cannot enumerate a repository's secret names, so the reasoning is recorded at each site rather than the rule being silenced. Claude-Session: https://claude.ai/code/session_01PvwKxJ5vnqa9h9XXyTEj43 --- .github/workflows/ci-orchestration.yml | 28 ++++++++++++++++++++++++++ .github/workflows/gt-sync.yml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-orchestration.yml b/.github/workflows/ci-orchestration.yml index 1e9bd0a..35912df 100644 --- a/.github/workflows/ci-orchestration.yml +++ b/.github/workflows/ci-orchestration.yml @@ -55,6 +55,13 @@ jobs: needs: [attest] if: needs.attest.outputs.validated != 'true' uses: ./.github/workflows/ci-preflight.yml + # `inherit` rather than an enumerated `secrets:` block, and semgrep's + # secrets-inherit rule is answered rather than silenced: gt renders this + # orchestrator for every repository and cannot know their secret names, so + # there is no list to write. The callee is this same repository's own + # workflow, so no secret crosses a trust boundary that `inherit` did not + # already sit inside. + # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit secrets: inherit # packages: read because several repositories resolve private @scope # dependencies from GitHub Packages during install, and a called workflow @@ -69,6 +76,13 @@ jobs: needs: [attest, preflight] if: needs.attest.outputs.validated != 'true' && needs.preflight.outputs.run-build != 'false' uses: ./.github/workflows/ci-build.yml + # `inherit` rather than an enumerated `secrets:` block, and semgrep's + # secrets-inherit rule is answered rather than silenced: gt renders this + # orchestrator for every repository and cannot know their secret names, so + # there is no list to write. The callee is this same repository's own + # workflow, so no secret crosses a trust boundary that `inherit` did not + # already sit inside. + # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit secrets: inherit # packages: read because several repositories resolve private @scope # dependencies from GitHub Packages during install, and a called workflow @@ -83,6 +97,13 @@ jobs: needs: [attest, build, preflight] if: needs.attest.outputs.validated != 'true' && needs.preflight.outputs.run-test != 'false' uses: ./.github/workflows/ci-test.yml + # `inherit` rather than an enumerated `secrets:` block, and semgrep's + # secrets-inherit rule is answered rather than silenced: gt renders this + # orchestrator for every repository and cannot know their secret names, so + # there is no list to write. The callee is this same repository's own + # workflow, so no secret crosses a trust boundary that `inherit` did not + # already sit inside. + # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit secrets: inherit # packages: read because several repositories resolve private @scope # dependencies from GitHub Packages during install, and a called workflow @@ -114,6 +135,13 @@ jobs: # green. bulwark still runs *after* tests, to consume their coverage. if: "!cancelled() && needs.attest.outputs.validated != 'true'" uses: pedromvgomes/gt/.github/workflows/reusable-bulwark.yml@v1 + # `inherit` because bulwark reads CODECOV_TOKEN and SEMGREP_APP_TOKEN, + # neither of which gt can name per repository — some repos set one, some + # both, some neither, and an enumerated block naming a secret a repo does + # not have is not an error but is not honest either. The callee is gt's own + # reusable workflow, pinned to a tag in a repository we control, and it + # forwards exactly those two values to bulwark and nothing else. + # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit secrets: inherit permissions: contents: write diff --git a/.github/workflows/gt-sync.yml b/.github/workflows/gt-sync.yml index a13d05f..7e4060e 100644 --- a/.github/workflows/gt-sync.yml +++ b/.github/workflows/gt-sync.yml @@ -7,7 +7,7 @@ # Workflow files are deliberately excluded: GITHUB_TOKEN cannot create or # update anything under .github/workflows/**. When one of them has drifted the # job reports it and asks for a local `gt repo fleet sync`, which runs with -# your own credentials. Because the callers pin the moving v1 tag, this is +# your own credentials. Because the callers pin a moving major tag, this is # rare — gate logic changes need no file change at all. name: gt sync From 23d9aceaf6f1a778e3f26898572ab5c76a04ad1f Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 08:09:41 +0100 Subject: [PATCH 3/5] ci: move the CI jobs into the gt stages and retire ci.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The governance PR added the gt workflows but left ci.yml in place, so ci-gate went green on a pipeline that ran nothing. This moves the work. ci.yml's single `test` job becomes two stages: ci-build runs the type-check (for TypeScript the build is the type-check — the worker's own "build" script is `tsc --noEmit`) and ci-test runs `pnpm -r test`. Every step is carried over unchanged; the install is repeated because each stage is its own workflow on its own runner. ci.yml's concurrency group is dropped rather than lost — ci-orchestration.yml already cancels superseded PR runs. Both stages declare packages: read. @wardnet/* resolve from GitHub Packages, so a frozen-lockfile install fails without it, and a called workflow can only narrow what the caller granted. Action tags are pinned to the SHAs `@v4` resolved to at the time, so the move changes no behaviour; Dependabot owns bumps from here. .bulwark.yml goes back to `coverage.source: run`. It was scaffolded as `report` by an earlier pass, but nothing produces a report — neither workspace declares a test:coverage script and @vitest/coverage-v8 is not a dependency. Under `run` bulwark skips packages with no such script and reports no TypeScript coverage, which is at least true. Re-synced with gt 1.3.0: ci-orchestration.yml and dependabot-auto-merge.yml now name their secrets instead of inheriting them, which is what actually works across owners — gt lives under pedromvgomes, this repo under wardnet. `gt repo config` resolves identically before and after apart from the version stamp. --- .bulwark.yml | 21 +++++--- .github/workflows/ci-build.yml | 32 +++++++++--- .github/workflows/ci-orchestration.yml | 37 +++++++++---- .github/workflows/ci-test.yml | 39 +++++++++----- .github/workflows/ci.yml | 38 -------------- .github/workflows/dependabot-auto-merge.yml | 10 ++++ .gt-repo.yaml | 58 +++------------------ 7 files changed, 108 insertions(+), 127 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.bulwark.yml b/.bulwark.yml index 4ad8e2d..848bada 100644 --- a/.bulwark.yml +++ b/.bulwark.yml @@ -4,17 +4,22 @@ # what goes in it; gt only makes sure it exists, because the one setting below # is load-bearing for the pipeline and silently wrong by default. # -# `coverage.source` says who produces the coverage bulwark gates on. In this -# pipeline ci-test produces it and uploads it as the `gt-coverage` artifact, -# which the bulwark stage extracts before bulwark runs — so `report` is -# correct. Leaving it unset means `run`, and bulwark would execute your suite a -# second time without saying so. +# `coverage.source` says who produces the coverage bulwark gates on: # -# A stage skipped by ci-preflight simply produces no report, which bulwark -# treats as no coverage for that ecosystem rather than an error. +# run bulwark executes the suite itself. +# report bulwark only reads a report a prior job produced. +# +# `run` here, because nothing produces a report yet: neither workspace declares +# a test:coverage script, and @vitest/coverage-v8 is not a dependency, so +# ci-test uploads no `gt-coverage` artifact. Under `run` bulwark skips packages +# with no test:coverage script and reports no coverage for TypeScript, which is +# honest. `report` would have it look for a file nobody writes. +# +# Flip this to `report` in the same commit that adds coverage instrumentation +# and uploads the artifact — not before. # # Everything else bulwark supports — per-language enablement, coverage # tolerances, patch-coverage opt-outs, toolchain overrides — belongs here too. # See bulwark's README. coverage: - source: report + source: run diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 8892a9f..e47958d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,23 +1,41 @@ # ci-build — this file is yours. # -# gt created it once and will never modify or delete it again. Put this -# repository's build steps here; the orchestration around it stays gt's. +# gt created it once and will never modify or delete it again. It is called by +# ci-orchestration.yml and must keep `workflow_call`, or the orchestrator loses +# the stage. # -# It is called by ci-orchestration.yml and must keep `workflow_call`, or the -# orchestrator loses the stage. +# For TypeScript the build *is* the type-check: both workspaces compile with +# `tsc`, and the worker's own "build" script is literally `tsc --noEmit`. So +# this stage runs what ci.yml's type-check step ran, and ci-test runs the suite. +# The install is repeated there rather than shared, because each stage is a +# separate workflow with its own runner and no filesystem between them. name: ci-build on: workflow_call: +# packages: read is load-bearing — @wardnet/* resolve from GitHub Packages, so +# a frozen-lockfile install fails without it. The orchestrator grants it; a +# called workflow can only narrow what the caller gave, never ask for more. permissions: contents: read + packages: read jobs: build: name: build runs-on: ubuntu-latest steps: - # A no-op so the stage is green from the first run. Replace it. - - name: Nothing to do yet - run: echo "No build steps defined. Add them in .github/workflows/ci-build.yml" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Install + run: pnpm install --frozen-lockfile + env: + # @wardnet/* resolve from GitHub Packages; the built-in token has packages:read. + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Type-check + run: pnpm type-check diff --git a/.github/workflows/ci-orchestration.yml b/.github/workflows/ci-orchestration.yml index 35912df..284551b 100644 --- a/.github/workflows/ci-orchestration.yml +++ b/.github/workflows/ci-orchestration.yml @@ -46,10 +46,15 @@ jobs: # A called workflow may only narrow these. Granting less than # reusable-attest declares fails the run at parse time, before any job # starts — so ci-gate never reports and every PR blocks. + # + # pull-requests: write is for the note attest leaves when it skips the + # pipeline. A skipped job is indistinguishable from a broken one at a + # glance, so the reason is said out loud on the pull request and withdrawn + # when it stops being true. permissions: contents: read statuses: read - pull-requests: read + pull-requests: write preflight: needs: [attest] @@ -135,14 +140,23 @@ jobs: # green. bulwark still runs *after* tests, to consume their coverage. if: "!cancelled() && needs.attest.outputs.validated != 'true'" uses: pedromvgomes/gt/.github/workflows/reusable-bulwark.yml@v1 - # `inherit` because bulwark reads CODECOV_TOKEN and SEMGREP_APP_TOKEN, - # neither of which gt can name per repository — some repos set one, some - # both, some neither, and an enumerated block naming a secret a repo does - # not have is not an error but is not honest either. The callee is gt's own - # reusable workflow, pinned to a tag in a repository we control, and it - # forwards exactly those two values to bulwark and nothing else. - # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit - secrets: inherit + # Named explicitly, NOT `inherit`. GitHub documents inherit as working for + # "reusable workflows in the same organization or enterprise", and gt lives + # under a different owner than most repositories that call it — so an + # organization secret never arrived, bulwark skipped its Codecov upload and + # quietly fell back to token-less semgrep. Nothing failed; coverage history + # just stopped being recorded. + # + # Naming them makes this workflow resolve each value in its own context and + # pass it in, which works across owners. An unset secret resolves to the + # empty string, which bulwark already treats as "not configured", so a + # repository using neither is unaffected. + # + # This also answers semgrep's secrets-inherit rule properly rather than + # suppressing it: nothing is inherited here now. + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} permissions: contents: write pull-requests: write @@ -193,8 +207,11 @@ jobs: # tree of the merged result — which is exactly what a squash merge # puts on the default branch. tree=$(git rev-parse "HEAD^{tree}") + # target_url so a later run can link back to the one that did the + # work, rather than asserting an earlier run exists somewhere. gh api "repos/${REPO}/statuses/${SHA}" \ -f state=success \ -f context=gt/validated-tree \ - -f description="$tree" >/dev/null + -f description="$tree" \ + -f target_url="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}" >/dev/null echo "attested tree ${tree} on ${SHA}" diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index eb3b526..0530167 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -1,29 +1,44 @@ # ci-test — this file is yours. # -# gt created it once and will never modify or delete it again. Put this -# repository's test suite here; the orchestration around it stays gt's. +# gt created it once and will never modify or delete it again. It is called by +# ci-orchestration.yml and must keep `workflow_call`, or the orchestrator loses +# the stage. # -# Upload coverage as an artifact named `gt-coverage` and the bulwark -# stage will consume it instead of running the suite a second time. Without it -# bulwark falls back to running the tests itself, which is correct but slower. +# This is ci.yml's test step: `pnpm -r test`, which runs vitest in both +# workspaces. That includes worker/test/topology-file.test.ts, so topology.yaml +# is schema-validated on every PR here too, not only in validate-topology.yml. # -# - uses: actions/upload-artifact@ -# with: -# name: gt-coverage -# path: coverage.out +# No `gt-coverage` artifact yet: neither workspace declares a test:coverage +# script or pulls in @vitest/coverage-v8, so there is nothing to upload. +# .bulwark.yml therefore stays on `coverage.source: run` — flipping it to +# `report` is the same commit that starts producing one. name: ci-test on: workflow_call: +# packages: read is load-bearing — @wardnet/* resolve from GitHub Packages, so +# a frozen-lockfile install fails without it. The orchestrator grants it; a +# called workflow can only narrow what the caller gave, never ask for more. permissions: contents: read + packages: read jobs: test: name: test runs-on: ubuntu-latest steps: - # A no-op so the stage is green from the first run. Replace it. - - name: Nothing to do yet - run: echo "No test steps defined. Add them in .github/workflows/ci-test.yml" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Install + run: pnpm install --frozen-lockfile + env: + # @wardnet/* resolve from GitHub Packages; the built-in token has packages:read. + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Test + run: pnpm test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7266339..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -# PR gate: type-check + unit tests for both workspaces on every pull request -# (and on pushes to main, so the branch tip is always known-green). Deploys -# live in deploy.yml; this workflow only decides whether a change is mergeable. -name: ci - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - packages: read - -concurrency: - # One in-flight run per branch/PR; newer pushes cancel superseded runs. - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - name: Install - run: pnpm install --frozen-lockfile - env: - # @wardnet/* resolve from GitHub Packages; the built-in token has packages:read. - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Type-check - run: pnpm type-check - - name: Test - run: pnpm test diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 3ba236d..42d98e9 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -26,3 +26,13 @@ permissions: jobs: auto-merge: uses: pedromvgomes/gt/.github/workflows/reusable-dependabot-auto-merge.yml@v1 + # Named, not inherited: `secrets: inherit` is documented as working for + # reusable workflows "in the same organization or enterprise", and gt lives + # under a different owner than the repositories calling it. The bulwark + # stage lost its Codecov token to exactly that, silently. + # + # Both are optional and resolve to the empty string where the organization + # has no bot App, which the called workflow treats as "not configured". + secrets: + APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.gt-repo.yaml b/.gt-repo.yaml index 2ac324e..7961d1d 100644 --- a/.gt-repo.yaml +++ b/.gt-repo.yaml @@ -9,8 +9,13 @@ # ci-*/cd-* workflow that belongs to this repository: gt creates those once and # never touches them again. Branch protection needs exactly one check, ci-gate, # which waits on all of them. +# +# Only deliberate overrides are written here. Everything absent follows gt's +# default and keeps following it as that default changes — a value pinned in +# this file stops tracking gt, which is why sync removes the ones that merely +# restate it. Run 'gt repo config' to see the resolved spec, defaults included. -gt_version: v1.0.0 +gt_version: 1.3.0 dependabot: - ecosystem: github-actions directory: / @@ -22,62 +27,11 @@ dependabot: workspace ROOT: per-member entries for /page and /worker would edit only those package.json files, leave the root lockfile stale, and every PR would fail a frozen-lockfile install. -dependabot_auto_merge: - enabled: true - schedule: 0 1 * * * - max_bump: minor - delete_branch: true -bulwark: - # On. There is no security scanning here today, so gt's stage is an addition - # rather than a duplicate. Expect findings on the first run; nothing gates on - # them while ci-gate is not the required check. - enabled: true pipeline: ci: - enabled: true stages: - preflight - build - test - merge_queue: false - # Off. deploy.yml already ships from pushes to main rather than from tags, so - # delivery here is not tag-shaped at all — turning CD on means deciding - # whether that changes, not just moving jobs. verify-attestation also runs - # with require: true, and no tree carries an attestation yet. cd: enabled: false - stages: - - preflight - - publish - - deploy - - verify - tags: - - v*.*.* -conventional_commits: - enabled: true - scope: pr_title - types: - - feat - - fix - - docs - - style - - refactor - - perf - - test - - build - - ci - - chore - - revert -settings: - merge: - squash: true - merge_commit: false - rebase: false - delete_branch_on_merge: true - branch_protection: - branch: main - required_approvals: 0 - require_up_to_date: false -files: - - sync - - dependabot-auto-merge From a819b8edcb8a1fd2faf698349f30d2fb3d48beb9 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 08:45:55 +0100 Subject: [PATCH 4/5] fix: switch bulwark's TypeScript linter to Biome and clear its findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding put bulwark's TypeScript check on this repo for the first time and it went red on code the governance PR never touched: bulwark's eslint is not diff-scoped (--diff-base is passed to semgrep only), so the whole back-catalogue arrives at once. Of eslint-plugin-security's 14 findings, 13 were detect-object-injection on typed record lookups. That rule fires on any obj[key] and cannot be disabled on its own — .bulwark.yml has no rule-level exclusion — so the choice was to suppress it at fourteen sites or to change linter. Biome's security + correctness sets report five things instead, and unlike the object-injection noise they are worth acting on: - incidents.ts took a `from: Status` the body never read. Removed, with its 21 call sites. A transition function advertising a parameter that cannot affect the outcome misleads every caller. - topology-file.test.ts used __dirname and node:path. Now a URL relative to import.meta.url, which drops the node:path import and the CommonJS global. fileURLToPath takes .href rather than the URL object because @cloudflare/workers-types declares a global URL that is not node:url's, and passing the object does not type-check. - the same test's node:fs import is suppressed file-wide with a reason: it reads the real topology.yaml off disk under vitest and is never bundled into the Worker, which is the rule's actual concern. - mockServiceWorker.js trips noSecrets on msw's integrity checksum. bulwark's typescript.exclude only filters package discovery, and Biome ignores a nested config under --config-path, so the suppression has to be inline. msw regenerates that file and will drop it; CI says so again if that happens. The file already carries msw's own /* eslint-disable */, so it is in keeping. Verified with bulwark v1.9.0, the release the action installs: biome passes at the root and in both packages, and semgrep passes diff-scoped. type-check and all 97 tests pass. --- .bulwark.yml | 8 +++++++ page/public/mockServiceWorker.js | 4 ++++ worker/src/incidents.ts | 1 - worker/src/region-prober.ts | 1 - worker/test/incidents.test.ts | 40 +++++++++++++++---------------- worker/test/topology-file.test.ts | 7 ++++-- 6 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.bulwark.yml b/.bulwark.yml index 848bada..8d26a31 100644 --- a/.bulwark.yml +++ b/.bulwark.yml @@ -23,3 +23,11 @@ # See bulwark's README. coverage: source: run + +typescript: + # Biome rather than the default ESLint + eslint-plugin-security. The security + # plugin's detect-object-injection fires on every `obj[key]` — 13 of its 14 + # findings here were that one rule on typed record lookups, which is noise + # that teaches people to ignore the gate. Biome's security + correctness sets + # found five things instead, four of them real and now fixed. + linter: biome diff --git a/page/public/mockServiceWorker.js b/page/public/mockServiceWorker.js index 33dde9e..a1f8f8d 100644 --- a/page/public/mockServiceWorker.js +++ b/page/public/mockServiceWorker.js @@ -8,6 +8,10 @@ */ const PACKAGE_VERSION = '2.14.6' +// msw's own integrity checksum for this generated file, not a credential. The +// suppression below is dropped whenever `msw init` regenerates the file, and CI +// flags it again if that happens. +// biome-ignore lint/security/noSecrets: msw integrity checksum, not a credential const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/worker/src/incidents.ts b/worker/src/incidents.ts index 47ba88c..1391a01 100644 --- a/worker/src/incidents.ts +++ b/worker/src/incidents.ts @@ -85,7 +85,6 @@ export async function onComponentTransition( env: Env, now: number, ref: ComponentRef, - from: Status, to: Status, failures: ProbeFailure[], ): Promise { diff --git a/worker/src/region-prober.ts b/worker/src/region-prober.ts index 24b18e1..8e645d5 100644 --- a/worker/src/region-prober.ts +++ b/worker/src/region-prober.ts @@ -171,7 +171,6 @@ export class RegionProber implements DurableObject { this.env, now, ref, - announced ?? "UNKNOWN", current, failingProbes.get(component.name) ?? [], ); diff --git a/worker/test/incidents.test.ts b/worker/test/incidents.test.ts index 5d4881c..cab2285 100644 --- a/worker/test/incidents.test.ts +++ b/worker/test/incidents.test.ts @@ -140,60 +140,60 @@ describe("incident lifecycle", () => { }); it("opens one incident on entering DEGRADED and escalates in place on DOWN", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); expect(db.rows).toHaveLength(1); expect(db.rows[0]).toMatchObject({ severity: "DEGRADED", escalated_at: null, resolved_at: null }); // The stored report carries the request details the page displays. expect(db.rows[0]!.report).toContain("https://svc.example/readyz"); - await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DEGRADED", "DOWN", fails("readyz", "livez")); + await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DOWN", fails("readyz", "livez")); expect(db.rows).toHaveLength(1); expect(db.rows[0]).toMatchObject({ severity: "DOWN", escalated_at: T0 + 60_000 }); }); it("resolves on UP, reports the episode duration, and flags resolved", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); - const { durationMs, resolved } = await onComponentTransition(env, T0 + 300_000, ref("use1", "ddns"), "DEGRADED", "UP", fails()); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); + const { durationMs, resolved } = await onComponentTransition(env, T0 + 300_000, ref("use1", "ddns"), "UP", fails()); expect(db.rows[0]!.resolved_at).toBe(T0 + 300_000); expect(durationMs).toBe(300_000); expect(resolved).toBe(true); }); it("UP with no open incident resolves nothing (cold-start reconcile is a no-op)", async () => { - const { resolved } = await onComponentTransition(env, T0, ref("use1", "ddns"), "UNKNOWN", "UP", fails()); + const { resolved } = await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", fails()); expect(resolved).toBeUndefined(); expect(db.rows).toHaveLength(0); }); it("re-open that escalates to DOWN stamps escalated_at", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); - await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DEGRADED", "UP", fails()); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "UP", fails()); expect(db.rows[0]!.escalated_at).toBeNull(); - await onComponentTransition(env, T0 + 120_000, ref("use1", "ddns"), "UP", "DOWN", fails("livez")); + await onComponentTransition(env, T0 + 120_000, ref("use1", "ddns"), "DOWN", fails("livez")); expect(db.rows).toHaveLength(1); expect(db.rows[0]).toMatchObject({ severity: "DOWN", escalated_at: T0 + 120_000, resolved_at: null }); }); it("re-opens the previous incident within the re-open window", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DOWN", fails("livez")); - await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DOWN", "UP", fails()); - await onComponentTransition(env, T0 + 60_000 + REOPEN_WINDOW_MS - 1, ref("use1", "ddns"), "UP", "DEGRADED", fails("healthz")); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DOWN", fails("livez")); + await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "UP", fails()); + await onComponentTransition(env, T0 + 60_000 + REOPEN_WINDOW_MS - 1, ref("use1", "ddns"), "DEGRADED", fails("healthz")); expect(db.rows).toHaveLength(1); expect(db.rows[0]).toMatchObject({ resolved_at: null, severity: "DOWN" }); // keeps worst severity }); it("creates a new incident after the re-open window has passed", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); - await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DEGRADED", "UP", fails()); - await onComponentTransition(env, T0 + 60_000 + REOPEN_WINDOW_MS + 1, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "UP", fails()); + await onComponentTransition(env, T0 + 60_000 + REOPEN_WINDOW_MS + 1, ref("use1", "ddns"), "DEGRADED", fails("readyz")); expect(db.rows).toHaveLength(2); }); it("keeps incidents per (region, component) independent", async () => { - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); - await onComponentTransition(env, T0, ref("global", "tenants"), "UP", "DOWN", fails("livez")); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0, ref("global", "tenants"), "DOWN", fails("livez")); expect(db.rows).toHaveLength(2); - await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "DEGRADED", "UP", fails()); + await onComponentTransition(env, T0 + 60_000, ref("use1", "ddns"), "UP", fails()); expect(db.rows.filter((r) => r.resolved_at === null)).toHaveLength(1); }); }); @@ -216,8 +216,8 @@ describe("resolveOrphanedIncidents", () => { it("resolves open incidents for components no longer in the topology", async () => { const db = fakeDb(); const env = envWith(db.db); - await onComponentTransition(env, T0, ref("global", "tenants-edge"), "UP", "DOWN", fails("readyz")); - await onComponentTransition(env, T0, ref("global", "tenants"), "UP", "DEGRADED", fails("healthz")); + await onComponentTransition(env, T0, ref("global", "tenants-edge"), "DOWN", fails("readyz")); + await onComponentTransition(env, T0, ref("global", "tenants"), "DEGRADED", fails("healthz")); const resolved = await resolveOrphanedIncidents(env, T0 + 60_000, topo(["global", "tenants"])); @@ -237,7 +237,7 @@ describe("resolveOrphanedIncidents", () => { it("is a no-op when every open incident's component is still in the topology", async () => { const db = fakeDb(); const env = envWith(db.db); - await onComponentTransition(env, T0, ref("use1", "ddns"), "UP", "DEGRADED", fails("readyz")); + await onComponentTransition(env, T0, ref("use1", "ddns"), "DEGRADED", fails("readyz")); const resolved = await resolveOrphanedIncidents(env, T0 + 60_000, topo(["use1", "ddns"])); expect(resolved).toHaveLength(0); diff --git a/worker/test/topology-file.test.ts b/worker/test/topology-file.test.ts index 7ff16a7..328d672 100644 --- a/worker/test/topology-file.test.ts +++ b/worker/test/topology-file.test.ts @@ -1,5 +1,8 @@ +// biome-ignore-all lint/correctness/noNodejsModules: this test reads the real +// topology.yaml off disk and runs only under vitest on Node — it is never +// bundled into the Worker, where the rule's concern actually applies. import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parseTopology } from "../src/topology"; @@ -11,7 +14,7 @@ import { parseTopology } from "../src/topology"; */ describe("topology.yaml (repo root)", () => { it("parses against the schema", () => { - const yamlText = readFileSync(join(__dirname, "../../topology.yaml"), "utf8"); + const yamlText = readFileSync(fileURLToPath(new URL("../../topology.yaml", import.meta.url).href), "utf8"); const topology = parseTopology(yamlText); expect(topology.regions.length).toBeGreaterThan(0); for (const region of topology.regions) { From 689bcfc914999c7ff1507987fc9039e91af43382 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 08:56:04 +0100 Subject: [PATCH 5/5] ci: drop validate-topology.yml, now that ci-test runs the same test It ran one vitest file, worker/test/topology-file.test.ts, which `pnpm -r test` in ci-test now runs anyway. Keeping it meant the worker suite ran twice on any PR touching topology.yaml. Coverage widens rather than narrows. validate-topology.yml was filtered to `paths: [topology.yaml]`, so it only fired when that file changed; gt CI has no path filter and runs on every pull request and every push to main, and ci-preflight is still the scaffolded no-op that skips nothing. A topology.yaml pushed straight to main carries no prior attestation, so attest does not short-circuit the stages either. deploy.yml is untouched: it still skips topology-only pushes, because topology.yaml is fetched at runtime and needs no deploy. That is exactly why the schema gate has to exist somewhere else, and it is now ci-test. Both comments that pointed at the deleted file say so instead. --- .github/workflows/ci-test.yml | 4 ++- .github/workflows/validate-topology.yml | 33 ------------------------- worker/test/topology-file.test.ts | 7 +++--- 3 files changed, 7 insertions(+), 37 deletions(-) delete mode 100644 .github/workflows/validate-topology.yml diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 0530167..b175513 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -6,7 +6,9 @@ # # This is ci.yml's test step: `pnpm -r test`, which runs vitest in both # workspaces. That includes worker/test/topology-file.test.ts, so topology.yaml -# is schema-validated on every PR here too, not only in validate-topology.yml. +# is schema-validated here — which is why validate-topology.yml is gone: it ran +# the same test, only on topology-only changes, while this runs on every PR and +# every push to main. # # No `gt-coverage` artifact yet: neither workspace declares a test:coverage # script or pulls in @vitest/coverage-v8, so there is nothing to upload. diff --git a/.github/workflows/validate-topology.yml b/.github/workflows/validate-topology.yml deleted file mode 100644 index 26d2a5c..0000000 --- a/.github/workflows/validate-topology.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Topology changes deliberately skip the deploy workflow (they're runtime data), -# but main's topology.yaml is exactly what every prober fetches — an invalid -# file would pin monitoring to last-known-good forever. This workflow is the -# validation gate deploy.yml's paths-ignore opts out of. -name: validate-topology - -on: - push: - branches: [main] - paths: ["topology.yaml"] - pull_request: - paths: ["topology.yaml"] - -permissions: - contents: read - packages: read - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - name: Install worker deps - run: pnpm install --frozen-lockfile --filter @wardnet/status-worker - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Validate topology.yaml against the schema - run: pnpm --filter @wardnet/status-worker exec vitest run test/topology-file.test.ts diff --git a/worker/test/topology-file.test.ts b/worker/test/topology-file.test.ts index 328d672..2b3e41e 100644 --- a/worker/test/topology-file.test.ts +++ b/worker/test/topology-file.test.ts @@ -8,9 +8,10 @@ import { parseTopology } from "../src/topology"; /** * Validates the REAL topology.yaml at the repo root — the artifact every - * prober fetches from main at runtime. CI runs this on topology-only pushes - * (.github/workflows/validate-topology.yml) so an invalid file can never land - * on main and silently pin the probers to last-known-good. + * prober fetches from main at runtime. It runs in ci-test on every pull + * request and every push to main, so an invalid file can never land on main + * and silently pin the probers to last-known-good. deploy.yml skips topology + * changes (they need no deploy), which is why that gate has to live here. */ describe("topology.yaml (repo root)", () => { it("parses against the schema", () => {