Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .bulwark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# .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:
#
# 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: 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
45 changes: 45 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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`, 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:
- 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
217 changes: 217 additions & 0 deletions .github/workflows/ci-orchestration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# 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.
#
# 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: write

preflight:
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
# 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
# `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
# 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
# `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
# 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
# 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

# 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}")
# 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" \
-f target_url="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}" >/dev/null
echo "attested tree ${tree} on ${SHA}"
38 changes: 38 additions & 0 deletions .github/workflows/ci-preflight.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading