diff --git a/.bulwark.yml b/.bulwark.yml new file mode 100644 index 0000000..8d26a31 --- /dev/null +++ b/.bulwark.yml @@ -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 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..e47958d --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -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 diff --git a/.github/workflows/ci-orchestration.yml b/.github/workflows/ci-orchestration.yml new file mode 100644 index 0000000..284551b --- /dev/null +++ b/.github/workflows/ci-orchestration.yml @@ -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}" 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..b175513 --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,46 @@ +# 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`, or the orchestrator loses +# the stage. +# +# 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 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. +# .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: + - 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 new file mode 100644 index 0000000..42d98e9 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,38 @@ +# 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 + # 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/.github/workflows/gt-sync.yml b/.github/workflows/gt-sync.yml new file mode 100644 index 0000000..7e4060e --- /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 a moving major 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/.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/.gt-repo.yaml b/.gt-repo.yaml new file mode 100644 index 0000000..7961d1d --- /dev/null +++ b/.gt-repo.yaml @@ -0,0 +1,37 @@ +# 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. +# +# 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: 1.3.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. +pipeline: + ci: + stages: + - preflight + - build + - test + cd: + enabled: false 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..2b3e41e 100644 --- a/worker/test/topology-file.test.ts +++ b/worker/test/topology-file.test.ts @@ -1,17 +1,21 @@ +// 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"; /** * 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", () => { - 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) {