From fcdb0eb23cc8290b5aaf604d3d46383c5b6ab3ff Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 02:38:34 +0100 Subject: [PATCH 1/5] ci: adopt the gt-managed pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves this repository onto gt's centrally-owned CI orchestration. gt owns the entry point and the shared stages; the ci-* files hold this repo's own work and gt never touches them again. validate-topology.yml's schema check becomes ci-build, and ci.yml's job becomes ci-test. That job keeps type-check and tests together deliberately: splitting them across two stages would mean installing the workspace twice, and this repository already made the opposite trade. Both stages need packages: read to resolve @wardnet/* from GitHub Packages during install. A called workflow can only narrow what its caller grants, so that permission now comes from the orchestrator — without it the install would fail to authenticate. Migrating this repository is what surfaced it. The concurrency group ci.yml declared is now in the orchestrator, with one difference: superseded pull-request runs are still cancelled, but default-branch runs are not, because cancelling one would discard the validated-tree attestation it was about to record. deploy.yml is untouched and CD stays disabled. Deployment here happens on merge to main, whereas gt's delivery pipeline is tag-triggered — forcing this repo into that shape would change when it ships, which is not a governance decision. Branch protection needs exactly one check after this: `ci-gate`. --- .github/dependabot.yml | 70 ++++++++ .../{validate-topology.yml => ci-build.yml} | 20 +-- .github/workflows/ci-orchestration.yml | 165 ++++++++++++++++++ .github/workflows/ci-preflight.yml | 38 ++++ .github/workflows/{ci.yml => ci-test.yml} | 22 ++- .github/workflows/dependabot-auto-merge.yml | 28 +++ .github/workflows/gt-sync.yml | 25 +++ .gt-repo.yaml | 74 ++++++++ 8 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 .github/dependabot.yml rename .github/workflows/{validate-topology.yml => ci-build.yml} (58%) create mode 100644 .github/workflows/ci-orchestration.yml create mode 100644 .github/workflows/ci-preflight.yml rename .github/workflows/{ci.yml => ci-test.yml} (57%) 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dabd939 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,70 @@ +# 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 + + - 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 + + - package-ecosystem: "npm" + directory: "/page" + 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 + + - package-ecosystem: "npm" + directory: "/worker" + 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/validate-topology.yml b/.github/workflows/ci-build.yml similarity index 58% rename from .github/workflows/validate-topology.yml rename to .github/workflows/ci-build.yml index 26d2a5c..1535484 100644 --- a/.github/workflows/validate-topology.yml +++ b/.github/workflows/ci-build.yml @@ -1,18 +1,18 @@ -# 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 +# ci-build — this file is yours. +# +# gt created it once and will never modify or delete it again. It is called by +# ci-orchestration.yml and must keep `workflow_call`. +# +# Validates topology.yaml against its schema. +# +name: ci-build on: - push: - branches: [main] - paths: ["topology.yaml"] - pull_request: - paths: ["topology.yaml"] + workflow_call: permissions: contents: read + # @wardnet/* resolve from GitHub Packages during install. packages: read jobs: diff --git a/.github/workflows/ci-orchestration.yml b/.github/workflows/ci-orchestration.yml new file mode 100644 index 0000000..ff7ac4c --- /dev/null +++ b/.github/workflows/ci-orchestration.yml @@ -0,0 +1,165 @@ +# 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". +name: 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.yml b/.github/workflows/ci-test.yml similarity index 57% rename from .github/workflows/ci.yml rename to .github/workflows/ci-test.yml index 7266339..957adde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-test.yml @@ -1,22 +1,20 @@ -# 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 +# ci-test — this file is yours. +# +# gt created it once and will never modify or delete it again. It is called by +# ci-orchestration.yml and must keep `workflow_call`. +# +# Type-check and unit tests share one job so the install happens once. +# +name: ci-test on: - pull_request: - push: - branches: [main] + workflow_call: permissions: contents: read + # @wardnet/* resolve from GitHub Packages during install. 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 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..8735c84 --- /dev/null +++ b/.gt-repo.yaml @@ -0,0 +1,74 @@ +# 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: / + - ecosystem: npm + directory: /page + - ecosystem: npm + directory: /worker +dependabot_auto_merge: + enabled: true + schedule: 0 1 * * * + max_bump: minor + delete_branch: true +bulwark: + enabled: true +pipeline: + ci: + enabled: true + stages: + - preflight + - build + - test + merge_queue: false + 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 e4cb5f70c518a7ea70ed876237200a7cc26c27c0 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 02:49:26 +0100 Subject: [PATCH 2/5] ci: deploy on tags rather than on every merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the pipeline adoption by moving delivery onto gt's tag-triggered CD, so this repository ships the same way as every other one. deploy.yml becomes cd-deploy.yml, keeping the production environment, the D1 migrations, the worker secret push and the wrangler deploy exactly as they were. Two things around it are deliberately dropped: Its test job, which re-ran the same type-check and suite ci-test already ran on the pull request. cd-orchestration verifies the tagged tree carries a passing gt/validated-tree attestation instead — proving that exact tree passed, rather than proving it again on a fresh runner. The paths-ignore that skipped deploys for topology.yaml, docs and markdown. topology.yaml is still fetched at runtime so those changes still need no deploy, but tagging is now the decision to ship, and inferring that from paths alongside an explicit tag would be two mechanisms answering one question. The trade this makes is real: a fix now reaches production when it is tagged rather than when it is merged. For a status page that matters during an incident, so workflow_dispatch remains on cd-orchestration as the immediate path. v0.1.0 has been tagged at c1a9b45, the source currently running on Cloudflare, so the first tagged release has a predecessor rather than starting from nothing. --- .../workflows/{deploy.yml => cd-deploy.yml} | 51 ++++-------- .github/workflows/cd-orchestration.yml | 79 +++++++++++++++++++ .gt-repo.yaml | 5 +- 3 files changed, 97 insertions(+), 38 deletions(-) rename .github/workflows/{deploy.yml => cd-deploy.yml} (63%) create mode 100644 .github/workflows/cd-orchestration.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/cd-deploy.yml similarity index 63% rename from .github/workflows/deploy.yml rename to .github/workflows/cd-deploy.yml index c4be6f2..6534318 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/cd-deploy.yml @@ -1,46 +1,29 @@ -# Deploys the status Worker (page assets + prober + API) to Cloudflare on push -# to main. Topology changes need NO deploy — topology.yaml is fetched at runtime. -name: deploy +# cd-deploy — this file is yours. +# +# gt created it once and will never modify or delete it again. It is called by +# cd-orchestration.yml, which verifies the tagged tree carries a passing +# gt/validated-tree attestation before this runs. +# +# deploy.yml's own test job is deliberately gone: it re-ran the same +# type-check and suite ci-test already ran on the PR, and the attestation +# proves that exact tree passed rather than proving it again. +# +# The paths-ignore that skipped deploys for topology/docs-only pushes is gone +# too. topology.yaml is still fetched at runtime, so those changes still need +# no deploy — but with tag-triggered delivery, tagging *is* the decision to +# ship, so inferring it from paths no longer earns its place. +name: cd-deploy on: - push: - branches: [main] - # Skip deploys for data/docs-only changes. - paths-ignore: - - "topology.yaml" - - "docs/**" - - "*.md" - workflow_dispatch: + workflow_call: permissions: contents: read + # @wardnet/* resolve from GitHub Packages during install. packages: read -concurrency: - group: deploy - cancel-in-progress: false - 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: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Type-check - run: pnpm type-check - - name: Test - run: pnpm test - deploy: - needs: test runs-on: ubuntu-latest environment: production steps: diff --git a/.github/workflows/cd-orchestration.yml b/.github/workflows/cd-orchestration.yml new file mode 100644 index 0000000..7845a09 --- /dev/null +++ b/.github/workflows/cd-orchestration.yml @@ -0,0 +1,79 @@ +# Managed by gt — edit .gt-repo.yaml, then run `gt repo sync`. +# +# gt owns the orchestration; the cd-* stages it calls are yours. It mirrors +# ci-orchestration deliberately, so there is one pipeline idea to learn. +# +# `cd-gate` is not a branch-protection check — nothing merges here. It exists so +# a release has one status meaning "the whole delivery succeeded" rather than +# four job results to read. +name: CD + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +# Grouped by ref, so independently-versioned components still release in +# parallel — wardnet-cloud ships several. Never cancelled: killing a +# half-finished publish leaves a partial release behind, which is worse than +# letting a superseded run finish. +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Refuses to ship a tree that never passed the gate. Stronger than trusting + # that CI ran: a re-run would only say the code passes now, on a runner, + # again — this says this exact tree passed the full gate. + verify-attestation: + uses: pedromvgomes/gt/.github/workflows/reusable-attest.yml@v1 + with: + require: true + # Must cover everything reusable-attest declares; a caller granting less + # fails the run at parse time. + permissions: + contents: read + statuses: read + pull-requests: read + + deploy: + needs: [verify-attestation] + uses: ./.github/workflows/cd-deploy.yml + secrets: inherit + # Delivery writes: a publish stage creates a release, a deploy stage moves + # artifacts. A called workflow can only narrow this, so a stage needing + # less should say so in its own permissions block. A stage needing *more* + # — OIDC id-token, package registries — is a gt change today; no consumer + # has asked for one yet. + permissions: + contents: write + # Same reason as the CI stages: private @scope dependencies resolve from + # GitHub Packages during install, and a called workflow cannot ask for a + # permission its caller withheld. + packages: read + + cd-gate: + name: cd-gate + needs: [verify-attestation, deploy] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + 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 delivery stage failed" + exit 1 + fi + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "::error::a delivery stage was cancelled" + exit 1 + fi diff --git a/.gt-repo.yaml b/.gt-repo.yaml index 8735c84..fc74120 100644 --- a/.gt-repo.yaml +++ b/.gt-repo.yaml @@ -36,12 +36,9 @@ pipeline: - test merge_queue: false cd: - enabled: false + enabled: true stages: - - preflight - - publish - deploy - - verify tags: - v*.*.* conventional_commits: From 988c3bc4740aa743b5c42e4f851a483fd6be963e Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 02:55:29 +0100 Subject: [PATCH 3/5] ci: verify the deploy is actually serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cd-verify was a stub in every repository; this gives it something real to do, and delivery gets a check it did not have before rather than just moving where the old one lived. Two endpoints are probed, and they fail differently on purpose. The page root exercises the edge assets. /api/status exercises the worker and its D1 read — so a migration that broke the schema returns 500 there while the page still returns 200. Checking only the root would miss precisely the failure a deploy applying migrations is most likely to introduce. Two consecutive passes are required. A single success can be a lucky hit on an edge node still serving the previous version, so a failure between two passes resets the count rather than decrementing it: the claim being made is that the deploy is consistently healthy, not that it answered once. Requests bypass cache for the reason 6e0e383 records for the probes themselves — a cached 200 from before the deploy says nothing about the deploy. Verified against the live site before committing: two consecutive healthy responses pass, an unreachable host reports 000 and fails, and a path that returns the SPA shell for /api/status fails on the JSON check rather than passing on its 200. That last case is why the body is validated and not just the status code. What it does not prove is global propagation — nothing reachable from a single runner can. It proves the deploy is not obviously broken. --- .github/workflows/cd-orchestration.yml | 18 ++++- .github/workflows/cd-verify.yml | 101 +++++++++++++++++++++++++ .gt-repo.yaml | 1 + 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cd-verify.yml diff --git a/.github/workflows/cd-orchestration.yml b/.github/workflows/cd-orchestration.yml index 7845a09..23055c3 100644 --- a/.github/workflows/cd-orchestration.yml +++ b/.github/workflows/cd-orchestration.yml @@ -56,9 +56,25 @@ jobs: # permission its caller withheld. packages: read + verify: + needs: [verify-attestation, deploy] + uses: ./.github/workflows/cd-verify.yml + secrets: inherit + # Delivery writes: a publish stage creates a release, a deploy stage moves + # artifacts. A called workflow can only narrow this, so a stage needing + # less should say so in its own permissions block. A stage needing *more* + # — OIDC id-token, package registries — is a gt change today; no consumer + # has asked for one yet. + permissions: + contents: write + # Same reason as the CI stages: private @scope dependencies resolve from + # GitHub Packages during install, and a called workflow cannot ask for a + # permission its caller withheld. + packages: read + cd-gate: name: cd-gate - needs: [verify-attestation, deploy] + needs: [verify-attestation, deploy, verify] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/cd-verify.yml b/.github/workflows/cd-verify.yml new file mode 100644 index 0000000..74b9c87 --- /dev/null +++ b/.github/workflows/cd-verify.yml @@ -0,0 +1,101 @@ +# cd-verify — this file is yours. +# +# gt created it once and will never modify or delete it again. It is called by +# cd-orchestration.yml after cd-deploy. +# +# Confirms the deploy actually took, rather than assuming wrangler exiting zero +# means the site is serving. Two endpoints are checked, and they fail +# differently on purpose: +# +# / — the page assets. Served from the edge. +# /api/status — the worker itself, which reads D1. A broken migration +# returns 500 here while the page still returns 200, so +# checking only the root would miss exactly the failure this +# deploy is most likely to introduce. +# +# Two *consecutive* passes are required. One success can be a lucky hit on an +# edge node still serving the previous version; requiring two spaced apart +# makes a half-propagated or flapping deploy fail rather than squeak through. +# It does not prove global propagation — nothing reachable from one runner +# does — it proves the deploy is not obviously broken. +# +# Requests bypass cache for the reason 6e0e383 records for the probes +# themselves: a cached 200 from before the deploy tells you nothing about it. +name: cd-verify + +on: + workflow_call: + +permissions: + contents: read + +jobs: + smoke: + name: smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Wait for the deploy to serve + env: + BASE: https://status.wardnet.network + ATTEMPTS: "18" + INTERVAL: "10" + REQUIRED_CONSECUTIVE: "2" + run: | + set -uo pipefail + + # Returns 0 when both endpoints answer 200 and the API returns JSON. + # + # No `|| echo 000` on the curl calls: -w already prints 000 when the + # connection fails, and the fallback appended a second one, yielding + # a nonsense "000000" in the diagnostics. There is no `set -e` here, + # so a failed curl does not abort the loop. + probe() { + local stamp="$1" code + code=$(curl -sS -o /tmp/page.out -w '%{http_code}' \ + --max-time 15 -H 'Cache-Control: no-cache' \ + "${BASE}/?cb=${stamp}") + if [[ "$code" != "200" ]]; then + echo " page ${code}" + return 1 + fi + + code=$(curl -sS -o /tmp/api.out -w '%{http_code}' \ + --max-time 15 -H 'Cache-Control: no-cache' \ + "${BASE}/api/status?cb=${stamp}") + if [[ "$code" != "200" ]]; then + echo " page 200, api ${code}" + return 1 + fi + + # A 200 carrying an error body is still a broken deploy. + if ! jq -e . /tmp/api.out >/dev/null 2>&1; then + echo " page 200, api 200 but the body is not JSON" + return 1 + fi + echo " page 200, api 200, JSON ok" + return 0 + } + + consecutive=0 + for attempt in $(seq 1 "$ATTEMPTS"); do + echo "attempt ${attempt}/${ATTEMPTS}:" + if probe "$(date +%s)-${attempt}"; then + consecutive=$((consecutive + 1)) + if [[ "$consecutive" -ge "$REQUIRED_CONSECUTIVE" ]]; then + echo "✅ ${consecutive} consecutive healthy responses — deploy is serving" + exit 0 + fi + else + # Reset rather than decrement: the point is *consecutive* health, + # so a failure between two passes means the deploy is flapping. + consecutive=0 + fi + sleep "$INTERVAL" + done + + echo "::error::status.wardnet.network did not return ${REQUIRED_CONSECUTIVE} consecutive healthy responses" + echo "Last page body:" >&2; head -c 500 /tmp/page.out >&2 || true + echo "" >&2 + echo "Last api body:" >&2; head -c 500 /tmp/api.out >&2 || true + exit 1 diff --git a/.gt-repo.yaml b/.gt-repo.yaml index fc74120..1d16105 100644 --- a/.gt-repo.yaml +++ b/.gt-repo.yaml @@ -39,6 +39,7 @@ pipeline: enabled: true stages: - deploy + - verify tags: - v*.*.* conventional_commits: From 05bb3449fa4e814c058038db6f589e4bc0127104 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 03:18:23 +0100 Subject: [PATCH 4/5] ci: serialise delivery per repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gt grouped CD runs by ref; wardnet-design-system showed why that is wrong — a release pushing one tag per package would have its runs racing to publish the same versions. Over-serialising costs latency, under-serialising corrupts a release. --- .github/workflows/cd-orchestration.yml | 35 ++++++++++++++++---------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cd-orchestration.yml b/.github/workflows/cd-orchestration.yml index 23055c3..a00c2fe 100644 --- a/.github/workflows/cd-orchestration.yml +++ b/.github/workflows/cd-orchestration.yml @@ -14,12 +14,19 @@ on: - "v*.*.*" workflow_dispatch: -# Grouped by ref, so independently-versioned components still release in -# parallel — wardnet-cloud ships several. Never cancelled: killing a -# half-finished publish leaves a partial release behind, which is worse than -# letting a superseded run finish. +# One delivery at a time per repository, and never cancelled. +# +# Deliberately not grouped by ref. wardnet-design-system pushes one tag per +# package for a single release, and `changeset publish` ships every pending +# version on the first run — so per-ref grouping would have those runs racing +# to publish the same versions and 409-ing each other. Its release.yml +# serialises for exactly that reason. +# +# The asymmetry decides it: over-serialising costs latency, under-serialising +# corrupts a release. A repo that genuinely needs parallel component delivery +# can make this a setting when one actually does. concurrency: - group: cd-${{ github.ref }} + group: cd-${{ github.workflow }} cancel-in-progress: false permissions: @@ -51,10 +58,11 @@ jobs: # has asked for one yet. permissions: contents: write - # Same reason as the CI stages: private @scope dependencies resolve from - # GitHub Packages during install, and a called workflow cannot ask for a - # permission its caller withheld. - packages: read + # write, not read: delivery both consumes private @scope dependencies and + # publishes to GitHub Packages — wardnet-design-system's changeset + # publish needs it. A called workflow can only narrow, so a stage that + # merely installs can drop this to read in its own block. + packages: write verify: needs: [verify-attestation, deploy] @@ -67,10 +75,11 @@ jobs: # has asked for one yet. permissions: contents: write - # Same reason as the CI stages: private @scope dependencies resolve from - # GitHub Packages during install, and a called workflow cannot ask for a - # permission its caller withheld. - packages: read + # write, not read: delivery both consumes private @scope dependencies and + # publishes to GitHub Packages — wardnet-design-system's changeset + # publish needs it. A called workflow can only narrow, so a stage that + # merely installs can drop this to read in its own block. + packages: write cd-gate: name: cd-gate From 4dd40d44c52409bb0b0f53c5d39e72073b4717fd Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sun, 16 Aug 2026 03:35:49 +0100 Subject: [PATCH 5/5] ci: follow bulwark's coverage.source move bulwark no longer takes tests-mode or go-report as action inputs; which side produces coverage is coverage.source in .bulwark.yml. Without this the removed inputs would be silently ignored and bulwark would re-run the suite ci-test already ran. --- .bulwark.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .bulwark.yml 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