From b00c3ebe91fed12fd366ff46684f57929fb347c0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:33:46 +0200 Subject: [PATCH 01/25] ci: enforce Conventional Commits titles on commits and pull requests The first product built on the template went from 98% conventional subjects to 13% once the agent harness changed and the prose guidance stopped being read. A commit-msg hook and a pr-title workflow now share one checker; squash merges take the PR title. AGENTS.md records the harness-neutral rule and keeps generated content out of hand-written PRs. --- .github/desired-repo-settings.json | 6 ++-- .github/workflows/pr-title.yml | 30 ++++++++++++++++ AGENTS.md | 23 ++++++++++++ apps/docs/public/agents.md | 1 + scripts/audit-repo-settings.sh | 4 +-- scripts/ci/check-commit-title.sh | 44 +++++++++++++++++++++++ scripts/ci/install-git-hooks.sh | 16 +++++++-- tools/agent/commit-title.test.ts | 57 ++++++++++++++++++++++++++++++ 8 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pr-title.yml create mode 100755 scripts/ci/check-commit-title.sh create mode 100644 tools/agent/commit-title.test.ts diff --git a/.github/desired-repo-settings.json b/.github/desired-repo-settings.json index fa8d2c52..62a6393d 100644 --- a/.github/desired-repo-settings.json +++ b/.github/desired-repo-settings.json @@ -9,7 +9,8 @@ "delete_branch_on_merge": true, "allow_squash_merge": true, "allow_merge_commit": false, - "allow_rebase_merge": false + "allow_rebase_merge": false, + "squash_merge_commit_title": "PR_TITLE" }, "branch_protection_main": { "required_status_check_contexts": [ @@ -23,7 +24,8 @@ "typecheck + lint + test", "validate", "yamllint (compose + workflows)", - "agent verification contract" + "agent verification contract", + "pull request title (conventional commit)" ], "required_approving_review_count": 0, "dismiss_stale_reviews": true, diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..6273be61 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,30 @@ +name: pr-title + +# Squash merges make the PR title the commit subject on main. The commit-msg +# hook only sees local commits, so the title gets the same check here. + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + title: + name: pull request title (conventional commit) + runs-on: ubuntu-24.04 + timeout-minutes: 2 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts/ci + - name: Check the title + env: + TITLE: ${{ github.event.pull_request.title }} + run: bash scripts/ci/check-commit-title.sh "$TITLE" diff --git a/AGENTS.md b/AGENTS.md index 0b5cbea2..56bd5890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,3 +94,26 @@ cross-checks this manifest against the real `.env.example` files and FAILS if a watched toggle is neither modelled here nor waived via `watchIgnore`. A drifted manifest is a red build, not a silent gap. The `_about` field at the top of the JSON restates this for anyone who opens the file directly. + +## Commits and pull requests + +Every commit subject and PR title is a Conventional Commit: +`()!?: `, at most 100 characters, for example +`fix(api): keep the session after refresh`. This is a gate, not a guideline. +The `commit-msg` hook (installed by `bun install`) and the `pr-title` workflow +both run `scripts/ci/check-commit-title.sh`. Main is squash-merged, so the PR +title is the commit that lands. + +These instructions are harness-neutral. Codex, Claude Code and other agents all +start here, and `CLAUDE.md` only points at this file. Put a convention a +product depends on into a hook, a lint rule or CI, and keep this file in sync +with it. Prose that only one harness reads stops being followed when the +harness changes. + +## Generated content and data + +Large generated artifacts, such as content libraries, catalogue imports and +rendered diagrams, do not belong in the same commit or PR as hand-written +code. Commit the generator and its source data, then regenerate in a separate +`chore(content): ...` change, or build the artifacts in CI. A 500k-line diff +cannot be reviewed, and it hides the code change that travels with it. diff --git a/apps/docs/public/agents.md b/apps/docs/public/agents.md index f608e26b..1e7765bc 100644 --- a/apps/docs/public/agents.md +++ b/apps/docs/public/agents.md @@ -146,6 +146,7 @@ CI runs the same gates and a pre-push hook mirrors them. | Env access goes through the validator | No direct `process.env` / `import.meta.env`. One typed entry point, and the schema must match `.env.example`. | | UI talks to the API only via the generated client | No raw `fetch` outside `src/lib/api`. Regenerate with `bun run regen` after an API change. | | Dependencies are exact-pinned | No ranges. New packages face a 7-day `minimumReleaseAge` quarantine, so a fresh malicious release cannot land. | +| Commits and PR titles are Conventional Commits | `type(scope): summary`, checked by the `commit-msg` hook and the `pr-title` workflow. Main is squash-merged, so the PR title is what lands. | After changing anything cross-cutting: `bun run regen` then `bun run check`. diff --git a/scripts/audit-repo-settings.sh b/scripts/audit-repo-settings.sh index 67b913f8..49276b5a 100755 --- a/scripts/audit-repo-settings.sh +++ b/scripts/audit-repo-settings.sh @@ -53,10 +53,10 @@ done # # --- Section 2: merge settings # -MERGE_CURRENT=$(gh api "repos/$REPO" --jq '{delete_branch_on_merge, allow_squash_merge, allow_merge_commit, allow_rebase_merge}') +MERGE_CURRENT=$(gh api "repos/$REPO" --jq '{delete_branch_on_merge, allow_squash_merge, allow_merge_commit, allow_rebase_merge, squash_merge_commit_title}') MERGE_DESIRED=$(jq -c '.merge' "$DESIRED") -for key in delete_branch_on_merge allow_squash_merge allow_merge_commit allow_rebase_merge; do +for key in delete_branch_on_merge allow_squash_merge allow_merge_commit allow_rebase_merge squash_merge_commit_title; do want=$(echo "$MERGE_DESIRED" | jq -r --arg k "$key" '.[$k]') have=$(echo "$MERGE_CURRENT" | jq -r --arg k "$key" '.[$k]') if [[ "$have" != "$want" ]]; then diff --git a/scripts/ci/check-commit-title.sh b/scripts/ci/check-commit-title.sh new file mode 100755 index 00000000..bfae5a6e --- /dev/null +++ b/scripts/ci/check-commit-title.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Enforce the Conventional Commits subject line on commits and PR titles. +# +# scripts/ci/check-commit-title.sh "feat(api): add invoices" # a title +# scripts/ci/check-commit-title.sh --file .git/COMMIT_EDITMSG # a message file +# +# The commit-msg hook (scripts/ci/install-git-hooks.sh) checks local commits; +# .github/workflows/pr-title.yml checks the PR title, which becomes the squash +# commit on main. Prose that only lives in a guide does not survive a change +# of agent or harness, so the convention is a gate, not a suggestion. + +set -euo pipefail + +TYPES='feat|fix|perf|refactor|test|docs|build|ci|chore|style|revert|deps' +PATTERN="^(${TYPES})(\([a-z0-9][a-z0-9._/-]*\))?!?: [^ ].*$" + +if [[ "${1:-}" == "--file" ]]; then + [[ -n "${2:-}" && -f "$2" ]] || { echo "check-commit-title: no message file: ${2:-}" >&2; exit 2; } + # First line that is neither blank nor a git comment. + title="$(grep -v '^#' "$2" | grep -m1 -v '^[[:space:]]*$' || true)" +else + title="${1:-}" +fi + +# Git's own generated subjects pass untouched: autosquash markers, merges and +# reverts are rewritten or squashed away before they reach main. +case "$title" in + "fixup! "* | "squash! "* | "amend! "* | "Merge "* | "Revert \""*) exit 0 ;; +esac + +if [[ "$title" =~ $PATTERN ]] && (( ${#title} <= 100 )); then + exit 0 +fi + +cat >&2 <()!?: , at most 100 characters. +Types: ${TYPES//|/, } +Example: feat(api): invoices can be voided +EOF +exit 1 diff --git a/scripts/ci/install-git-hooks.sh b/scripts/ci/install-git-hooks.sh index 630347e8..0a77b3e2 100755 --- a/scripts/ci/install-git-hooks.sh +++ b/scripts/ci/install-git-hooks.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash -# Install the root-level git pre-push hook for the monorepo. +# Install the root-level git pre-push and commit-msg hooks for the monorepo. # # Run automatically by the root `package.json` postinstall on # `bun install`. Safe to run manually: # # bun run scripts/ci/install-git-hooks.sh # -# The hook delegates to scripts/ci/pre-push.sh, which fans out to each +# pre-push delegates to scripts/ci/pre-push.sh, which fans out to each # app's own husky gate based on what changed since `origin/main`. +# commit-msg delegates to scripts/ci/check-commit-title.sh. set -euo pipefail @@ -46,3 +47,14 @@ EOF chmod +x "$HOOK_FILE" echo "[install-git-hooks] Installed root pre-push hook → $HOOK_FILE" + +COMMIT_MSG_HOOK="$HOOKS_DIR/commit-msg" + +cat > "$COMMIT_MSG_HOOK" <<'EOF' +#!/usr/bin/env bash +# Auto-installed by scripts/ci/install-git-hooks.sh: Conventional Commits titles. +exec "$(git rev-parse --show-toplevel)/scripts/ci/check-commit-title.sh" --file "$1" +EOF + +chmod +x "$COMMIT_MSG_HOOK" +echo "[install-git-hooks] Installed root commit-msg hook → $COMMIT_MSG_HOOK" diff --git a/tools/agent/commit-title.test.ts b/tools/agent/commit-title.test.ts new file mode 100644 index 00000000..2ba3c308 --- /dev/null +++ b/tools/agent/commit-title.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath( + new URL("../../scripts/ci/check-commit-title.sh", import.meta.url) +); + +const check = (...args: string[]): number => + spawnSync("/bin/bash", [script, ...args], { encoding: "utf8" }).status ?? -1; + +test("conventional titles pass", () => { + for (const title of [ + "feat(api): invoices can be voided", + "fix: keep the session after refresh", + "feat(k3s)!: pin every image digest in one release commit", + "chore(deps): bump elysia", + "fixup! feat(api): invoices can be voided", + 'Revert "feat(api): invoices can be voided"', + ]) { + expect(check(title)).toBe(0); + } +}); + +test("prose, unknown types and overlong titles fail", () => { + for (const title of [ + "Homepage leads with the product demo video (#77)", + "feature(api): invoices", + "feat(API): uppercase scope", + "feat:missing space", + "", + `feat: ${"x".repeat(100)}`, + ]) { + expect(check(title)).toBe(1); + } +}); + +test("message files are read from their first non-comment line", () => { + const dir = mkdtempSync(join(tmpdir(), "commit-title-")); + + try { + const good = join(dir, "good"); + + writeFileSync(good, "# comment\n\nfix(ui): label contrast\n\nBody.\n"); + const bad = join(dir, "bad"); + + writeFileSync(bad, "Fix the label contrast\n"); + expect(check("--file", good)).toBe(0); + expect(check("--file", bad)).toBe(1); + expect(check("--file", join(dir, "missing"))).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 27398427f8c47e245df3d70b11de06a7a9ce26ae Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:42:55 +0200 Subject: [PATCH 02/25] feat(scripts): discover packages/* workspaces in check, pre-push and CI Tinkercaster added packages/wiring-engine (36k lines) and apps/site and both fell outside the gates; template tooling also hard-coded the app list and failed when an app was removed. Root check and pre-push now validate every packages/ with a package.json, pre-push discovers apps/*, the per-app dependency scans skip apps that are no longer in the checkout, and Dependabot covers packages/*. The shared-packages guide documents the convention. --- .github/dependabot.yml | 25 +++++ .github/workflows/apps-api-security-deps.yml | 3 + .github/workflows/apps-docs-security-deps.yml | 3 + .github/workflows/apps-ui-security-deps.yml | 3 + AGENTS.md | 1 + apps/ui/AGENTS.md | 1 + apps/ui/docs/agents/shared-packages.md | 91 +++++++++++++++++++ scripts/ci/pre-push.sh | 13 ++- scripts/stack-check.sh | 3 + scripts/stack-lib.sh | 29 ++++++ 10 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 apps/ui/docs/agents/shared-packages.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0017c55..8aded44a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -121,6 +121,31 @@ updates: - dependency-name: "@astrojs/cloudflare" update-types: ["version-update:semver-major"] + # Shared workspace packages under packages/. The glob matches nothing when the + # directory is absent, so products without packages/ get no entry in effect. + - package-ecosystem: bun + directories: + - /packages/* + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Berlin + open-pull-requests-limit: 4 + labels: ["dependencies"] + commit-message: + prefix: "chore(deps)" + include: scope + cooldown: + default-days: 7 + groups: + production-dependencies: + dependency-type: "production" + patterns: ["*"] + development-dependencies: + dependency-type: "development" + patterns: ["*"] + - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/apps-api-security-deps.yml b/.github/workflows/apps-api-security-deps.yml index bb1879bc..79e17c34 100644 --- a/.github/workflows/apps-api-security-deps.yml +++ b/.github/workflows/apps-api-security-deps.yml @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/api/bun.lock') != '' with: filters: | code: diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index ffab84e4..18e623a3 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/docs/bun.lock') != '' with: filters: | code: diff --git a/.github/workflows/apps-ui-security-deps.yml b/.github/workflows/apps-ui-security-deps.yml index de786233..c44a714f 100644 --- a/.github/workflows/apps-ui-security-deps.yml +++ b/.github/workflows/apps-ui-security-deps.yml @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/ui/bun.lock') != '' with: filters: | code: diff --git a/AGENTS.md b/AGENTS.md index 56bd5890..abbea254 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ checks types, lint and formatting and is also included in root `check`. | `apps/docs` | Astro docs site | | `infra/compose` | Docker Compose runtime | | `infra/bootstrap` | OpenTofu bootstrap | +| `packages/*` | Shared code used by more than one app ([guide](apps/ui/docs/agents/shared-packages.md)) | | `.tsforge` | tsforge scaffold manifest (see below) | CI: `.github/workflows/` at repo root with path filters. diff --git a/apps/ui/AGENTS.md b/apps/ui/AGENTS.md index b57dcb53..a85edee8 100644 --- a/apps/ui/AGENTS.md +++ b/apps/ui/AGENTS.md @@ -25,6 +25,7 @@ focused pattern guides under [`docs/agents/`](docs/agents/). | Catching `ApiError`, error boundaries, Sentry | [errors](docs/agents/errors.md) | | Adding a shadcn primitive; `@theme` token wiring | [shadcn](docs/agents/shadcn.md) | | Adding an allowlist entry; debugging a security workflow | [security-pipeline](docs/agents/security-pipeline.md) | +| Sharing code across apps; a `packages/` workspace; consuming it | [shared-packages](docs/agents/shared-packages.md) | | Starting a UI feature or a vertical slice (`/build-feature`) | [feature-skills](docs/agents/feature-skills.md) | | Reviewing a diff or running instance for security | [security-skills](docs/agents/security-skills.md) | diff --git a/apps/ui/docs/agents/shared-packages.md b/apps/ui/docs/agents/shared-packages.md new file mode 100644 index 00000000..f93bb9ce --- /dev/null +++ b/apps/ui/docs/agents/shared-packages.md @@ -0,0 +1,91 @@ +# Shared packages (`packages/*`) + +Read when code must be shared by more than one app, when adding a package under +`packages/`, or when the UI starts consuming one. + +## Where shared code goes + +Code used by two or more apps goes in `packages//`. Code used by one app +stays in that app. A package holds no secrets and no app-specific routes or +pages. + +## Package shape + +``` +packages// + package.json "private": true, scripts below + src/index.ts public entry; everything else is internal + tsconfig.json extends ../../apps/ui/tsconfig.json + eslint.config.mjs imports the UI config (see below) +``` + +A package borrows the UI toolchain. Its lint, format and typecheck scripts run +the binaries installed in `apps/ui/node_modules`, so run `bun install` in +`apps/ui` first. A package with runtime dependencies of its own lists them in +its `package.json` and installs them itself. + +## Required scripts + +```json +{ + "lint": "bun ../../apps/ui/node_modules/eslint/bin/eslint.js --max-warnings=0 .", + "format:check": "bun ../../apps/ui/node_modules/prettier/bin/prettier.cjs --check .", + "typecheck": "bun ../../apps/ui/node_modules/typescript/bin/tsc -p tsconfig.json", + "validate": "bun run lint && bun run typecheck && bun run format:check" +} +``` + +`validate` is the package gate. It is required. `--max-warnings=0` is required +on `lint`, so a warning fails the gate the same way an error does. + +## Lint and tsconfig + +`eslint.config.mjs` reuses the UI rule set, so the package is held to the same +architecture rules as the app: + +```js +import uiConfig from "../../apps/ui/eslint.config.mjs"; + +export default [{ ignores: ["node_modules", "dist"] }, ...uiConfig]; +``` + +The UI config's file globs (`src/**`) are relative to the UI directory, so a +package keeps its code under `src/` to match them. Turn off a UI rule in a +later block only when it cannot apply, and say why in a comment. + +`tsconfig.json` extends the UI config and sets `"include": ["src/**/*.ts"]` with +`"noEmit": true`. + +## Root wiring + +- `scripts/stack-lib.sh` (`validate_packages`) runs `bun run validate` in every + `packages/*/` that has a `package.json`. A package without a `validate` script + fails the check. +- `scripts/stack-check.sh` runs that as the `packages validate` step of + `bun run check`. +- `scripts/ci/pre-push.sh` runs it when anything under `packages/` changed. +- `.github/workflows/apps-ui-validate.yml` runs it on `packages/**` changes. + +A product with no `packages/` directory gets no packages checks and no error. + +## Consuming a package from `apps/ui` + +Nothing in the UI build knows about packages until one is consumed. When the +first one is, add: + +- `tsconfig.json`: a `paths` entry such as + `"@/lib/": ["../../packages//src/index.ts"]` (keep the `@/*` entry + for `src/`), and the package's `src/**/*.ts` in `include`. Vitest reads these + paths through `tsconfigPaths: true`, so tests resolve the same way. +- `vite.config.ts`, `server.fs.allow`: add `"../../packages"`. Without it the dev + server refuses to serve files outside `apps/ui`. +- `vite.config.ts`, `resolve.dedupe`: list every dependency that both the app and + the package declare (for example `zod`), so the bundle carries one copy. + +## Known gaps + +- `lint:meta` scans only `src/`, `tests/`, `e2e/` and `.storybook/` + (`scripts/lint-meta/context.ts`). Its architecture rules do not run on + `packages/`. The package's own ESLint config is the only architecture gate + there. +- The bundle budget gate (`check:size-budget`) covers `apps/ui` output only. diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index 2855c682..a4db9d39 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -120,13 +120,24 @@ bash "$ROOT/scripts/ci/pre-push-smoke.sh" RAN_ANY=0 -for app in api ui docs; do +for app_path in "$ROOT"/apps/*/; do + [[ -d "$app_path" ]] || continue + app="$(basename "$app_path")" if app_changed "$app"; then run_app_gate "$app" RAN_ANY=1 fi done +# Shared packages run their own validate; they borrow the UI toolchain, so a +# change to them is gated here whether or not any app changed. +if [[ -z "$CHANGED_PATHS" ]] || grep -q '^packages/' <<< "$CHANGED_PATHS"; then + step "Running shared packages validate" + bash -c 'source "$1/scripts/stack-lib.sh" && validate_packages' _ "$ROOT" + ok "shared packages validate passed" + RAN_ANY=1 +fi + if infra_compose_changed; then step "Running infra/compose pre-push gate (compose config + shellcheck + yamllint)" bash "$ROOT/infra/compose/scripts/pre-push.sh" diff --git a/scripts/stack-check.sh b/scripts/stack-check.sh index e94f4ceb..7b0e32c0 100755 --- a/scripts/stack-check.sh +++ b/scripts/stack-check.sh @@ -47,6 +47,9 @@ fi run_check "ui lint-meta docs" bash -c "cd \"$BORINGSTACK_UI_DIR\" && bun run check:lint-meta-docs" run_check "ui scripts docs" bash -c "cd \"$BORINGSTACK_UI_DIR\" && bun run check:scripts-docs" +# Packages are discovered, so a product without packages/ skips this silently. +run_check "packages validate" validate_packages + if [[ "$HAS_DOCS" == "1" ]]; then run_check "docs data" bash -c "cd \"$BORINGSTACK_DOCS_DIR\" && BORINGSTACK_API_DIR=\"$BORINGSTACK_API_DIR\" BORINGSTACK_UI_DIR=\"$BORINGSTACK_UI_DIR\" bun run check:docs-data" fi diff --git a/scripts/stack-lib.sh b/scripts/stack-lib.sh index cf55a4f1..a3d1c4c0 100755 --- a/scripts/stack-lib.sh +++ b/scripts/stack-lib.sh @@ -7,6 +7,7 @@ export BORINGSTACK_ROOT="$ROOT" export BORINGSTACK_API_DIR="${BORINGSTACK_API_DIR:-$ROOT/apps/api}" export BORINGSTACK_UI_DIR="${BORINGSTACK_UI_DIR:-$ROOT/apps/ui}" export BORINGSTACK_DOCS_DIR="${BORINGSTACK_DOCS_DIR:-$ROOT/apps/docs}" +export BORINGSTACK_PACKAGES_DIR="${BORINGSTACK_PACKAGES_DIR:-$ROOT/packages}" export BORINGSTACK_INFRA_COMPOSE_DIR="${BORINGSTACK_INFRA_COMPOSE_DIR:-$ROOT/infra/compose/compose}" c_red() { printf '\033[1;31m%s\033[0m\n' "$*"; } @@ -37,3 +38,31 @@ require_api_swagger() { c_yellow " start the API or set OPENAPI_URL; strict evidence: bun run agent:verify -- --json" return 1 } + +# Workspace packages are every packages// that has a package.json. A +# missing packages/ directory means there are none, which is not an error. +list_packages() { + local dir + for dir in "$BORINGSTACK_PACKAGES_DIR"/*/; do + if [[ -f "${dir}package.json" ]]; then + printf '%s\n' "${dir%/}" + fi + done +} + +# Each package must define a `validate` script (its own lint, typecheck and +# tests). Runs all of them and returns non-zero if any fails. +validate_packages() { + local dir name failed=0 + while IFS= read -r dir; do + name="$(basename "$dir")" + step "package $name validate" + if (cd "$dir" && bun run validate); then + ok "package $name validate" + else + c_red "✗ package $name validate" + failed=1 + fi + done < <(list_packages) + return "$failed" +} From b32378193dd89bf44c91a72c457c8b5ce3a021e3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:42:55 +0200 Subject: [PATCH 03/25] feat(ui): bundle budget raises need a recorded reason Tinkercaster raised .size-limit.json in 30 commits (CSS 12 KB to 26.5 KB) although the documented policy reviews every raise. check:size-budget now fails when a limit goes up, or an entry is added, without a new reason line in apps/ui/budgets.md compared with the merge-base. It runs in validate, pre-push and the apps-ui-validate workflow. --- .github/workflows/apps-ui-validate.yml | 13 + apps/docs/src/data/scripts-catalog.json | 6 + apps/ui/budgets.md | 14 ++ apps/ui/package.json | 3 +- apps/ui/scripts/README.md | 1 + apps/ui/scripts/ci/pre-push.manifest.json | 3 +- apps/ui/scripts/ci/pre-push.sh | 3 +- .../quality/check-size-budget-raises.ts | 233 ++++++++++++++++++ .../scripts/check-size-budget-raises.test.ts | 159 ++++++++++++ 9 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 apps/ui/budgets.md create mode 100644 apps/ui/scripts/quality/check-size-budget-raises.ts create mode 100644 apps/ui/tests/scripts/check-size-budget-raises.test.ts diff --git a/.github/workflows/apps-ui-validate.yml b/.github/workflows/apps-ui-validate.yml index 6be8411f..6f11165f 100644 --- a/.github/workflows/apps-ui-validate.yml +++ b/.github/workflows/apps-ui-validate.yml @@ -7,6 +7,7 @@ on: branches: [main] paths: - "apps/ui/**" + - "packages/**" - ".github/workflows/apps-ui-validate.yml" concurrency: @@ -26,6 +27,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: the budget gate diffs .size-limit.json against the merge-base. + fetch-depth: 0 - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 @@ -34,6 +38,7 @@ jobs: filters: | code: - 'apps/ui/**' + - 'packages/**' - '.github/workflows/apps-ui-validate.yml' - name: Set up Bun @@ -62,6 +67,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: bun run check + - name: Shared packages validate + if: steps.filter.outputs.code == 'true' + run: bash -c 'source ../../scripts/stack-lib.sh && validate_packages' + - name: Unit + integration tests if: steps.filter.outputs.code == 'true' run: bun run test:ci @@ -86,6 +95,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: bun run size:check + - name: Bundle budget reasons + if: steps.filter.outputs.code == 'true' + run: bun run check:size-budget + - name: Modulepreload size coverage if: steps.filter.outputs.code == 'true' run: bun run size:check:modulepreload diff --git a/apps/docs/src/data/scripts-catalog.json b/apps/docs/src/data/scripts-catalog.json index d69095b4..6a12b76b 100644 --- a/apps/docs/src/data/scripts-catalog.json +++ b/apps/docs/src/data/scripts-catalog.json @@ -37,6 +37,12 @@ "description": "After `bun run build`, every `` in dist/index.html must match at least one glob in `.size-limit.json`.", "invocation": "tsx scripts/quality/check-modulepreload-size-coverage.ts" }, + { + "command": "check:size-budget", + "script": "quality/check-size-budget-raises.ts", + "description": "Bundle budget gate.", + "invocation": "tsx scripts/quality/check-size-budget-raises.ts" + }, { "command": "lint:meta", "script": "lint-meta/cli.ts", diff --git a/apps/ui/budgets.md b/apps/ui/budgets.md new file mode 100644 index 00000000..1d75f872 --- /dev/null +++ b/apps/ui/budgets.md @@ -0,0 +1,14 @@ +# Bundle budget reasons + +Limits in `.size-limit.json` do not go up silently. `bun run check:size-budget` +(part of `bun run validate`, CI and pre-push) fails when a limit is raised, or a +new entry is added, unless this file has a reason for that entry that is new or +changed in the same change. Compare against the base branch, so an old reason +cannot cover a later raise. + +Each reason is one line, written as the entry name in backticks, a colon, then +the reason in a sentence. Reasons are at least ten characters long. Lowering a +limit, or leaving it alone, needs no line here. + +Budgets are reviewed per feature. A raise states what grew and why the growth +is acceptable, and the line is kept as the record of that decision. diff --git a/apps/ui/package.json b/apps/ui/package.json index bc661422..e4cb856a 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -15,10 +15,11 @@ "dev:docker": "bun --no-install ./node_modules/.bin/vite", "build": "vite build && (cp _headers dist/ 2>/dev/null || true)", "preview": "vite preview", - "validate": "bun run check && bun run test:ci && bun run build && bun run size:check && bun run size:check:modulepreload", + "validate": "bun run check && bun run test:ci && bun run build && bun run size:check && bun run size:check:modulepreload && bun run check:size-budget", "validate:full": "bun run validate && bun run e2e:ci", "size:check": "size-limit", "size:check:modulepreload": "tsx scripts/quality/check-modulepreload-size-coverage.ts", + "check:size-budget": "tsx scripts/quality/check-size-budget-raises.ts", "size:why": "size-limit --why", "analyze": "ANALYZE=true bun run build && open dist/stats.html", "lighthouse": "bun run build && lhci autorun", diff --git a/apps/ui/scripts/README.md b/apps/ui/scripts/README.md index 9fec1cdd..d79a481a 100644 --- a/apps/ui/scripts/README.md +++ b/apps/ui/scripts/README.md @@ -25,6 +25,7 @@ When you add a `package.json` script that runs a file under `scripts/`, update t | `bun run generate:lint-meta-docs` | `lint-meta/generate-rules-md.ts` | | `bun run test:ci` | `quality/run-tests-clean.ts` | | `bun run size:check:modulepreload` | `quality/check-modulepreload-size-coverage.ts` | +| `bun run check:size-budget` | `quality/check-size-budget-raises.ts` | | `bun run generate:api` | `codegen/generate-api.ts` | | `bun run generate:api:check` | `codegen/generate-api.ts --check` | | `bun run new:component` | `codegen/new-component.ts` | diff --git a/apps/ui/scripts/ci/pre-push.manifest.json b/apps/ui/scripts/ci/pre-push.manifest.json index 0687595c..3cd1b424 100644 --- a/apps/ui/scripts/ci/pre-push.manifest.json +++ b/apps/ui/scripts/ci/pre-push.manifest.json @@ -5,6 +5,7 @@ "bun run test:ci", "bun run build", "bun run size:check", - "bun run size:check:modulepreload" + "bun run size:check:modulepreload", + "bun run check:size-budget" ] } diff --git a/apps/ui/scripts/ci/pre-push.sh b/apps/ui/scripts/ci/pre-push.sh index 51adbfc9..b5cece45 100755 --- a/apps/ui/scripts/ci/pre-push.sh +++ b/apps/ui/scripts/ci/pre-push.sh @@ -7,7 +7,7 @@ # 2. Dependency : osv-scanner against bun.lock # 3. Tests : vitest run --coverage (no services needed) # 4. Build : vite build (catches type-level regressions through routes) -# 5. Size : size-limit budget +# 5. Size : size-limit budget and budget raise reasons # 6. OpenAPI drift : if apps/api is reachable on :7330, ensure schema is fresh # # Set ALLOW_OPENAPI_DRIFT_SKIP=false to fail instead of skip when the API is @@ -66,6 +66,7 @@ RAN=$((RAN + 1)) step "5/${TOTAL} Bundle size budgets" bun run size:check bun run size:check:modulepreload +bun run check:size-budget ok "size budgets met" RAN=$((RAN + 1)) diff --git a/apps/ui/scripts/quality/check-size-budget-raises.ts b/apps/ui/scripts/quality/check-size-budget-raises.ts new file mode 100644 index 00000000..9ac7c8ac --- /dev/null +++ b/apps/ui/scripts/quality/check-size-budget-raises.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env tsx +/** + * Bundle budget gate. A limit in .size-limit.json may only go up with a reason. + * + * An entry is "raised" when its limit grows against the base, and "new" when the + * base does not have it. Each one needs a reason line in budgets.md that is new + * or changed relative to the base, so an old reason cannot cover a later raise. + * Decreases and unchanged entries need nothing. + * + * Base: `--base `, else the merge-base of origin/main and HEAD. When that + * merge-base is HEAD itself (a push to main), HEAD~1 is used instead. + * Head: the working tree, so the same check runs locally and in CI. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SIZE_LIMIT_FILE = ".size-limit.json"; +export const BUDGET_LEDGER_FILE = "budgets.md"; +export const MIN_REASON_LENGTH = 10; + +const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +const UNIT_BYTES = new Map([ + ["b", 1], + ["kb", 1024], + ["mb", 1024 * 1024] +]); +const LIMIT_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB)\s*$/iu; +const LEDGER_LINE_PATTERN = /^- `([^`]+)`:\s*(.+)$/u; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseLimitBytes(limit: string): number { + const match = LIMIT_PATTERN.exec(limit); + const value = match?.[1]; + const unit = match?.[2]; + const multiplier = + unit === undefined ? undefined : UNIT_BYTES.get(unit.toLowerCase()); + + if (value === undefined || multiplier === undefined) { + throw new Error(`unsupported size-limit limit "${limit}"`); + } + + return Math.round(Number(value) * multiplier); +} + +/** Entry name to limit in bytes. Entries without a string limit are not budgets. */ +export function parseSizeLimit(json: string): Map { + const parsed: unknown = JSON.parse(json); + + if (!Array.isArray(parsed)) { + throw new Error(`${SIZE_LIMIT_FILE} must be a JSON array`); + } + + const limits = new Map(); + + for (const entry of parsed) { + if ( + isRecord(entry) && + typeof entry.name === "string" && + typeof entry.limit === "string" + ) { + limits.set(entry.name, parseLimitBytes(entry.limit)); + } + } + + return limits; +} + +/** Entry name to reason, from lines shaped as "- ``: ". */ +export function parseLedger(markdown: string): Map { + const reasons = new Map(); + + for (const line of markdown.split("\n")) { + const match = LEDGER_LINE_PATTERN.exec(line.trim()); + const name = match?.[1]; + const reason = match?.[2]?.trim(); + + if (name !== undefined && reason !== undefined) { + reasons.set(name, reason); + } + } + + return reasons; +} + +function formatKb(bytes: number): string { + return `${(bytes / 1024).toFixed(2)} KB`; +} + +export interface IBudgetSnapshot { + readonly limits: ReadonlyMap; + readonly reasons: ReadonlyMap; +} + +export function findBudgetRaiseViolations( + base: IBudgetSnapshot, + head: IBudgetSnapshot +): string[] { + const violations: string[] = []; + + for (const [name, headBytes] of head.limits) { + const baseBytes = base.limits.get(name); + + if (baseBytes !== undefined && headBytes <= baseBytes) { + continue; + } + + const change = + baseBytes === undefined + ? `new entry at ${formatKb(headBytes)}` + : `raised from ${formatKb(baseBytes)} to ${formatKb(headBytes)}`; + const reason = head.reasons.get(name); + + if (reason === undefined || reason.length < MIN_REASON_LENGTH) { + violations.push( + `"${name}": ${change} needs a reason line in ${BUDGET_LEDGER_FILE}: - \`${name}\`: ` + ); + } else if (reason === base.reasons.get(name)) { + violations.push( + `"${name}": ${change}; its reason in ${BUDGET_LEDGER_FILE} is unchanged from the base, write one for this change` + ); + } + } + + return violations; +} + +const UI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function git(args: readonly string[]): string | null { + try { + return execFileSync("git", args, { + cwd: UI_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + } catch { + return null; + } +} + +/** Contents of a file at a git ref, or null when the file does not exist there. */ +function readAtRef(ref: string, file: string): string | null { + return git(["show", `${ref}:./${file}`]); +} + +function resolveBase(explicit: string | undefined): string { + if (explicit !== undefined) { + return explicit; + } + + const mergeBase = git(["merge-base", "origin/main", "HEAD"])?.trim(); + + if (mergeBase === undefined || mergeBase === "") { + throw new Error( + "cannot resolve origin/main; fetch it or pass --base " + ); + } + + if (mergeBase !== git(["rev-parse", "HEAD"])?.trim()) { + return mergeBase; + } + + return ( + git(["rev-parse", "--verify", "--quiet", "HEAD~1"])?.trim() ?? + EMPTY_TREE_SHA + ); +} + +function readBaseSnapshot(ref: string): IBudgetSnapshot { + const limitJson = readAtRef(ref, SIZE_LIMIT_FILE); + const ledger = readAtRef(ref, BUDGET_LEDGER_FILE); + + return { + limits: limitJson === null ? new Map() : parseSizeLimit(limitJson), + reasons: ledger === null ? new Map() : parseLedger(ledger) + }; +} + +function readHeadSnapshot(): IBudgetSnapshot { + const limitPath = resolve(UI_ROOT, SIZE_LIMIT_FILE); + const ledgerPath = resolve(UI_ROOT, BUDGET_LEDGER_FILE); + const ledger = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : ""; + + return { + limits: parseSizeLimit(readFileSync(limitPath, "utf8")), + reasons: parseLedger(ledger) + }; +} + +function main(): void { + const args = process.argv.slice(2); + const baseIndex = args.indexOf("--base"); + const explicitBase = baseIndex === -1 ? undefined : args[baseIndex + 1]; + + if (baseIndex !== -1 && explicitBase === undefined) { + throw new Error("--base needs a git ref"); + } + + const base = resolveBase(explicitBase); + const violations = findBudgetRaiseViolations( + readBaseSnapshot(base), + readHeadSnapshot() + ); + + if (violations.length > 0) { + console.error( + `[check:size-budget] ${String(violations.length)} budget raise(s) without a reason (base ${base.slice(0, 12)}):\n` + ); + + for (const violation of violations) { + console.error(` - ${violation}`); + } + + process.exit(1); + } + + console.log( + `[check:size-budget] no budget raised or added without a reason (base ${base.slice(0, 12)}).` + ); +} + +const isEntrypoint = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isEntrypoint) { + main(); +} diff --git a/apps/ui/tests/scripts/check-size-budget-raises.test.ts b/apps/ui/tests/scripts/check-size-budget-raises.test.ts new file mode 100644 index 00000000..47e11d52 --- /dev/null +++ b/apps/ui/tests/scripts/check-size-budget-raises.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { + type IBudgetSnapshot, + findBudgetRaiseViolations, + parseLedger, + parseLimitBytes, + parseSizeLimit +} from "../../scripts/quality/check-size-budget-raises"; + +const REASON = "Tailwind grew with the new settings screens."; + +function snapshot( + limits: Record, + reasons: Record = {} +): IBudgetSnapshot { + return { + limits: new Map( + Object.entries(limits).map(([name, limit]) => [ + name, + parseLimitBytes(limit) + ]) + ), + reasons: new Map(Object.entries(reasons)) + }; +} + +describe("parseLimitBytes", () => { + it("reads KB, MB and B limits", () => { + expect(parseLimitBytes("12 KB")).toBe(12 * 1024); + expect(parseLimitBytes("1 MB")).toBe(1024 * 1024); + expect(parseLimitBytes("512 B")).toBe(512); + }); + + it("rejects limits it cannot read", () => { + expect(() => parseLimitBytes("twelve")).toThrow(/unsupported/u); + }); +}); + +describe("parseSizeLimit", () => { + it("keeps entry names with parentheses and colons, skips entries without a limit", () => { + const limits = parseSizeLimit( + JSON.stringify([ + { name: "Initial route (cold start: index + react)", limit: "255 KB" }, + { name: "no limit here", path: "dist/x.js" } + ]) + ); + + expect([...limits.keys()]).toEqual([ + "Initial route (cold start: index + react)" + ]); + }); +}); + +describe("parseLedger", () => { + it("reads only reason lines, ignoring headings and prose", () => { + const reasons = parseLedger( + [ + "# Budget reasons", + "", + "Prose that mentions `budgets` and a colon: here.", + "", + "- `Initial route (cold start: index + react)`: Settings shell moved into the cold route.", + "- `CSS (Tailwind compiled)`: New tokens for the billing screens." + ].join("\n") + ); + + expect(reasons.get("Initial route (cold start: index + react)")).toBe( + "Settings shell moved into the cold route." + ); + expect(reasons.get("CSS (Tailwind compiled)")).toBe( + "New tokens for the billing screens." + ); + expect(reasons.size).toBe(2); + }); +}); + +describe("findBudgetRaiseViolations", () => { + it("fails a raised limit with no reason", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot({ CSS: "26.5 KB" }) + ); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('"CSS"'); + expect(violations[0]).toContain("needs a reason line"); + }); + + it("passes a raised limit whose reason is new in the change", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot({ CSS: "26.5 KB" }, { CSS: REASON }) + ); + + expect(violations).toEqual([]); + }); + + it("fails a raise whose reason was already in the base", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }, { CSS: REASON }), + snapshot({ CSS: "26.5 KB" }, { CSS: REASON }) + ); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("unchanged from the base"); + }); + + it("passes a decrease with no reason", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "26.5 KB" }), + snapshot({ CSS: "12 KB" }) + ); + + expect(violations).toEqual([]); + }); + + it("passes an unchanged limit with no reason", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot({ CSS: "12 KB" }) + ); + + expect(violations).toEqual([]); + }); + + it("fails a new entry with no reason", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot({ CSS: "12 KB", "Billing chunk": "4 KB" }) + ); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('"Billing chunk"'); + expect(violations[0]).toContain("new entry"); + }); + + it("passes a new entry that carries a reason", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot( + { CSS: "12 KB", "Billing chunk": "4 KB" }, + { "Billing chunk": REASON } + ) + ); + + expect(violations).toEqual([]); + }); + + it("treats a reason shorter than the minimum as missing", () => { + const violations = findBudgetRaiseViolations( + snapshot({ CSS: "12 KB" }), + snapshot({ CSS: "26.5 KB" }, { CSS: "tbd" }) + ); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("needs a reason line"); + }); +}); From 6455f6ad3bf069f2620942ce40241c6ea53c9de5 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:44:11 +0200 Subject: [PATCH 04/25] feat(agent): record test-inventory additions during verification In Tinkercaster 20 of the first 62 commits only accepted test inventories, and after day three the step was skipped and the protection lapsed. Feature verification now writes additions-only changes to the committed inventory after the checkout-stability check, so the diff rides along with the tests. Removals, renames and substitutions still block and print one acknowledgement command with the removed identities; zero observed cases block with test_inventory_empty and cannot be acknowledged. The workflow and README record that CI does not yet compare observed cases. --- .github/workflows/agent-verification.yml | 4 + apps/docs/public/account-resource.json | 2 +- .../docs/recipes/add-account-resource.mdx | 2 +- .../docs/reference/product-validation.mdx | 8 +- .../content/docs/skills/verified-workflow.mdx | 10 +- tools/agent/README.md | 74 +++++-- tools/agent/cli.ts | 15 +- tools/agent/inventory-cli.ts | 3 +- tools/agent/inventory.test.ts | 207 ++++++++++++++++++ tools/agent/inventory.ts | 181 +++++++++++++-- tools/agent/profiles.ts | 7 + tools/agent/result.ts | 2 + tools/agent/tasks/account-resource.json | 2 +- 13 files changed, 462 insertions(+), 55 deletions(-) create mode 100644 tools/agent/inventory.test.ts diff --git a/.github/workflows/agent-verification.yml b/.github/workflows/agent-verification.yml index cf0c77bb..7eaae454 100644 --- a/.github/workflows/agent-verification.yml +++ b/.github/workflows/agent-verification.yml @@ -1,3 +1,7 @@ +# Known gap: this workflow does not run the API, UI or browser lanes, so it never +# compares observed cases with tools/agent/inventories/*.json. agent:check only +# validates that the committed baselines parse and are non-empty. See +# tools/agent/README.md "CI enforcement". name: agent-verification on: pull_request: diff --git a/apps/docs/public/account-resource.json b/apps/docs/public/account-resource.json index ff023ea0..96c8c98d 100644 --- a/apps/docs/public/account-resource.json +++ b/apps/docs/public/account-resource.json @@ -64,7 +64,7 @@ "Use the existing UI component/feature conventions. Scope every query key by accountId; capture accountId in mutations and invalidate that exact account's queries.", "Render loading, empty, error and permission-denied states. Translate visible labels into both locales. Do not put service authorization exclusively in the UI.", "Verify owner/admin mutations, member/viewer read access, revoked membership, cross-account reads and writes, forged ownership, and account-switch cache isolation.", - "Run feature verification. If intentional test additions change the inventory, preview agent:inventory for the affected lanes, review its case diff, apply --accept= (plus --allow-removals= for intentional removals), and rerun verification. Run release-local before claiming full local readiness. Report blockers and GitHub-only checks separately." + "Run feature verification. It records intentional test additions in tools/agent/inventories automatically; include that inventory diff in the change. If it reports removed cases, run the single agent:inventory --accept= --allow-removals= command it prints after reviewing the listed removals, then rerun verification. Run release-local before claiming full local readiness. Report blockers and GitHub-only checks separately." ], "verificationProfile": "feature", "limitations": [ diff --git a/apps/docs/src/content/docs/recipes/add-account-resource.mdx b/apps/docs/src/content/docs/recipes/add-account-resource.mdx index 0a7e69f6..46f32d3d 100644 --- a/apps/docs/src/content/docs/recipes/add-account-resource.mdx +++ b/apps/docs/src/content/docs/recipes/add-account-resource.mdx @@ -43,7 +43,7 @@ The shared account-switch mutation cancels outstanding queries, resets account d ## Verify -Run the `feature` profile, review any intentional test-inventory additions, then rerun it. Run `release-local` for the broader local gate. Cover owner/admin writes, member/viewer reads, revoked membership, cross-account reads and writes, forged ownership and account-switch cache isolation. +Run the `feature` profile. Commit any test-inventory additions it records with the change, acknowledge any reported removals, then rerun it. Run `release-local` for the broader local gate. Cover owner/admin writes, member/viewer reads, revoked membership, cross-account reads and writes, forged ownership and account-switch cache isolation. The generated list is bounded to 100 records. Design a pagination contract before relying on larger lists. Audit writes follow the existing best-effort convention; this generator does not add a durable outbox or deletion behavior. diff --git a/apps/docs/src/content/docs/reference/product-validation.mdx b/apps/docs/src/content/docs/reference/product-validation.mdx index acfe9f17..fb262b43 100644 --- a/apps/docs/src/content/docs/reference/product-validation.mdx +++ b/apps/docs/src/content/docs/reference/product-validation.mdx @@ -64,10 +64,10 @@ partially committed test data. Recreate the disposable sandbox if its state is uncertain. `sandbox_not_found` means the descriptor is absent; use `sandbox:up` to create an owned runtime before retrying. -Inventory previews include an `acceptCommand` tied to the reviewed diff. Removed -cases require an additional acknowledgement, included in that command. Review -case identities before accepting; shrinking an inventory cannot repair tests -that failed to execute. +Additions to a test inventory are recorded by the feature run itself. Removed +cases block the lane and the failure output prints the acknowledgement command +with its removal token. Review the listed identities before running it; shrinking +an inventory cannot repair tests that failed to execute. ## Use the same Compose project diff --git a/apps/docs/src/content/docs/skills/verified-workflow.mdx b/apps/docs/src/content/docs/skills/verified-workflow.mdx index ddc73b95..23c08959 100644 --- a/apps/docs/src/content/docs/skills/verified-workflow.mdx +++ b/apps/docs/src/content/docs/skills/verified-workflow.mdx @@ -67,15 +67,15 @@ This applies committed migrations to the owned sandbox, builds test templates an The ordinary API, UI and browser suites compare observed test identities, including multiplicity, with `tools/agent/inventories/*.json`. This rejects missing or substituted tests even when the reported total looks plausible. -A complete passing underlying suite records an observation even when new tests cause an inventory mismatch. Preview only the changed lanes: +New tests need no separate step. When a feature verification run observes the committed inventory plus new cases and nothing removed, it rewrites that lane's `tools/agent/inventories/*.json` and reports `test_inventory_additions_recorded`. Commit the updated inventory with the feature change and review it in the diff against the actual tests. + +Removed, renamed or substituted cases block the lane. The failure output lists each removed identity and prints one acknowledgement command: ```sh -bun run agent:inventory -- api.tests ui.tests ui.e2e +bun run agent:inventory -- api.tests --accept= --allow-removals= ``` -The preview prints exact additions/removals and a review token without writing. Apply the reviewed diff with `--accept=`. If it removes any case, also pass `--allow-removals=`; this includes duplicate removal and same-count substitutions. Both approvals bind that exact checkout and diff, so changed observations or source invalidate them. - -Name fewer lanes when appropriate. Approve multiple changed lanes together, review the committed diff against the actual tests, then rerun verification. Do not approve an unexpected shrink to obtain a green result. The security suite has its own [per-case manifest](/topics/security-spec/). +Run it only after reviewing the listed removals, then rerun verification. The token binds that exact checkout and diff, so changed observations or source invalidate it. Zero observed cases always block. Do not acknowledge an unexpected shrink to obtain a green result. The security suite has its own [per-case manifest](/topics/security-spec/). ## Tooling maintenance and boundaries diff --git a/tools/agent/README.md b/tools/agent/README.md index 83140c09..2a551b26 100644 --- a/tools/agent/README.md +++ b/tools/agent/README.md @@ -111,30 +111,56 @@ The desired repository settings list `agent verification contract` as required. ## Updating the expected test inventory Ordinary API, UI and browser lanes compare every observed case identity, including -multiplicity, against `tools/agent/inventories/*.json`. Missing, substituted and -unexpected cases block verification. This deliberately requires review when tests -are added or removed, including generated feature tests. - -Run the feature profile first. A complete passing underlying suite writes a private -observation even if its inventory disagrees. Then explicitly run: - -```sh -bun run agent:inventory -- api.tests ui.tests ui.e2e -``` - -This first command is a preview: it prints exact added/removed identities and a -review token without updating files. Approve the same diff explicitly: - -```sh -bun run agent:inventory -- api.tests ui.tests ui.e2e --accept= -``` - -If any cases are removed (including one occurrence of a duplicate or a same-count -replacement), also supply `--allow-removals=`. This acknowledges the exact -removals already displayed; it is not a blanket permission for future reductions. -The token binds the checkout, old baseline and fresh observation. Any change -invalidates approval. Choose only changed lanes, approve them together, review the -committed diff, then rerun verification. Approval never declares the update verified. +multiplicity, against `tools/agent/inventories/*.json`. The protocol: + +- **Additions are recorded automatically.** When a lane's observation is the baseline + plus new cases and nothing is removed, the feature verification run rewrites that + lane's baseline after the checkout is confirmed unchanged. The lane reports + `test_inventory_additions_recorded`. Commit the updated `tools/agent/inventories/*.json` + with the feature change; no separate accept step is needed. +- **Removals block and are never recorded.** A removed, renamed or same-count + substituted case (including one fewer occurrence of a duplicate) fails the lane with + `test_inventory_disagrees`. The failure output lists each removed identity and prints + one command. Run it, then rerun verification: + + ```sh + bun run agent:inventory -- api.tests --accept= --allow-removals= + ``` + + The token binds the checkout, the old baseline and the fresh observation, so any + source or test change invalidates it. Review the printed identities before running it. + Acknowledgement only updates the baseline; it does not declare the run verified. + +- **Zero observed cases block** with `test_inventory_empty`; they are never recorded + and cannot be acknowledged. Fix the lane so it runs its tests. +- **Preview** is still available and writes nothing: + `bun run agent:inventory -- api.tests ui.tests ui.e2e`. + +A run writes baselines only when no lane has removals, and only from observations +bound to the run's own fingerprint. The run's reported checkout fingerprint describes +the tree before that write, so review the inventory diff as part of the feature diff. + +Limitation: CI does not compare observed cases with the committed inventories. See +"CI enforcement" below. + +### CI enforcement + +CI does not enforce that the committed inventories match what a run observed. +Verified facts: + +- `.github/workflows/agent-verification.yml` runs `agent:check`, `agent:check:docker` + and `agent:eval --deterministic`. None of these runs the API, UI or browser lanes, so + no JUnit report and no observation is produced there. +- `apps-api-ci.yml` runs `bun run test` against Postgres, and the UI workflows run their + own test scripts. None writes JUnit or reads `tools/agent/inventories`. +- Running the `feature` profile in CI would need the owned Docker sandbox, migrations, + Chromium and every suite. That is not cheap, so it was not added. + +What CI does enforce: `agent:check` asserts that each committed baseline parses and is +non-empty. That catches an emptied or deleted baseline. It does not catch a removed test +whose baseline was left stale, or a feature change merged without running feature +verification. Closing that gap needs a CI job that runs the feature profile, or a JUnit +comparison in the API workflow. Owned browser invocations disable retries. A retry attachment is blocked if fed back as evidence. Coverage/forbidden-warning gate failures are reported as failures diff --git a/tools/agent/cli.ts b/tools/agent/cli.ts index b3428c86..ed1bebfe 100644 --- a/tools/agent/cli.ts +++ b/tools/agent/cli.ts @@ -46,13 +46,18 @@ if ( `${result.profile}: ${result.status}`, ...result.checks.map((check) => [ - `${check.checkId}: ${check.status} (${check.reason})`, - check.durationMs === undefined - ? "" - : `${(check.durationMs / 1000).toFixed(1)}s`, + [ + `${check.checkId}: ${check.status} (${check.reason})`, + check.durationMs === undefined + ? "" + : `${(check.durationMs / 1000).toFixed(1)}s`, + ] + .filter(Boolean) + .join(" "), + check.remediation ?? "", ] .filter(Boolean) - .join(" ") + .join("\n") ), ].join("\n") ); diff --git a/tools/agent/inventory-cli.ts b/tools/agent/inventory-cli.ts index b3dcc1eb..000a6671 100644 --- a/tools/agent/inventory-cli.ts +++ b/tools/agent/inventory-cli.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { acceptInventories, + acknowledgeCommand, isInventoryLane, reviewInventories, } from "./inventory"; @@ -40,7 +41,7 @@ try { { fingerprint: review.fingerprint, token: review.token, - acceptCommand: `bun run agent:inventory -- ${lanes.join(" ")} --accept=${review.token}${review.changes.some((change) => change.removed.length > 0) ? ` --allow-removals=${review.token}` : ""}`, + acceptCommand: acknowledgeCommand(lanes, review), changes: review.changes.map((change) => ({ lane: change.lane, beforeCount: change.before.length, diff --git a/tools/agent/inventory.test.ts b/tools/agent/inventory.test.ts new file mode 100644 index 00000000..ac5f3bab --- /dev/null +++ b/tools/agent/inventory.test.ts @@ -0,0 +1,207 @@ +import { afterEach, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { identifyCheckout } from "./checkout"; +import { + INVENTORY_REASONS, + inventoryEvidence, + readInventory, + reviewInventories, + settleInventories, +} from "./inventory"; +import type { ICheckResult } from "./result"; + +const dirs: string[] = []; +const passed: ICheckResult = { + checkId: "api.tests", + status: "passed", + reason: "tests_passed", +}; + +function fixture(cases: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "bs-inventory-evidence-")); + + dirs.push(dir); + execFileSync("git", ["init", "-q", dir]); + writeFileSync(join(dir, ".gitignore"), ".agent-state/\n"); + writeFileSync(join(dir, "README.md"), "fixture\n"); + execFileSync("git", ["-C", dir, "add", "."]); + execFileSync("git", [ + "-C", + dir, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ]); + mkdirSync(join(dir, "tools/agent/inventories"), { recursive: true }); + writeFileSync( + join(dir, "tools/agent/inventories/api.tests.json"), + `${JSON.stringify({ schemaVersion: 1, cases }, null, 2)}\n` + ); + + return dir; +} + +function xmlFor(names: string[]): string { + const cases = names + .map((name) => ``) + .join(""); + + return `${cases}`; +} + +function baseline(dir: string): string[] { + return readInventory(join(dir, "tools/agent/inventories/api.tests.json")) + .cases; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const control = JSON.stringify(["a.ts", "control"]); +const added = JSON.stringify(["a.ts", "new case"]); + +test("additions pass and are written to the baseline by the same run", () => { + const dir = fixture([control]); + const fingerprint = identifyCheckout(dir).fingerprint; + const evidence = inventoryEvidence( + dir, + "api.tests", + xmlFor(["control", "new case"]), + passed, + fingerprint + ); + + expect(evidence.status).toBe("passed"); + expect(evidence.reason).toBe(INVENTORY_REASONS.additions); + + const settled = settleInventories(dir, [evidence], fingerprint); + + expect(settled[0]?.status).toBe("passed"); + expect(baseline(dir)).toEqual([added, control].sort()); + + const rerunFingerprint = identifyCheckout(dir).fingerprint; + const rerun = inventoryEvidence( + dir, + "api.tests", + xmlFor(["control", "new case"]), + passed, + rerunFingerprint + ); + + expect(rerun).toEqual(passed); +}); + +test("removals stay blocked, are never written, and print one acknowledgement command", () => { + const dir = fixture([control, added]); + const fingerprint = identifyCheckout(dir).fingerprint; + const before = readFileSync( + join(dir, "tools/agent/inventories/api.tests.json"), + "utf8" + ); + const evidence = inventoryEvidence( + dir, + "api.tests", + xmlFor(["control"]), + passed, + fingerprint + ); + + expect(evidence.status).toBe("blocked"); + expect(evidence.reason).toBe(INVENTORY_REASONS.disagrees); + + const [settled] = settleInventories(dir, [evidence], fingerprint); + const token = reviewInventories(dir, ["api.tests"]).token; + const command = `bun run agent:inventory -- api.tests --accept=${token} --allow-removals=${token}`; + + expect(settled?.status).toBe("blocked"); + expect(settled?.remediation).toContain(command); + expect(settled?.remediation).toContain("new case"); + expect( + readFileSync(join(dir, "tools/agent/inventories/api.tests.json"), "utf8") + ).toBe(before); +}); + +test("zero observed cases stay blocked and are never recorded", () => { + const dir = fixture([control]); + const fingerprint = identifyCheckout(dir).fingerprint; + const evidence = inventoryEvidence( + dir, + "api.tests", + "", + passed, + fingerprint + ); + + expect(evidence.status).toBe("blocked"); + expect(evidence.reason).toBe(INVENTORY_REASONS.empty); + expect(settleInventories(dir, [evidence], fingerprint)[0]?.remediation).toBe( + undefined + ); + expect(baseline(dir)).toEqual([control]); +}); + +test("additions in one lane are not written while another lane has removals", () => { + const dir = fixture([control]); + const fingerprint = identifyCheckout(dir).fingerprint; + const additions = inventoryEvidence( + dir, + "api.tests", + xmlFor(["control", "new case"]), + passed, + fingerprint + ); + const uiRemoval: ICheckResult = { + checkId: "ui.tests", + status: "blocked", + reason: INVENTORY_REASONS.disagrees, + }; + const settled = settleInventories(dir, [additions, uiRemoval], fingerprint); + + expect(settled.find((check) => check.checkId === "api.tests")?.status).toBe( + "passed" + ); + expect(baseline(dir)).toEqual([control]); +}); + +test("additions are not recorded when the observation is not bound to this checkout", () => { + const dir = fixture([control]); + const evidence = inventoryEvidence( + dir, + "api.tests", + xmlFor(["control", "new case"]), + passed, + "not-this-checkout" + ); + + expect(evidence.status).toBe("blocked"); + expect(evidence.reason).toBe(INVENTORY_REASONS.disagrees); + expect(baseline(dir)).toEqual([control]); +}); + +test("committed baselines are valid, non-empty inventories", () => { + for (const lane of ["api.tests", "ui.tests", "ui.e2e"]) { + const inventory = readInventory( + join(import.meta.dir, "inventories", `${lane}.json`) + ); + + expect(inventory.cases.length).toBeGreaterThan(0); + } +}); diff --git a/tools/agent/inventory.ts b/tools/agent/inventory.ts index fd8d8a15..443cb512 100644 --- a/tools/agent/inventory.ts +++ b/tools/agent/inventory.ts @@ -11,6 +11,7 @@ import { identifyCheckout } from "./checkout"; import { inventoryChange, inventoryReview, + type IInventoryChange, type IInventoryReview, } from "./inventory-review"; import type { IInventory, InventoryLane } from "./inventory.types"; @@ -20,6 +21,14 @@ import { isRecord, isStringArray, parseRecord } from "./validation"; export const LANES = ["api.tests", "ui.tests", "ui.e2e"] as const; export type Lane = InventoryLane; +/** Reasons a lane check carries; the last two are settled after every lane has finished. */ +export const INVENTORY_REASONS = { + empty: "test_inventory_empty", + disagrees: "test_inventory_disagrees", + additions: "test_inventory_additions_recorded", + updateFailed: "test_inventory_update_failed", +} as const; + export function isInventoryLane(value: string): value is InventoryLane { const names: readonly string[] = LANES; @@ -72,7 +81,7 @@ export function compareInventory( ); } -function readInventory(path: string): IInventory { +export function readInventory(path: string): IInventory { const value = parseRecord(readFileSync(path, "utf8")); if ( @@ -92,16 +101,21 @@ function assertSafePath(path: string): void { } } +function baselinePath(root: string, lane: InventoryLane): string { + return join(root, "tools/agent/inventories", `${lane}.json`); +} + +/** Returns false when the observation is not bound to the checkout that produced it. */ function recordObservation( root: string, lane: InventoryLane, cases: string[], expectedFingerprint?: string -): void { +): boolean { const checkout = identifyCheckout(root); if (expectedFingerprint !== checkout.fingerprint) { - return; + return false; } const directory = join(root, ".agent-state/observed-tests"); @@ -113,8 +127,15 @@ function recordObservation( writeFileSync(path, JSON.stringify({ schemaVersion: 1, checkout, cases }), { mode: 0o600, }); + + return true; } +/** + * Classifies one lane's observed cases against its committed baseline. + * Additions alone pass and are written by settleInventories once the run is stable. + * Removals and empty observations block; removals are never recorded automatically. + */ export function inventoryEvidence( root: string, lane: InventoryLane, @@ -123,27 +144,42 @@ export function inventoryEvidence( expectedFingerprint?: string ): ICheckResult { const actual = identities(xml, root); - - if (evidence.status === "passed") { + const recorded = + evidence.status === "passed" && recordObservation(root, lane, actual, expectedFingerprint); - } if (evidence.status === "blocked") { return evidence; } + if (actual.length === 0) { + return { ...evidence, status: "blocked", reason: INVENTORY_REASONS.empty }; + } + try { - const path = join(root, "tools/agent/inventories", `${lane}.json`); - const expected = readInventory(path); + const expected = readInventory(baselinePath(root, lane)); if (compareInventory(expected.cases, actual)) { return evidence; } + + const change = inventoryChange(lane, expected.cases, actual); + + if (recorded && change.removed.length === 0) { + return { + ...evidence, + reason: INVENTORY_REASONS.additions, + }; + } } catch { // Missing or malformed expectations cannot establish complete execution. } - return { ...evidence, status: "blocked", reason: "test_inventory_disagrees" }; + return { + ...evidence, + status: "blocked", + reason: INVENTORY_REASONS.disagrees, + }; } function readFreshObservation( @@ -215,20 +251,139 @@ export function acceptInventories( throw new Error("Test removals require --allow-removals="); } + writeBaselines(root, review.changes); +} + +/** The exact command an operator runs to acknowledge a reviewed diff. */ +export function acknowledgeCommand( + lanes: readonly InventoryLane[], + review: IInventoryReview +): string { + const removals = review.changes.some((change) => change.removed.length > 0); + + return `bun run agent:inventory -- ${lanes.join(" ")} --accept=${review.token}${removals ? ` --allow-removals=${review.token}` : ""}`; +} + +function writeBaselines( + root: string, + changes: readonly IInventoryChange[] +): void { const directory = join(root, "tools/agent/inventories"); assertSafePath(directory); - for (const lane of lanes) { - assertSafePath(join(directory, `${lane}.json`)); + for (const change of changes) { + assertSafePath(baselinePath(root, change.lane)); } mkdirSync(directory, { recursive: true }); - for (const change of review.changes) { + for (const change of changes) { writeFileSync( - join(directory, `${change.lane}.json`), + baselinePath(root, change.lane), `${JSON.stringify({ schemaVersion: 1, cases: change.after }, null, 2)}\n` ); } } + +/** Writes additions-only baselines from observations bound to the run's fingerprint. */ +function recordAdditions( + root: string, + lanes: readonly InventoryLane[], + fingerprint: string +): void { + const changes = lanes.map((lane) => { + const observation = readFreshObservation(root, lane, fingerprint); + const change = inventoryChange( + lane, + readInventory(baselinePath(root, lane)).cases, + observation.cases + ); + + if (change.removed.length > 0 || change.added.length === 0) { + throw new Error("Only additions can be recorded automatically"); + } + + return change; + }); + + writeBaselines(root, changes); +} + +/** Lists removed cases and the single acknowledgement command for blocked lanes. */ +function explainRemovals( + root: string, + checks: readonly ICheckResult[], + lanes: readonly InventoryLane[] +): ICheckResult[] { + let review: IInventoryReview; + + try { + review = reviewInventories(root, lanes); + } catch { + return [...checks]; + } + + const removed = review.changes.flatMap((change) => + change.removed.map((identity) => `${change.lane} removed: ${identity}`) + ); + const remediation = [ + ...removed, + `Acknowledge the reviewed diff with: ${acknowledgeCommand(lanes, review)}`, + "Then rerun verification.", + ].join("\n"); + + return checks.map((check): ICheckResult => + isInventoryLane(check.checkId) && + lanes.includes(check.checkId) && + check.reason === INVENTORY_REASONS.disagrees + ? { ...check, remediation } + : check + ); +} + +/** + * Runs after every lane has finished and the checkout is confirmed unchanged. + * Any removal blocks recording and prints one acknowledgement command; otherwise + * additions-only lanes are written to their baselines so the change rides along + * in the same commit as the tests. + */ +export function settleInventories( + root: string, + checks: readonly ICheckResult[], + fingerprint: string +): ICheckResult[] { + const laneIds = (reason: string): InventoryLane[] => + checks.flatMap((check) => + isInventoryLane(check.checkId) && check.reason === reason + ? [check.checkId] + : [] + ); + const disagreeing = laneIds(INVENTORY_REASONS.disagrees); + + if (disagreeing.length > 0) { + return explainRemovals(root, checks, disagreeing); + } + + const additions = laneIds(INVENTORY_REASONS.additions); + + if (additions.length === 0) { + return [...checks]; + } + + try { + recordAdditions(root, additions, fingerprint); + + return [...checks]; + } catch { + return checks.map((check): ICheckResult => + isInventoryLane(check.checkId) && additions.includes(check.checkId) + ? { + ...check, + status: "blocked", + reason: INVENTORY_REASONS.updateFailed, + } + : check + ); + } +} diff --git a/tools/agent/profiles.ts b/tools/agent/profiles.ts index e7e826a6..652ab956 100644 --- a/tools/agent/profiles.ts +++ b/tools/agent/profiles.ts @@ -5,6 +5,7 @@ import { now } from "../../apps/api/src/lib/time/now"; import { identifyCheckout } from "./checkout"; import { RELEASE_CHECKS, STATIC_CHECKS, type Profile } from "./checks"; import { openApiUrl } from "./environment"; +import { settleInventories } from "./inventory"; import { orderChecks } from "./lanes"; import { executionBudget } from "./scheduling"; import { runTasks } from "./scheduler"; @@ -132,6 +133,12 @@ export async function runProfile( status: "passed", reason: "checkout_unchanged", }); + // Writes happen only after the stability check, so the run's fingerprint still describes what was tested. + result.checks = settleInventories( + root, + result.checks, + result.checkout.fingerprint + ); } if (isAborted(signal)) { diff --git a/tools/agent/result.ts b/tools/agent/result.ts index 714282fb..66e03978 100644 --- a/tools/agent/result.ts +++ b/tools/agent/result.ts @@ -12,6 +12,8 @@ export interface ICheckResult { cases?: { passed: number; failed: number; skipped: number }; status: Status; reason: string; + /** Operator guidance for a blocked check, such as the acknowledgement command. */ + remediation?: string; } export interface IVerificationResult { diff --git a/tools/agent/tasks/account-resource.json b/tools/agent/tasks/account-resource.json index ff023ea0..96c8c98d 100644 --- a/tools/agent/tasks/account-resource.json +++ b/tools/agent/tasks/account-resource.json @@ -64,7 +64,7 @@ "Use the existing UI component/feature conventions. Scope every query key by accountId; capture accountId in mutations and invalidate that exact account's queries.", "Render loading, empty, error and permission-denied states. Translate visible labels into both locales. Do not put service authorization exclusively in the UI.", "Verify owner/admin mutations, member/viewer read access, revoked membership, cross-account reads and writes, forged ownership, and account-switch cache isolation.", - "Run feature verification. If intentional test additions change the inventory, preview agent:inventory for the affected lanes, review its case diff, apply --accept= (plus --allow-removals= for intentional removals), and rerun verification. Run release-local before claiming full local readiness. Report blockers and GitHub-only checks separately." + "Run feature verification. It records intentional test additions in tools/agent/inventories automatically; include that inventory diff in the change. If it reports removed cases, run the single agent:inventory --accept= --allow-removals= command it prints after reviewing the listed removals, then rerun verification. Run release-local before claiming full local readiness. Report blockers and GitHub-only checks separately." ], "verificationProfile": "feature", "limitations": [ From a2c89310ffe4a92cd4b0d76b422db245964e84aa Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:47:43 +0200 Subject: [PATCH 05/25] fix(api): share the Postgres TLS policy between runtime and migrations drizzle-kit ignored DATABASE_SSL_CA, so migrations could pass against a trust store the runtime client then refused. Both paths now build ssl from one helper. drizzle-kit only accepts ssl with host-based credentials, so the migration config splits DATABASE_URL when TLS is on. --- apps/api/drizzle.config.ts | 26 +++++-- apps/api/src/clients/postgres/index.ts | 19 ++--- apps/api/src/lib/postgres/connection.ts | 67 ++++++++++++++++++ .../api/tests/lib/postgres/connection.test.ts | 70 +++++++++++++++++++ 4 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/lib/postgres/connection.ts create mode 100644 apps/api/tests/lib/postgres/connection.test.ts diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 9c92a120..bb103bc2 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,17 +1,35 @@ import * as dotenv from "dotenv"; import type { Config } from "drizzle-kit"; +import { + buildDrizzleCredentials, + buildPostgresSsl, +} from "./src/lib/postgres/connection"; +import { toBoolWithDefault } from "./src/config/env/validate"; + dotenv.config({ path: process.env.DOTENV_CONFIG_PATH ?? ".env" }); +// Same TLS policy as the runtime client, so migrations verify the database +// certificate with the same CA the API uses (see src/lib/postgres/connection.ts). +const ssl = buildPostgresSsl({ + isProduction: process.env.NODE_ENV === "production", + rejectUnauthorized: toBoolWithDefault( + process.env.DATABASE_SSL_REJECT_UNAUTHORIZED, + true, + "DATABASE_SSL_REJECT_UNAUTHORIZED" + ), + ca: process.env.DATABASE_SSL_CA ?? "", +}); + export default { schema: "./src/clients/postgres/schema/*.ts", out: "./drizzle", dialect: "postgresql", - dbCredentials: { - url: - process.env.DATABASE_URL ?? + dbCredentials: buildDrizzleCredentials( + process.env.DATABASE_URL ?? "postgresql://app:app_dev_password@localhost:5432/app", - }, + ssl + ), schemaFilter: ["auth", "billing", "audit", "app", "notifications"], verbose: true, strict: true, diff --git a/apps/api/src/clients/postgres/index.ts b/apps/api/src/clients/postgres/index.ts index 21af604e..596da1ad 100644 --- a/apps/api/src/clients/postgres/index.ts +++ b/apps/api/src/clients/postgres/index.ts @@ -2,27 +2,20 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { env } from "../../config/env"; import { logger } from "../../config/logger"; +import { buildPostgresSsl } from "../../lib/postgres/connection"; import * as schema from "./schema"; -const buildSslConfig = (): - false | { rejectUnauthorized: boolean; ca?: string } => { - if (!env.isProduction) { - return false; - } - - return { - rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED, - ...(env.DATABASE_SSL_CA !== "" && { ca: env.DATABASE_SSL_CA }), - }; -}; - const client = postgres(env.DATABASE_URL, { max: env.DATABASE_POOL_SIZE, idle_timeout: 20, connect_timeout: env.isTest ? 1 : 10, max_lifetime: 60 * 30, prepare: true, - ssl: buildSslConfig(), + ssl: buildPostgresSsl({ + isProduction: env.isProduction, + rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED, + ca: env.DATABASE_SSL_CA, + }), onnotice: (notice) => { if (!env.isDevelopment) { return; diff --git a/apps/api/src/lib/postgres/connection.ts b/apps/api/src/lib/postgres/connection.ts new file mode 100644 index 00000000..06dce465 --- /dev/null +++ b/apps/api/src/lib/postgres/connection.ts @@ -0,0 +1,67 @@ +/* + * One TLS policy for every Postgres connection the API opens: the runtime + * client (postgres-js, src/clients/postgres/index.ts) and the migration path + * (drizzle-kit, drizzle.config.ts). Both must trust the same CA, or migrations + * can pass against a trust store the app then refuses at boot. + * + * Pure on purpose: no env import, so drizzle.config.ts can use it without + * running the full API env validation. + */ + +export interface IPostgresTlsSettings { + readonly isProduction: boolean; + readonly rejectUnauthorized: boolean; + /** PEM-encoded CA bundle that signed the server certificate. Empty = system CAs. */ + readonly ca: string; +} + +export type PostgresSsl = false | { rejectUnauthorized: boolean; ca?: string }; + +export const buildPostgresSsl = ({ + isProduction, + rejectUnauthorized, + ca, +}: IPostgresTlsSettings): PostgresSsl => { + if (!isProduction) { + return false; + } + + return { rejectUnauthorized, ...(ca !== "" && { ca }) }; +}; + +export type DrizzleCredentials = + | { url: string } + | { + host: string; + port: number; + user: string; + password: string; + database: string; + ssl: Exclude; + }; + +/* + * drizzle-kit only accepts `ssl` alongside host-based credentials, not with + * `{ url }`. When TLS is on, split the URL into parts and carry the same ssl + * object the runtime client uses. Any `sslmode` query parameter is ignored + * here, so keep TLS policy in DATABASE_SSL_* rather than in the URL. + */ +export const buildDrizzleCredentials = ( + databaseUrl: string, + ssl: PostgresSsl +): DrizzleCredentials => { + if (ssl === false) { + return { url: databaseUrl }; + } + + const parsed = new URL(databaseUrl); + + return { + host: parsed.hostname, + port: parsed.port === "" ? 5432 : Number(parsed.port), + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + database: decodeURIComponent(parsed.pathname.replace(/^\//, "")), + ssl, + }; +}; diff --git a/apps/api/tests/lib/postgres/connection.test.ts b/apps/api/tests/lib/postgres/connection.test.ts new file mode 100644 index 00000000..95495331 --- /dev/null +++ b/apps/api/tests/lib/postgres/connection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { + buildDrizzleCredentials, + buildPostgresSsl, +} from "../../../src/lib/postgres/connection"; + +describe("buildPostgresSsl", () => { + it("disables TLS outside production", () => { + expect( + buildPostgresSsl({ + isProduction: false, + rejectUnauthorized: true, + ca: "pem", + }) + ).toBe(false); + }); + + it("verifies against system CAs in production when no CA is configured", () => { + expect( + buildPostgresSsl({ isProduction: true, rejectUnauthorized: true, ca: "" }) + ).toEqual({ rejectUnauthorized: true }); + }); + + it("carries the configured CA bundle in production", () => { + expect( + buildPostgresSsl({ + isProduction: true, + rejectUnauthorized: true, + ca: "-----BEGIN CERTIFICATE-----", + }) + ).toEqual({ + rejectUnauthorized: true, + ca: "-----BEGIN CERTIFICATE-----", + }); + }); +}); + +describe("buildDrizzleCredentials", () => { + it("passes the URL through unchanged when TLS is off", () => { + expect( + buildDrizzleCredentials("postgresql://app:pw@localhost:5432/app", false) + ).toEqual({ url: "postgresql://app:pw@localhost:5432/app" }); + }); + + it("splits the URL and carries the runtime ssl object when TLS is on", () => { + const ssl = { rejectUnauthorized: true, ca: "pem" }; + + expect( + buildDrizzleCredentials( + "postgresql://app%40ops:p%40ss%2Fword@db.internal:6432/tinkercaster", + ssl + ) + ).toEqual({ + host: "db.internal", + port: 6432, + user: "app@ops", + password: "p@ss/word", + database: "tinkercaster", + ssl, + }); + }); + + it("defaults the port to 5432 when the URL omits it", () => { + expect( + buildDrizzleCredentials("postgresql://app:pw@db.internal/app", { + rejectUnauthorized: true, + }) + ).toMatchObject({ host: "db.internal", port: 5432 }); + }); +}); From 8b5a34e290ad21f366b3512b8b08625bd014be71 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:47:44 +0200 Subject: [PATCH 06/25] fix(api): run migrations from their own image and never install at startup drizzle-kit is a devDependency and was missing from the runtime image. A migrations target now carries it. The API CMD passes --no-install so bun never tries to write packages to a read-only root filesystem (EROFS). The migrations stage sits before production so a plain docker build still produces the API. --- apps/api/Dockerfile.prod | 42 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/api/Dockerfile.prod b/apps/api/Dockerfile.prod index 2adbd5ad..a597612a 100644 --- a/apps/api/Dockerfile.prod +++ b/apps/api/Dockerfile.prod @@ -20,6 +20,43 @@ COPY . . # the API into /app/dist. Both are needed at runtime. RUN bun run build +# Migration image (`--target migrations`): drizzle-kit is a devDependency, so it +# is installed here and kept out of the lean runtime image. The Kubernetes +# migration Job runs it. This stage must stay before `production`: a plain +# `docker build` builds the last stage, and that must be the API. +FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS migrations + +RUN apk add --no-cache dumb-init + +RUN addgroup -g 1001 -S nodejs && \ + adduser -S app -u 1001 -G nodejs + +WORKDIR /app + +ENV HUSKY=0 + +COPY package.json bun.lock* ./ + +RUN bun install --frozen-lockfile + +# Only what `db:migrate` reads: the drizzle config, the SQL history, and the +# env/connection modules the config imports for the shared TLS policy. +COPY drizzle.config.ts ./ +COPY drizzle ./drizzle +COPY src/lib/postgres/connection.ts ./src/lib/postgres/connection.ts +COPY src/config/env ./src/config/env + +# Set after install so `bun install` above still sees devDependencies. +ENV NODE_ENV=production + +RUN chown -R app:nodejs /app + +USER app + +ENTRYPOINT ["dumb-init", "--"] + +CMD ["bun", "run", "db:migrate"] + # Production stage FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS production @@ -53,4 +90,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ ENTRYPOINT ["dumb-init", "--"] -CMD ["bun", "run", "dist/index.js"] +# --no-install: bun must never try to install packages at startup. Production +# root filesystems are read-only (readOnlyRootFilesystem), so an install attempt +# crashes with EROFS. Writable scratch space is /tmp, mounted by the manifests. +CMD ["bun", "--no-install", "run", "dist/index.js"] From 374eac1f33601cb2fabf1e13e064ca8b3bf60a12 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:50:59 +0200 Subject: [PATCH 07/25] feat(k3s): publish one coordinated production release Per-image Argo Image Updater bumped api and ui independently, so the UI could ship before the API image it needed existed. production-release.yml is now the only publisher for Kubernetes. It builds api, migrations and ui as candidates for one commit, then pins all three digests in one GitOps commit. The image-updater annotations are removed. The prod overlay has no mutable tags, and Argo waves order infrastructure, migrations, API, UI. Compose/WUD keeps apps-api-release.yml and apps-ui-release.yml. Adds release-script tests and k3s invariant tests, run by infra-k3s-validate. --- .github/workflows/infra-k3s-validate.yml | 16 ++ .github/workflows/production-release.yml | 129 +++++++++++++ infra/k3s/argocd/boringstack-prod.yaml | 22 +-- infra/k3s/overlays/prod/kustomization.yaml | 45 ++++- infra/k3s/tests/deployment.test.ts | 213 +++++++++++++++++++++ scripts/release/release.mjs | 155 +++++++++++++++ scripts/release/release.test.ts | 195 +++++++++++++++++++ 7 files changed, 753 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/production-release.yml create mode 100644 infra/k3s/tests/deployment.test.ts create mode 100644 scripts/release/release.mjs create mode 100644 scripts/release/release.test.ts diff --git a/.github/workflows/infra-k3s-validate.yml b/.github/workflows/infra-k3s-validate.yml index dce1d3a0..c2720946 100644 --- a/.github/workflows/infra-k3s-validate.yml +++ b/.github/workflows/infra-k3s-validate.yml @@ -28,12 +28,21 @@ jobs: filters: | code: - 'infra/k3s/**' + - 'scripts/release/**' + - 'apps/api/Dockerfile.prod' - '.github/workflows/infra-k3s-validate.yml' + - '.github/workflows/production-release.yml' - name: Install kubectl (bundles kustomize) if: steps.filter.outputs.code == 'true' uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - name: Set up Bun + if: steps.filter.outputs.code == 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + # `kubectl kustomize` renders base + prod overlay offline. Catches the # realistic breakage: bad resource refs, malformed YAML, broken patches, # and the swappable secrets component wiring. CRDs (CNPG, Traefik, @@ -59,3 +68,10 @@ jobs: printf 'JWT_SECRET=x\nVALKEY_PASSWORD=x\n' > infra/k3s/overlays/prod/secrets/plain/secret.env trap 'rm -f infra/k3s/overlays/prod/secrets/plain/secret.env' EXIT kubectl kustomize infra/k3s/overlays/prod/secrets/plain > /dev/null + + # Release invariants on the real render: digest-pinned images, no + # image-updater, a migrate-only Job, a read-only API, and the release + # script's pin contract. See docs/operations/production-release.md. + - name: Test production release invariants + if: steps.filter.outputs.code == 'true' + run: bun test infra/k3s/tests scripts/release diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml new file mode 100644 index 00000000..f90a7baa --- /dev/null +++ b/.github/workflows/production-release.yml @@ -0,0 +1,129 @@ +name: Coordinated production release + +# The only workflow that publishes images for the Kubernetes (Argo) target. +# It builds the API, migrations and UI for one commit as candidates. Only when +# every build succeeds does one commit pin all three digests in +# infra/k3s/overlays/prod/kustomization.yaml. Argo then syncs that single set, +# so the UI can never run ahead of the API it needs. +# Compose/WUD keeps its own publishers (apps-api-release.yml, apps-ui-release.yml). +# They push :latest and sha-* tags that Kubernetes never references. +# See docs/operations/production-release.md. + +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "scripts/release/**" + - ".github/workflows/**" + - "infra/k3s/**" + - "!infra/k3s/overlays/prod/kustomization.yaml" + +# Serialize releases. A stale source commit fails before promotion. +concurrency: + group: production-release + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + release: + # Do not compete with PR checks for self-hosted runners. + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: write + actions: read + packages: write + steps: + - name: Require coordinated deployment activation + env: + RELEASE_MODE: ${{ vars.PRODUCTION_RELEASE_MODE }} + run: | + if [ "$RELEASE_MODE" != coordinated ]; then + echo '::error::Remove the argocd-image-updater annotations from the live Argo Application, then set PRODUCTION_RELEASE_MODE=coordinated. See docs/operations/production-release.md.' + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Wait for this commit's applicable CI checks + env: + GH_TOKEN: ${{ github.token }} + run: bun scripts/release/release.mjs gate + - name: Reject superseded release + run: | + git fetch origin main + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Candidate tags only. Nothing moves `latest` or `sha-*`, and nothing is + # promoted until all three builds below have succeeded. + - name: Build API candidate + id: api + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/api + file: apps/api/Dockerfile.prod + push: true + tags: ghcr.io/${{ github.repository }}-api:candidate-${{ github.sha }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-api + cache-to: type=gha,scope=release-api,mode=max + - name: Build migrations candidate + id: migrations + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/api + file: apps/api/Dockerfile.prod + target: migrations + push: true + tags: ghcr.io/${{ github.repository }}-migrations:candidate-${{ github.sha }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-migrations + cache-to: type=gha,scope=release-migrations,mode=max + - name: Build UI candidate + id: ui + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/ui + file: apps/ui/Dockerfile.prod + push: true + tags: ghcr.io/${{ github.repository }}-ui:candidate-${{ github.sha }} + # Same-origin API by default. Public URL, Sentry and VAPID values come + # from repository variables, so no product domain is hard-coded here. + build-args: | + VITE_API_URL= + VITE_PUBLIC_URL=${{ vars.VITE_PUBLIC_URL }} + VITE_SENTRY_DSN=${{ vars.VITE_SENTRY_DSN }} + VITE_VAPID_PUBLIC_KEY=${{ vars.VITE_VAPID_PUBLIC_KEY }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-ui + cache-to: type=gha,scope=release-ui,mode=max + - name: Promote complete image set in one GitOps commit + env: + API_DIGEST: ${{ steps.api.outputs.digest }} + MIGRATIONS_DIGEST: ${{ steps.migrations.outputs.digest }} + UI_DIGEST: ${{ steps.ui.outputs.digest }} + run: | + set -euo pipefail + git fetch origin main + # No force pushes, rebases, partial promotions or stale release retries. + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + bun scripts/release/release.mjs pin + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add infra/k3s/overlays/prod/kustomization.yaml + git commit -m "deploy: promote complete release $GITHUB_SHA" + git push origin HEAD:main + echo "Promoted three images from $GITHUB_SHA. Argo rollout health must still be checked." >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/k3s/argocd/boringstack-prod.yaml b/infra/k3s/argocd/boringstack-prod.yaml index 6ea11cea..33ff9844 100644 --- a/infra/k3s/argocd/boringstack-prod.yaml +++ b/infra/k3s/argocd/boringstack-prod.yaml @@ -3,24 +3,10 @@ kind: Application metadata: name: boringstack-prod namespace: argocd - annotations: - # --- argocd-image-updater: auto-deploy new GHCR builds ---------------- - # alias=full-image-path. The updater watches GHCR and writes the new tag - # back into overlays/prod/kustomization.yaml, which triggers a sync. - argocd-image-updater.argoproj.io/image-list: api=ghcr.io/boringstack-xyz/boringstack-api, ui=ghcr.io/boringstack-xyz/boringstack-ui - # Track the newest `sha-` build. BoringStack's release workflows tag - # every push to main as `latest` + `sha-` (and a semver tag on a - # changesets/`v*` release), so allow-tags matches the sha- form and ignores - # `latest`. To pin to releases instead, switch to `update-strategy: semver` - # with `allow-tags: regexp:^v?\d+\.\d+\.\d+$`. - argocd-image-updater.argoproj.io/api.update-strategy: newest-build - argocd-image-updater.argoproj.io/api.allow-tags: regexp:^sha-[0-9a-f]{7,40}$ - argocd-image-updater.argoproj.io/ui.update-strategy: newest-build - argocd-image-updater.argoproj.io/ui.allow-tags: regexp:^sha-[0-9a-f]{7,40}$ - # Commit the tag bump back to git (GitOps source of truth). - argocd-image-updater.argoproj.io/write-back-method: git:secret:argocd/image-updater-git-creds - argocd-image-updater.argoproj.io/write-back-target: kustomization - argocd-image-updater.argoproj.io/git-branch: main + # No argocd-image-updater annotations. Image digests are promoted by + # .github/workflows/production-release.yml in one commit to + # overlays/prod/kustomization.yaml, and that commit is what Argo syncs. + # A per-image updater would ship one image ahead of the others. spec: project: default source: diff --git a/infra/k3s/overlays/prod/kustomization.yaml b/infra/k3s/overlays/prod/kustomization.yaml index bbfb120b..f7bbf826 100644 --- a/infra/k3s/overlays/prod/kustomization.yaml +++ b/infra/k3s/overlays/prod/kustomization.yaml @@ -40,15 +40,52 @@ resources: # uncomment this line AND the postgres-backup patch below. # - scheduled-backup.yaml -# Image Updater rewrites these tags on every CI build (write-back-target: -# kustomization). The initial value is a bootstrap placeholder. +# Digests come from ONE coordinated release: .github/workflows/production-release.yml +# builds every image for a commit, then pins all three digests here in a single +# commit. Never edit these by hand, and never use mutable tags. +# The all-zero digests are a bootstrap placeholder: pulls fail closed until the +# first release has run. images: - name: ghcr.io/boringstack-xyz/boringstack-api - newTag: latest + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + - name: ghcr.io/boringstack-xyz/boringstack-migrations + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 - name: ghcr.io/boringstack-xyz/boringstack-ui - newTag: latest + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 patches: + # Argo sync waves. A full sync applies in this order: + # -4 namespace, identity, config and secrets + # -2 database and cache (kept on prune) + # -1 migrations (Sync hook, base/api/migration-job.yaml) + # 0 api, plus the edge and monitoring resources + # 1 ui, once the api is healthy + # Ordering is what makes a first sync work: a PreSync hook would run before + # the database exists. + - target: + kind: Namespace|ServiceAccount|Role|RoleBinding|VaultConnection|VaultAuth|VaultStaticSecret|ConfigMap|NetworkPolicy|Service|ResourceQuota|LimitRange + patch: |- + - op: add + path: /metadata/annotations + value: + argocd.argoproj.io/sync-wave: "-4" + - target: + kind: Cluster|StatefulSet + patch: |- + - op: add + path: /metadata/annotations + value: + argocd.argoproj.io/sync-wave: "-2" + argocd.argoproj.io/sync-options: Delete=false,Prune=false + - target: + kind: Deployment + name: ui + patch: |- + - op: add + path: /metadata/annotations + value: + argocd.argoproj.io/sync-wave: "1" + # Pull private GHCR images on every Deployment in the overlay. - patch: |- apiVersion: apps/v1 diff --git a/infra/k3s/tests/deployment.test.ts b/infra/k3s/tests/deployment.test.ts new file mode 100644 index 00000000..bae66176 --- /dev/null +++ b/infra/k3s/tests/deployment.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { YAML } from "bun"; + +// Invariants for the production release path. These run against the real +// `kubectl kustomize` render, so they assert what Argo will apply. +const overlay = new URL("../overlays/prod", import.meta.url).pathname; +const rendered = Bun.spawnSync(["kubectl", "kustomize", overlay]); +if (rendered.exitCode !== 0) throw new Error(rendered.stderr.toString()); +const renderedText = rendered.stdout.toString(); +// Rendered documents are controlled deployment fixtures, parsed only in tests. +const documents = renderedText + .split(/^---$/m) + .map((doc) => doc.trim()) + .filter((doc) => doc.length > 0) + .map((doc) => YAML.parse(doc)); + +type Doc = (typeof documents)[number]; + +const resource = (kind: string, name: string): Doc => { + const item = documents.find( + (doc) => doc.kind === kind && doc.metadata.name === name, + ); + if (!item) throw new Error(`Missing ${kind}/${name}`); + return item; +}; +const wave = (item: Doc) => + Number(item.metadata.annotations?.["argocd.argoproj.io/sync-wave"] ?? 0); +const podSpec = (item: Doc) => item.spec.template.spec; +const container = (item: Doc, name: string) => { + const found = podSpec(item).containers.find( + (c: { name: string }) => c.name === name, + ); + if (!found) throw new Error(`Missing container ${name}`); + return found; +}; +const env = (item: Doc, name: string) => { + const found = container(item, "api").env as Array<{ + name: string; + valueFrom?: { secretKeyRef?: { name: string; key: string } }; + }>; + return found.find((entry) => entry.name === name); +}; +const images = () => + documents.flatMap((doc) => + doc.spec?.template?.spec?.containers + ? doc.spec.template.spec.containers.map( + (c: { image: string }) => c.image, + ) + : [], + ); + +describe("production release contracts", () => { + test("project images are pinned by digest and no image renders as latest", () => { + const all = images(); + expect(all.length).toBeGreaterThan(0); + for (const image of all) { + expect(image).not.toMatch(/:latest\b/); + expect(image).toContain(":"); + if (image.startsWith("ghcr.io/boringstack-xyz/")) { + expect(image).toMatch(/@sha256:[a-f0-9]{64}$/); + } + } + expect(renderedText).not.toMatch(/image: \S+:latest\b/); + }); + + test("the three release images are the ones the release workflow pins", () => { + const names = images().map((image) => image.split("@")[0]); + expect(names).toContain("ghcr.io/boringstack-xyz/boringstack-api"); + expect(names).toContain("ghcr.io/boringstack-xyz/boringstack-migrations"); + expect(names).toContain("ghcr.io/boringstack-xyz/boringstack-ui"); + }); + + test("argocd-image-updater annotations are gone from the Application and the render", async () => { + const application = YAML.parse( + await Bun.file( + new URL("../argocd/boringstack-prod.yaml", import.meta.url), + ).text(), + ) as { metadata: { annotations?: Record } }; + const keys = Object.keys(application.metadata.annotations ?? {}); + expect(keys.filter((key) => key.startsWith("argocd-image-updater."))).toEqual( + [], + ); + expect(renderedText).not.toContain("argocd-image-updater"); + }); + + test("first sync creates infrastructure before migrations, and migrations before the API", () => { + const migration = resource("Job", "api-migrations"); + expect(migration.metadata.annotations["argocd.argoproj.io/hook"]).toBe( + "Sync", + ); + expect( + wave(resource("VaultStaticSecret", "boringstack-secrets")), + ).toBeLessThan(wave(resource("Cluster", "boringstack-db"))); + expect(wave(resource("Cluster", "boringstack-db"))).toBeLessThan( + wave(migration), + ); + expect(wave(resource("StatefulSet", "valkey"))).toBeLessThan( + wave(migration), + ); + expect(wave(migration)).toBeLessThan(wave(resource("Deployment", "api"))); + expect(wave(resource("Deployment", "api"))).toBeLessThan( + wave(resource("Deployment", "ui")), + ); + }); + + test("the migration Job migrates only and never seeds", () => { + const migration = resource("Job", "api-migrations"); + const pod = podSpec(migration); + const migrate = pod.containers[0]; + expect(migrate.image).toMatch(/boringstack-migrations@sha256:/); + const invocation = [ + ...(migrate.command ?? []), + ...(migrate.args ?? []), + ].join(" "); + expect(invocation).toContain("db:migrate"); + expect(invocation).not.toMatch(/db:seed|db:prepare|seed/); + // No app secret set: migrations need only the database connection. + expect(migrate.envFrom).toBeUndefined(); + expect(migrate.securityContext.readOnlyRootFilesystem).toBe(true); + expect(migrate.volumeMounts).toContainEqual({ + name: "runtime-tmp", + mountPath: "/tmp", + }); + }); + + test("the API runs on a read-only root with only /tmp writable", async () => { + const api = resource("Deployment", "api"); + const pod = podSpec(api); + const app = container(api, "api"); + expect(app.securityContext.readOnlyRootFilesystem).toBe(true); + expect(app.securityContext.allowPrivilegeEscalation).toBe(false); + expect(app.volumeMounts).toContainEqual({ + name: "runtime-tmp", + mountPath: "/tmp", + }); + expect(pod.volumes).toContainEqual({ + name: "runtime-tmp", + emptyDir: { sizeLimit: "256Mi" }, + }); + expect(env(api, "BUN_RUNTIME_TRANSPILER_CACHE_PATH")?.value).toBe( + "/tmp/bun-transpiler", + ); + // The image CMD must not let bun install packages at startup. + const dockerfile = await Bun.file( + new URL("../../../apps/api/Dockerfile.prod", import.meta.url), + ).text(); + expect(dockerfile).toContain( + 'CMD ["bun", "--no-install", "run", "dist/index.js"]', + ); + }); + + test("runtime client and migrations verify the database with the same CA", () => { + const apiCa = env(resource("Deployment", "api"), "DATABASE_SSL_CA"); + const migrateEnv = container( + resource("Job", "api-migrations"), + "migrate", + ).env as Array<{ + name: string; + valueFrom?: { secretKeyRef?: { name: string; key: string } }; + }>; + const migrationCa = migrateEnv.find( + (entry) => entry.name === "DATABASE_SSL_CA", + ); + expect(apiCa?.valueFrom?.secretKeyRef).toEqual({ + name: "boringstack-db-ca", + key: "ca.crt", + }); + expect(migrationCa?.valueFrom?.secretKeyRef).toEqual( + apiCa?.valueFrom?.secretKeyRef, + ); + }); + + test("network policies admit the migration Job to Postgres and Prometheus to the API", () => { + const postgresIngress = JSON.stringify( + resource("NetworkPolicy", "postgres-network-policy").spec.ingress, + ); + expect(postgresIngress).toContain('"app":"api-migrations"'); + const apiIngress = JSON.stringify( + resource("NetworkPolicy", "api-network-policy").spec.ingress, + ); + expect(apiIngress).toContain('"app.kubernetes.io/name":"prometheus"'); + expect(apiIngress).toContain('"app.kubernetes.io/name":"traefik"'); + }); + + test("API disruption budget tolerates a single node drain", () => { + const budget = resource("PodDisruptionBudget", "api-pdb"); + expect(budget.spec.maxUnavailable).toBe(1); + expect(budget.spec.minAvailable).toBeUndefined(); + }); + + test("app secrets exclude VSO's _raw copy so they never reach one env var", () => { + const secret = resource("VaultStaticSecret", "boringstack-secrets"); + expect(secret.spec.destination.transformation.excludeRaw).toBe(true); + expect( + resource("VaultStaticSecret", "ghcr-registry-secret").spec.destination + .transformation.excludeRaw, + ).toBe(true); + }); + + test("the database and cache resist pruning and use canonical quantities", () => { + for (const [kind, name] of [ + ["Cluster", "boringstack-db"], + ["StatefulSet", "valkey"], + ] as const) { + expect( + resource(kind, name).metadata.annotations[ + "argocd.argoproj.io/sync-options" + ], + ).toContain("Prune=false"); + } + expect(renderedText).not.toContain('"1000m"'); + }); +}); diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs new file mode 100644 index 00000000..cae6a608 --- /dev/null +++ b/scripts/release/release.mjs @@ -0,0 +1,155 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { Glob, YAML } from "bun"; + +// Every image the production overlay pins. Keep in sync with the `images:` +// block in infra/k3s/overlays/prod/kustomization.yaml (the tests enforce it). +const images = ["api", "migrations", "ui"]; +const releasePath = ".github/workflows/production-release.yml"; +const overlayPath = "infra/k3s/overlays/prod/kustomization.yaml"; + +function matches(patterns, path) { + let included = false; + for (const pattern of patterns) { + const negative = pattern.startsWith("!"); + if (new Glob(negative ? pattern.slice(1) : pattern).match(path)) + included = !negative; + } + return included; +} + +// Which push workflows must succeed for this commit. A workflow is required +// when a main push would run it: branch filter, path filter and ignore lists +// all apply, exactly as GitHub evaluates them. +export function expectedWorkflows(workflows, changed) { + return workflows + .filter((workflow) => { + if ( + workflow.path === releasePath || + !Object.hasOwn(workflow.on ?? {}, "push") + ) + return false; + const push = workflow.on.push ?? {}; + if (push.tags && !push.branches) return false; + if (push.branches && !matches(push.branches, "main")) return false; + if (push["branches-ignore"] && matches(push["branches-ignore"], "main")) + return false; + if (push.paths && !changed.some((path) => matches(push.paths, path))) + return false; + if ( + push["paths-ignore"] && + changed.every((path) => matches(push["paths-ignore"], path)) + ) + return false; + return true; + }) + .map((workflow) => workflow.path); +} + +// GitHub returns newest runs first. A rerun must never inherit an old success. +export function checkRuns(expected, runs) { + if (!expected.length) + throw new Error("No required workflows discovered; refusing release"); + let ready = true; + for (const path of expected) { + const run = runs.find((item) => item.path === path); + if (!run || run.status !== "completed") { + ready = false; + } else if (run.conclusion !== "success") { + throw new Error(`Release blocked by ${path}: ${run.conclusion}`); + } + } + return ready; +} + +export function pinImages(text, repository, digests) { + if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repository)) + throw new Error("Invalid repository"); + for (const image of images) { + if (!/^sha256:[a-f0-9]{64}$/.test(digests[image] ?? "")) + throw new Error(`Missing/invalid ${image} digest`); + const escaped = `ghcr.io/${repository}-${image}`.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const pattern = new RegExp( + `( - name: ${escaped}\\n)((?: [^\\n]*\\n)*)`, + "g", + ); + let count = 0; + text = text.replace(pattern, (whole, name) => { + count++; + return `${name} digest: ${digests[image]}\n`; + }); + if (count !== 1) + throw new Error(`Expected exactly one production ${image} image`); + } + return text; +} + +async function command(args) { + const child = Bun.spawn(args, { stdout: "pipe", stderr: "inherit" }); + const output = await new Response(child.stdout).text(); + if ((await child.exited) !== 0) + throw new Error(`Command failed: ${args[0]} ${args[1]}`); + return output.trim(); +} + +async function gate() { + const event = JSON.parse( + await readFile(process.env.GITHUB_EVENT_PATH, "utf8"), + ); + const sha = process.env.GITHUB_SHA; + if ( + !/^[a-f0-9]{40}$/.test(sha ?? "") || + !/^[a-f0-9]{40}$/.test(event.before ?? "") + ) + throw new Error("Release requires a main push event"); + const changed = ( + await command(["git", "diff", "--name-only", event.before, sha]) + ).split("\n"); + const workflows = []; + for (const file of await readdir(".github/workflows")) { + if (!/\.ya?ml$/.test(file)) continue; + const path = `.github/workflows/${file}`; + workflows.push({ ...YAML.parse(await readFile(path, "utf8")), path }); + } + const expected = expectedWorkflows(workflows, changed); + console.log("Waiting for successful checks at", sha, expected); + const deadline = Date.now() + 45 * 60_000; + while (Date.now() < deadline) { + const pages = JSON.parse( + await command([ + "gh", + "api", + "--paginate", + "--slurp", + `repos/${process.env.GITHUB_REPOSITORY}/actions/runs?head_sha=${sha}&event=push&per_page=100`, + ]), + ); + const runs = pages.flatMap((page) => page.workflow_runs); + if (checkRuns(expected, runs)) return; + await Bun.sleep(15_000); + } + throw new Error( + "CI did not complete within 45 minutes; no production images changed", + ); +} + +async function promote() { + const digests = Object.fromEntries( + images.map((image) => [image, process.env[`${image.toUpperCase()}_DIGEST`]]), + ); + const text = await readFile(overlayPath, "utf8"); + // Validate the full set before touching disk. + await writeFile( + overlayPath, + pinImages(text, process.env.GITHUB_REPOSITORY, digests), + ); +} + +if (import.meta.main) { + const action = process.argv[2]; + if (action === "gate") await gate(); + else if (action === "pin") await promote(); + else throw new Error("Expected gate or pin"); +} diff --git a/scripts/release/release.test.ts b/scripts/release/release.test.ts new file mode 100644 index 00000000..75b89e78 --- /dev/null +++ b/scripts/release/release.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test"; +import { YAML } from "bun"; +import { checkRuns, expectedWorkflows, pinImages } from "./release.mjs"; + +const repository = "boringstack-xyz/boringstack"; +const imageNames = ["api", "migrations", "ui"]; +const hexFor = { api: "a", migrations: "b", ui: "c" } as const; +const digestOf = (name: string) => + "sha256:" + hexFor[name as keyof typeof hexFor].repeat(64); +const digests = Object.fromEntries( + imageNames.map((name) => [name, digestOf(name)]), +); +const placeholderOverlay = (suffix = "") => + "resources:\n - ../../base\nimages:\n" + + imageNames + .map( + (name) => + ` - name: ghcr.io/${repository}-${name}\n newTag: latest\n`, + ) + .join("") + + "patches: []\n" + + suffix; + +describe("coordinated release", () => { + test("requires matching workflows by file path, including unfiltered checks", () => { + const workflows = [ + { path: "all.yml", on: { push: { branches: ["main"] } } }, + { path: "api.yml", on: { push: { paths: ["apps/api/**"] } } }, + { path: "ui.yml", on: { push: { paths: ["apps/ui/**"] } } }, + { path: "pr.yml", on: { pull_request: {} } }, + { + path: ".github/workflows/production-release.yml", + on: { push: { branches: ["main"] } }, + }, + ]; + expect(expectedWorkflows(workflows, ["apps/api/a.ts"])).toEqual([ + "all.yml", + "api.yml", + ]); + }); + test("honours branch and ignore filters as GitHub does", () => { + const workflows = [ + { path: "tags.yml", on: { push: { tags: ["v*"] } } }, + { path: "other.yml", on: { push: { branches: ["dev"] } } }, + { + path: "ignored.yml", + on: { push: { "paths-ignore": ["docs/**"] } }, + }, + ]; + expect(expectedWorkflows(workflows, ["docs/readme.md"])).toEqual([]); + expect(expectedWorkflows(workflows, ["apps/api/a.ts"])).toEqual([ + "ignored.yml", + ]); + }); + test("never treats missing, running or failed checks as success", () => { + expect(checkRuns(["a"], [])).toBe(false); + expect(checkRuns(["a"], [{ path: "a", status: "queued" }])).toBe(false); + expect(() => + checkRuns( + ["a"], + [{ path: "a", status: "completed", conclusion: "failure" }], + ), + ).toThrow(); + expect(() => + checkRuns( + ["a"], + [{ path: "a", status: "completed", conclusion: "cancelled" }], + ), + ).toThrow(); + expect( + checkRuns( + ["a"], + [{ path: "a", status: "completed", conclusion: "success" }], + ), + ).toBe(true); + expect(() => checkRuns([], [])).toThrow(); + }); + test("latest rerun takes precedence over older successful run", () => { + expect( + checkRuns( + ["a"], + [ + { path: "a", status: "queued" }, + { path: "a", status: "completed", conclusion: "success" }, + ], + ), + ).toBe(false); + }); + test("pins all three images or changes nothing; preserves unrelated configuration", () => { + const text = placeholderOverlay(); + const result = pinImages(text, repository, digests); + expect(result.match(/digest: sha256:/g)).toHaveLength(3); + expect(result).not.toContain("newTag"); + expect(result).toContain("patches: []"); + expect(result).toContain("resources:\n - ../../base"); + expect(() => + pinImages(text, repository, { ...digests, api: "" }), + ).toThrow(); + expect(() => + pinImages(text.replace("boringstack-ui", "other"), repository, digests), + ).toThrow(); + expect(pinImages(result, repository, digests)).toBe(result); + }); + test("the committed overlay pins exactly the images the release publishes", async () => { + const overlay = YAML.parse( + await Bun.file("infra/k3s/overlays/prod/kustomization.yaml").text(), + ) as { images: Array<{ name: string; digest?: string }> }; + const names = overlay.images.map((image) => image.name).sort(); + expect(names).toEqual( + imageNames + .map((name) => `ghcr.io/${repository}-${name}`) + .sort(), + ); + // The committed overlay must stay in the shape the pin step rewrites. + const pinned = pinImages( + await Bun.file("infra/k3s/overlays/prod/kustomization.yaml").text(), + repository, + digests, + ); + for (const name of imageNames) { + expect(pinned).toContain( + ` - name: ghcr.io/${repository}-${name}\n digest: ${digests[name]}\n`, + ); + } + }); +}); + +test("release configuration has one k8s publisher, requires activation, checks and a complete set", async () => { + const workflow = YAML.parse( + await Bun.file(".github/workflows/production-release.yml").text(), + ) as { + jobs: { + release: { + "runs-on": string; + steps: Array<{ + run?: string; + uses?: string; + with?: { tags?: string; target?: string }; + env?: Record; + }>; + }; + }; + }; + const release = workflow.jobs.release; + expect(release["runs-on"]).toBe("ubuntu-24.04"); + const steps = release.steps; + expect(steps[0].run).toContain('"$RELEASE_MODE" != coordinated'); + const gate = steps.findIndex((step) => + step.run?.includes("release.mjs gate"), + ); + const builds = steps.filter((step) => + step.uses?.startsWith("docker/build-push-action"), + ); + expect(builds).toHaveLength(3); + expect(gate).toBeGreaterThan(-1); + expect(gate).toBeLessThan(steps.indexOf(builds[0])); + expect(builds.every((step) => step.with?.tags?.includes(":candidate-"))).toBe( + true, + ); + expect( + builds.map((step) => step.with?.tags?.match(/-(api|migrations|ui):/)?.[1]), + ).toEqual(imageNames); + const promotion = steps.at(-1); + expect(Object.keys(promotion?.env ?? {}).sort()).toEqual([ + "API_DIGEST", + "MIGRATIONS_DIGEST", + "UI_DIGEST", + ]); + expect(promotion?.run).toContain( + 'test "$(git rev-parse origin/main)" = "$GITHUB_SHA"', + ); + expect(promotion?.run).not.toContain("--force"); + // Only the release workflow may write the production image pins. + for (const file of [ + ".github/workflows/apps-api-release.yml", + ".github/workflows/apps-ui-release.yml", + ".github/workflows/infra-k3s-validate.yml", + ]) { + expect(await Bun.file(file).text()).not.toContain( + "overlays/prod/kustomization.yaml", + ); + } +}); + +test("no workflow still runs the Argo Image Updater for Kubernetes", async () => { + const glob = new Bun.Glob(".github/workflows/*.yml"); + for await (const file of glob.scan(".")) { + expect(await Bun.file(file).text()).not.toContain( + "argocd-image-updater.argoproj.io/", + ); + } + expect( + await Bun.file("infra/k3s/argocd/boringstack-prod.yaml").text(), + ).not.toContain("argocd-image-updater.argoproj.io/"); +}); From c5443543bdc25f72351e17ac236ed84ec8ea4ac4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:50:59 +0200 Subject: [PATCH 08/25] fix(k3s): migrate only, on a read-only API and migrations root The migration Job ran db:seed on every sync and reverted admin edits. It is now a Sync hook at wave -1 that runs db:migrate only, from the migrations image. It mounts no app secrets, runs on a read-only root with /tmp, and verifies the CNPG CA the same way the API does. A PreSync hook also ran before the database existed on a first sync. The API runs on a read-only root with /tmp and Bun's transpiler cache there. Its CMD passes --no-install. DATABASE_SSL_CA is injected from the CNPG CA secret. Release images are amd64, so pods select amd64 nodes. --- infra/k3s/base/api/deployment.yaml | 25 ++++++++++++ infra/k3s/base/api/migration-job.yaml | 57 ++++++++++++++++----------- infra/k3s/base/ui/deployment.yaml | 3 ++ 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/infra/k3s/base/api/deployment.yaml b/infra/k3s/base/api/deployment.yaml index 620f5982..95dd0ae2 100644 --- a/infra/k3s/base/api/deployment.yaml +++ b/infra/k3s/base/api/deployment.yaml @@ -18,7 +18,16 @@ spec: labels: app: api spec: + # Release workflows publish linux/amd64 images only. + nodeSelector: + kubernetes.io/arch: amd64 serviceAccountName: api + # The root filesystem is read-only. /tmp is the only writable scratch + # space, and Bun's transpiler cache lives there too (see env below). + volumes: + - name: runtime-tmp + emptyDir: + sizeLimit: 256Mi terminationGracePeriodSeconds: 30 securityContext: runAsNonRoot: true @@ -35,8 +44,13 @@ spec: capabilities: drop: - ALL + # Tag is a placeholder: overlays/prod pins a digest from one + # coordinated release. The image CMD is `bun --no-install run dist/index.js`. image: ghcr.io/boringstack-xyz/boringstack-api:latest imagePullPolicy: Always + volumeMounts: + - name: runtime-tmp + mountPath: /tmp ports: - name: http containerPort: 7330 @@ -49,12 +63,23 @@ spec: - secretRef: name: boringstack-secrets env: + - name: TMPDIR + value: /tmp + - name: BUN_RUNTIME_TRANSPILER_CACHE_PATH + value: /tmp/bun-transpiler # CloudNativePG publishes the app connection string here. - name: DATABASE_URL valueFrom: secretKeyRef: name: boringstack-db-app key: uri + # CloudNativePG's CA. The runtime client and the migration Job both + # verify the server certificate against it (src/lib/postgres/connection.ts). + - name: DATABASE_SSL_CA + valueFrom: + secretKeyRef: + name: boringstack-db-ca + key: ca.crt # Infra topology (not secret): the in-namespace Valkey Service. - name: VALKEY_HOST value: valkey diff --git a/infra/k3s/base/api/migration-job.yaml b/infra/k3s/base/api/migration-job.yaml index 7766a249..eaaee904 100644 --- a/infra/k3s/base/api/migration-job.yaml +++ b/infra/k3s/base/api/migration-job.yaml @@ -4,8 +4,12 @@ metadata: name: api-migrations namespace: boringstack annotations: - # Runs before every sync, so schema is migrated before new pods roll out. - argocd.argoproj.io/hook: PreSync + # A Sync hook at wave -1 runs after the database (wave -2) exists and + # before the API Deployment (wave 0). It runs on every sync, so it must + # only migrate. Never add db:seed or db:prepare here: they re-assert seed + # data over admin edits. See infra/k3s/README.md, "Migrations and seeds". + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/sync-wave: "-1" argocd.argoproj.io/hook-delete-policy: BeforeHookCreation spec: ttlSecondsAfterFinished: 300 @@ -15,6 +19,9 @@ spec: labels: app: api-migrations spec: + # Release workflows publish linux/amd64 images only. + nodeSelector: + kubernetes.io/arch: amd64 restartPolicy: OnFailure serviceAccountName: api imagePullSecrets: @@ -26,37 +33,43 @@ spec: fsGroup: 1001 seccompProfile: type: RuntimeDefault + volumes: + - name: runtime-tmp + emptyDir: + sizeLimit: 256Mi containers: - name: migrate - image: ghcr.io/boringstack-xyz/boringstack-api:latest + image: ghcr.io/boringstack-xyz/boringstack-migrations:latest imagePullPolicy: Always - command: - - /bin/sh - - -c - - | - echo "Running Drizzle migrations..." - bun run db:migrate - echo "Seeding..." - bun run db:seed - echo "Migrations complete." - envFrom: - - secretRef: - name: boringstack-secrets + # ENTRYPOINT is dumb-init. The migrations image also runs + # `bun run db:migrate` by default; this is explicit on purpose. + args: ["bun", "run", "db:migrate"] + volumeMounts: + - name: runtime-tmp + mountPath: /tmp + # Deliberately no envFrom: migrations need only the database + # connection, so the app secret set (JWT, Stripe, email keys) is not + # mounted into this pod. env: + - name: TMPDIR + value: /tmp + - name: NODE_ENV + value: production - name: DATABASE_URL valueFrom: secretKeyRef: name: boringstack-db-app key: uri - - name: VALKEY_HOST - value: valkey - - name: VALKEY_PORT - value: "6379" - - name: NODE_ENV - value: production + # Same CA as the API runtime client, so drizzle-kit verifies the + # CNPG server certificate the same way (src/lib/postgres/connection.ts). + - name: DATABASE_SSL_CA + valueFrom: + secretKeyRef: + name: boringstack-db-ca + key: ca.crt securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: false + readOnlyRootFilesystem: true capabilities: drop: - ALL diff --git a/infra/k3s/base/ui/deployment.yaml b/infra/k3s/base/ui/deployment.yaml index 9b7d9fc6..31fbdef4 100644 --- a/infra/k3s/base/ui/deployment.yaml +++ b/infra/k3s/base/ui/deployment.yaml @@ -18,6 +18,9 @@ spec: labels: app: ui spec: + # Release workflows publish linux/amd64 images only. + nodeSelector: + kubernetes.io/arch: amd64 serviceAccountName: ui terminationGracePeriodSeconds: 30 securityContext: From fc43ba7eacb47d367061ece319abcd9cb150a0df Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:51:00 +0200 Subject: [PATCH 09/25] fix(k3s): unblock migrations, scraping, drains and secret leakage - postgres policy admits the migration pods, which are not app: api. - api policy admits Prometheus scrapes of /metrics. - api PDB uses maxUnavailable: 1 so a node drain can complete on a small cluster. - VSO secrets use excludeRaw. Without it, every secret also lands in one JSON env var in api and migration pods. Rollouts follow Vault changes. - Valkey runs AOF with everysec fsync so queued jobs survive a restart. - CNPG CPU is written as "1", the form it stores, so there is no drift. - Certificate comment records the zone-scoped DNS-01 issuer pitfall. --- infra/k3s/base/api/networkpolicy.yaml | 12 ++++++++++++ infra/k3s/base/postgres/networkpolicy.yaml | 6 +++++- infra/k3s/base/valkey/statefulset.yaml | 14 +++++++------- infra/k3s/overlays/prod/certificate.yaml | 4 ++++ infra/k3s/overlays/prod/patches/postgres-ha.yaml | 2 +- infra/k3s/overlays/prod/pdbs.yaml | 7 ++++++- .../prod/secrets/vault/vault-registry-secret.yaml | 2 ++ .../overlays/prod/secrets/vault/vault-secrets.yaml | 12 ++++++++++++ 8 files changed, 49 insertions(+), 10 deletions(-) diff --git a/infra/k3s/base/api/networkpolicy.yaml b/infra/k3s/base/api/networkpolicy.yaml index 875957ac..fc28eb63 100644 --- a/infra/k3s/base/api/networkpolicy.yaml +++ b/infra/k3s/base/api/networkpolicy.yaml @@ -22,6 +22,18 @@ spec: ports: - protocol: TCP port: 7330 + # Cluster Prometheus scraping /metrics (overlays/prod/monitoring/servicemonitor.yaml). + # Without this the policy blocks the scrape while the target looks healthy. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 7330 egress: # DNS - to: diff --git a/infra/k3s/base/postgres/networkpolicy.yaml b/infra/k3s/base/postgres/networkpolicy.yaml index e9121939..c6d871bd 100644 --- a/infra/k3s/base/postgres/networkpolicy.yaml +++ b/infra/k3s/base/postgres/networkpolicy.yaml @@ -11,11 +11,15 @@ spec: - Ingress - Egress ingress: - # From the API and GlitchTip pods. + # From the API, the migration Job (its own pod label, so it is not covered + # by `app: api`) and GlitchTip pods. - from: - podSelector: matchLabels: app: api + - podSelector: + matchLabels: + app: api-migrations - podSelector: matchLabels: app: glitchtip diff --git a/infra/k3s/base/valkey/statefulset.yaml b/infra/k3s/base/valkey/statefulset.yaml index d3cb38fd..7296c704 100644 --- a/infra/k3s/base/valkey/statefulset.yaml +++ b/infra/k3s/base/valkey/statefulset.yaml @@ -37,16 +37,16 @@ spec: command: - valkey-server args: - # Cache + BullMQ queue backend. Light persistence (RDB) to /data so - # queued jobs survive a restart; flip --save "" + --appendonly no for - # a pure cache. requirepass must equal the password the API sends. + # Cache + BullMQ queue backend. AOF with fsync every second bounds + # queued-job loss to about a second on restart; RDB snapshots alone + # can lose minutes of queue mutations. For a pure cache, drop the + # appendonly flags. requirepass must equal the password the API sends. - --dir - /data - - --save - - "60" - - "1000" - --appendonly - - "no" + - "yes" + - --appendfsync + - everysec - --maxmemory-policy - noeviction - --requirepass diff --git a/infra/k3s/overlays/prod/certificate.yaml b/infra/k3s/overlays/prod/certificate.yaml index 66b0522f..b41ac3ea 100644 --- a/infra/k3s/overlays/prod/certificate.yaml +++ b/infra/k3s/overlays/prod/certificate.yaml @@ -3,6 +3,10 @@ # - cert-manager installed in the cluster # - a DNS-01-capable ClusterIssuer (verify name: kubectl get clusterissuer) # The issuer name below is a per-cluster knob, see infra/k3s/README.md. +# Pitfall: the issuer's DNS-01 credential must be able to write _acme-challenge +# records in the zone of EVERY dnsName here. A token scoped to a different zone +# leaves the Certificate Ready=False while Traefik serves its default cert. +# Check with `kubectl describe certificate` and the challenge events. apiVersion: cert-manager.io/v1 kind: Certificate metadata: diff --git a/infra/k3s/overlays/prod/patches/postgres-ha.yaml b/infra/k3s/overlays/prod/patches/postgres-ha.yaml index 9587d0ce..d8f91899 100644 --- a/infra/k3s/overlays/prod/patches/postgres-ha.yaml +++ b/infra/k3s/overlays/prod/patches/postgres-ha.yaml @@ -18,7 +18,7 @@ spec: cpu: "250m" limits: memory: "2Gi" - cpu: "1000m" + cpu: "1" # CNPG stores 1000m as "1"; write it that way or every sync shows drift. postgresql: parameters: shared_buffers: "256MB" diff --git a/infra/k3s/overlays/prod/pdbs.yaml b/infra/k3s/overlays/prod/pdbs.yaml index 6f1c3397..12c634ff 100644 --- a/infra/k3s/overlays/prod/pdbs.yaml +++ b/infra/k3s/overlays/prod/pdbs.yaml @@ -4,7 +4,12 @@ metadata: name: api-pdb namespace: boringstack-prod spec: - minAvailable: 2 + # maxUnavailable, not minAvailable: 2. The api runs 3 replicas with + # preferred (not required) anti-affinity, so on a small cluster they can + # land 2/1 across nodes. Draining the node that holds two then never + # satisfies minAvailable: 2, and node maintenance blocks indefinitely. + # maxUnavailable: 1 expresses the same intent and drains cleanly. + maxUnavailable: 1 selector: matchLabels: app: api diff --git a/infra/k3s/overlays/prod/secrets/vault/vault-registry-secret.yaml b/infra/k3s/overlays/prod/secrets/vault/vault-registry-secret.yaml index a1106fd3..567ca005 100644 --- a/infra/k3s/overlays/prod/secrets/vault/vault-registry-secret.yaml +++ b/infra/k3s/overlays/prod/secrets/vault/vault-registry-secret.yaml @@ -15,6 +15,8 @@ spec: overwrite: true type: kubernetes.io/dockerconfigjson transformation: + # Drop VSO's `_raw` copy of the Vault response; only the dockerconfigjson key is needed. + excludeRaw: true templates: .dockerconfigjson: text: "{{ .Secrets.DOCKER_CONFIG_JSON }}" diff --git a/infra/k3s/overlays/prod/secrets/vault/vault-secrets.yaml b/infra/k3s/overlays/prod/secrets/vault/vault-secrets.yaml index ce0966cc..58785491 100644 --- a/infra/k3s/overlays/prod/secrets/vault/vault-secrets.yaml +++ b/infra/k3s/overlays/prod/secrets/vault/vault-secrets.yaml @@ -15,5 +15,17 @@ spec: create: true overwrite: true type: Opaque + # REQUIRED here. VSO adds a `_raw` key holding the entire Vault response as + # JSON. Workloads consume this Secret with `envFrom: secretRef`, so without + # excludeRaw every secret would also land in one env var, JSON-encoded, in + # every api and valkey pod. Node error pages and APM agents dump process.env. + transformation: + excludeRaw: true # Re-sync the k8s Secret (and roll consumers) when the Vault value changes. + rolloutRestartTargets: + - kind: Deployment + name: api + - kind: StatefulSet + name: valkey + refreshAfter: 1m hmacSecretData: true From 61ac7b907e7f4df00aa0f33debc92a38b04cb4c4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:58:13 +0200 Subject: [PATCH 10/25] fix(api): carry the one-off superuser seed in the migrations image Production migrations are migrate-only, so the first admin needs an explicit run. The runtime image has no source tree, so the migrations image now carries it, and db:seed works there. The seed stays idempotent. Env and script comments no longer claim the migrate job creates the admin. --- apps/api/.env.example | 4 ++-- apps/api/Dockerfile.prod | 10 ++++++---- apps/api/scripts/db/seed-superuser.ts | 2 +- apps/api/src/config/env/schema.ts | 9 +++++---- ...argocd-image-updater.mdx => production-release.mdx} | 0 5 files changed, 14 insertions(+), 11 deletions(-) rename apps/docs/src/content/docs/runbooks/{argocd-image-updater.mdx => production-release.mdx} (100%) diff --git a/apps/api/.env.example b/apps/api/.env.example index 3aa3b68d..c1103c2d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -58,8 +58,8 @@ AUTH_RATE_LIMIT_WINDOW_MS=60000 # proxy in front). TRUST_PROXY=false -# Optional first-boot superuser. When both are set, `bun run db:seed` -# creates a user with admin role on the first run. Empty = no user +# Optional superuser. When both are set, `bun run db:seed` creates a user +# with admin role (an existing user is left alone). Empty = no user # created; sign up via the registration flow instead. # # Running under docker-compose? Setting these HERE has no effect — the diff --git a/apps/api/Dockerfile.prod b/apps/api/Dockerfile.prod index a597612a..c5ec5d4c 100644 --- a/apps/api/Dockerfile.prod +++ b/apps/api/Dockerfile.prod @@ -39,12 +39,14 @@ COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile -# Only what `db:migrate` reads: the drizzle config, the SQL history, and the -# env/connection modules the config imports for the shared TLS policy. +# `db:migrate` reads the drizzle config and SQL history. The source is here for +# an explicit one-off `bun run db:seed` (first admin only). The migration Job +# never runs it. The runtime image has no source tree, so seeds cannot run there. +COPY tsconfig.json ./ COPY drizzle.config.ts ./ COPY drizzle ./drizzle -COPY src/lib/postgres/connection.ts ./src/lib/postgres/connection.ts -COPY src/config/env ./src/config/env +COPY scripts/db ./scripts/db +COPY src ./src # Set after install so `bun install` above still sees devDependencies. ENV NODE_ENV=production diff --git a/apps/api/scripts/db/seed-superuser.ts b/apps/api/scripts/db/seed-superuser.ts index 7a4cfc85..db2e585d 100644 --- a/apps/api/scripts/db/seed-superuser.ts +++ b/apps/api/scripts/db/seed-superuser.ts @@ -33,7 +33,7 @@ async function main(): Promise { if (email === "" || password === "") { console.log( "[seed-superuser] SUPERUSER_EMAIL or SUPERUSER_PASSWORD unset — skipping. " + - "Set both to create a privileged user on first migrate, or sign up via the UI. " + + "Set both, then run `bun run db:seed`, to create a privileged user, or sign up via the UI. " + "Running under docker-compose? The api-migrate service interpolates these " + "from compose/.env (NOT apps/api/.env)." ); diff --git a/apps/api/src/config/env/schema.ts b/apps/api/src/config/env/schema.ts index 0581b0e5..f7971ffd 100644 --- a/apps/api/src/config/env/schema.ts +++ b/apps/api/src/config/env/schema.ts @@ -56,10 +56,11 @@ export const envSchema = t.Object({ NOTIFICATION_SETTINGS_URL: t.String({ default: "" }), /* - * Optional first-boot superuser bootstrap. When both are set, the migrate - * job creates this user with admin role on first run. Empty = no user - * created; the operator signs up via the registration flow or sets these - * and re-runs `bun run db:seed`. + * Optional superuser bootstrap, applied by `bun run db:seed`. When both are + * set, that run creates this user with admin role; an existing user is left + * alone. The k3s migration Job runs db:migrate only, so production applies + * this as an explicit one-off (infra/k3s/README.md). Empty = no user + * created; the operator signs up via the registration flow. */ SUPERUSER_EMAIL: t.String({ default: "" }), SUPERUSER_PASSWORD: t.String({ default: "" }), diff --git a/apps/docs/src/content/docs/runbooks/argocd-image-updater.mdx b/apps/docs/src/content/docs/runbooks/production-release.mdx similarity index 100% rename from apps/docs/src/content/docs/runbooks/argocd-image-updater.mdx rename to apps/docs/src/content/docs/runbooks/production-release.mdx From 7c137500c3e8f6ccc9a599ad709fe39f8413ddc6 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 8 Oct 2026 23:58:14 +0200 Subject: [PATCH 11/25] docs(k3s): describe the coordinated release and the migrate-only Job Replaces the image-updater runbook with a production releases runbook and updates the k3s overview and provisioning pages. The k3s README covers activation, digest rollback, the zone-scoped DNS-01 issuer pitfall, the migrate-only Job and the one-off superuser bootstrap. --- apps/docs/astro.config.mjs | 4 +- .../src/content/docs/infra/kubernetes.mdx | 15 +- .../docs/reference/product-validation.mdx | 3 +- .../docs/runbooks/production-release.mdx | 94 +++++------ .../docs/topics/provisioning-with-k3s.mdx | 48 +++--- infra/k3s/README.md | 154 +++++++++++++++--- 6 files changed, 220 insertions(+), 98 deletions(-) diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index cf849989..98bfe6a6 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -800,8 +800,8 @@ The full config surface is machine-readable at https://boringstack.xyz/scaffold- }, { label: "Codecov setup", link: "/runbooks/codecov-setup/" }, { - label: "ArgoCD image updater (k3s)", - link: "/runbooks/argocd-image-updater/", + label: "Production releases (k3s)", + link: "/runbooks/production-release/", }, { label: "Secrets backends (k3s)", diff --git a/apps/docs/src/content/docs/infra/kubernetes.mdx b/apps/docs/src/content/docs/infra/kubernetes.mdx index 3fa4fbaa..4f422fe6 100644 --- a/apps/docs/src/content/docs/infra/kubernetes.mdx +++ b/apps/docs/src/content/docs/infra/kubernetes.mdx @@ -8,8 +8,9 @@ description: Optional GitOps deployment target for any ArgoCD-managed k3s/Kubern paths. Pick the one that fits your operating model; you don't need all three. It ships BoringStack to any ArgoCD-managed k3s/Kubernetes cluster as GitOps: push -code, CI builds and pushes a GHCR image, argocd-image-updater bumps the tag, and -ArgoCD syncs. The whole app runs in one namespace with HA, autoscaling, and TLS. +code, one release workflow builds the API, migration and UI images and pins their +digests together, and ArgoCD syncs that set. The whole app runs in one namespace +with HA, autoscaling, and TLS. BoringStack is Compose-first on purpose, so this target lives in its own `infra/k3s/` subtree and is entirely opt-in. It never sits in the default deploy @@ -24,7 +25,7 @@ path. | Postgres | `base/postgres/` | CloudNativePG `Cluster`, 1 instance (3 in prod) | | Valkey | `base/valkey/` | in-namespace cache and BullMQ backend | | GlitchTip | `overlays/prod/glitchtip/` | per-project error tracking (web and worker) | -| migrations | `base/api/migration-job.yaml` | ArgoCD PreSync Job: `db:migrate && db:seed` | +| migrations | `base/api/migration-job.yaml` | ArgoCD Sync hook at wave -1: `db:migrate` only. Seeds are a deliberate one-off. | Routing mirrors Compose prod: one domain with same-origin path routing. `Host && (/api or /health)` goes to the api; everything else goes to the ui @@ -34,7 +35,7 @@ Routing mirrors Compose prod: one domain with same-origin path routing. ``` infra/k3s/ -├── argocd/ ArgoCD Application (+ image-updater) and registration example +├── argocd/ ArgoCD Application and registration example ├── base/ env-agnostic manifests: namespace, api, ui, valkey, postgres └── overlays/prod/ HA + TLS + secrets + GlitchTip + monitoring + patches └── secrets/ swappable backend: vault (default) | sealed | plain @@ -46,8 +47,8 @@ the TLS `Certificate`, GlitchTip, and the monitoring integration. ## Prerequisites -The cluster must provide ArgoCD + argocd-image-updater, the CloudNativePG -operator, cert-manager with a DNS-01 `ClusterIssuer`, Traefik (k3s default), and +The cluster must provide ArgoCD, the CloudNativePG operator, cert-manager with a +DNS-01 `ClusterIssuer` whose credential covers your zone, Traefik (k3s default), and a persistent StorageClass. Optionally kube-prometheus-stack (for the ServiceMonitor and Grafana dashboards) and your chosen secrets backend. @@ -80,7 +81,7 @@ and `infra/k3s/README.md`. ## Related - [Provisioning with k3s](/topics/provisioning-with-k3s/); zero to live on a cluster. -- [ArgoCD image updater](/runbooks/argocd-image-updater/); the auto-deploy loop. +- [Production releases](/runbooks/production-release/); the coordinated auto-deploy loop. - [Secrets backends (VSO / Sealed / plain)](/runbooks/vault-secrets-operator/). - [Postgres backups (CNPG)](/runbooks/cnpg-backups/). - [Infra overview](/infra/overview/); the Compose-first single-host target. diff --git a/apps/docs/src/content/docs/reference/product-validation.mdx b/apps/docs/src/content/docs/reference/product-validation.mdx index fb262b43..2c511e82 100644 --- a/apps/docs/src/content/docs/reference/product-validation.mdx +++ b/apps/docs/src/content/docs/reference/product-validation.mdx @@ -47,7 +47,8 @@ existing database contents. The development Compose migrator uses schema push and seeding. That does not test migration history. Validate generated migrations against a fresh owned database -before release; production uses `db:prepare` and committed migrations. +before release. Production applies committed migrations with `db:migrate` only; +seeds run as a deliberate one-off, never on every deploy. ## Recover interrupted verification diff --git a/apps/docs/src/content/docs/runbooks/production-release.mdx b/apps/docs/src/content/docs/runbooks/production-release.mdx index f144e4f4..2a6bbd67 100644 --- a/apps/docs/src/content/docs/runbooks/production-release.mdx +++ b/apps/docs/src/content/docs/runbooks/production-release.mdx @@ -1,63 +1,65 @@ --- -title: ArgoCD image updater -description: How the k3s target auto-deploys new builds via image-list, tag strategy, and git write-back, plus how to bootstrap and troubleshoot it. +title: Production releases (k3s) +description: How the k3s target ships api, migrations and ui together as one digest-pinned release, how to activate it, how migrations and seeds behave, and how to roll back. --- -The [k3s target](/infra/kubernetes/) closes the deploy loop with -[argocd-image-updater](https://argocd-image-updater.readthedocs.io/): when CI -pushes a new GHCR image, the updater rewrites the tag in -`overlays/prod/kustomization.yaml` and ArgoCD syncs it. All config lives in -annotations on `infra/k3s/argocd/boringstack-prod.yaml`. +The [k3s target](/infra/kubernetes/) deploys one coordinated release. It replaces +per-image auto-updating. `.github/workflows/production-release.yml` is the only +publisher for this target. It builds three candidate images for one commit, then +pins all three digests in `infra/k3s/overlays/prod/kustomization.yaml` in a +single commit. ArgoCD syncs that commit. -## The annotations +The per-image approach shipped the UI before the API image it needed existed. It +also produced a stream of `automatic update` commits. Full detail lives in +[`infra/k3s/README.md`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/k3s#images-and-the-coordinated-release). -```yaml -argocd-image-updater.argoproj.io/image-list: api=ghcr.io//-api, ui=ghcr.io//-ui -argocd-image-updater.argoproj.io/api.update-strategy: newest-build -argocd-image-updater.argoproj.io/api.allow-tags: regexp:^sha-[0-9a-f]{7,40}$ -argocd-image-updater.argoproj.io/write-back-method: git:secret:argocd/image-updater-git-creds -argocd-image-updater.argoproj.io/write-back-target: kustomization -argocd-image-updater.argoproj.io/git-branch: main -``` +## How a release runs -`image-list` pairs an alias with each image the updater watches. +1. Your push to `main` waits, up to 45 minutes, for every applicable CI workflow + at the same commit. Any missing, cancelled, failed or timed-out check blocks + the release. +2. The workflow builds `boringstack-api`, `boringstack-migrations` and + `boringstack-ui` as `candidate-` images. Nothing moves `latest` or + `sha-*` for this target. +3. Only after all three builds succeed does it pin the digests in one commit. A + newer `main` commit supersedes an older release. +4. Argo syncs in wave order: infrastructure, then the migration Job (wave -1), + then the API (wave 0), then the UI (wave 1) once the API is healthy. -`update-strategy: newest-build` plus the `^sha-` regex tracks the newest -`sha-` build. BoringStack's release workflows tag every push to main as -`latest` + `sha-` (and a semver tag on a `v*` release), so this matches -the `sha-` form and ignores `latest`. To deploy only tagged releases instead, -use `update-strategy: semver` with `allow-tags: regexp:^v?\d+\.\d+\.\d+$`. +A successful workflow means the digests were promoted. It does not mean Argo has +finished deploying. Check Synced and Healthy, then smoke-test sign-in and the +main screens. -`write-back-method: git` commits the tag bump back to your repo (the GitOps -source of truth) using the `argocd/image-updater-git-creds` secret. -`write-back-target: kustomization` edits the `images:` block rather than the -Application spec. +## Activation -## Bootstrap +1. Fresh fork: nothing to remove. +2. If an older Argo Application still has `argocd-image-updater` annotations, + remove them first. Otherwise a per-image updater can overwrite the coordinated + digests. +3. Set the repository variable `PRODUCTION_RELEASE_MODE=coordinated`. Without it, + the release fails before any image is built. +4. Allow the release bot to push the promotion commit to `main`. Do not disable + branch protection to make a release pass. -1. Install argocd-image-updater in the `argocd` namespace. -2. Create the git write-back credential it references: - ```bash - kubectl -n argocd create secret generic image-updater-git-creds \ - --from-literal=username= \ - --from-literal=password= - ``` - (Or an `sshPrivateKey` key for SSH write-back.) -3. Give it pull access to GHCR (a registry pull secret or `--registries-conf`) - so it can read tags from your private packages. +## Migrations and seeds -## Troubleshooting +The migration Job runs `db:migrate` only. It runs on every sync, so it must never +seed. `db:seed` upserts reference data and would revert admin edits. Run the first +admin bootstrap as a deliberate one-off. The steps are in +[`infra/k3s/README.md`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/k3s#migrations-and-seeds). +Its `db:seed` is idempotent: it creates the admin only when that user does not +exist. -No updates happen: check the image-updater pod logs and confirm the `allow-tags` -regex matches the tags CI actually publishes. +## Rollback -Updates detected but not committed: the git creds secret is missing or lacks -push rights to `main`. - -Tag bumped but the app didn't change: confirm the Application's `images:` block -names match `image-list` exactly (registry plus repo path). +Restore all three digests from one earlier promotion in a reviewed commit, then +sync the whole Application. Do not selectively sync Deployments. Migrations do not +reverse automatically, so confirm the older API works with the current schema +first. ## Related +- [Kubernetes (k3s) infra reference](/infra/kubernetes/) - [Provisioning with k3s](/topics/provisioning-with-k3s/) -- [Image updates (Compose / WUD)](/runbooks/image-updates/), the Compose-path equivalent. +- [Secrets backends](/runbooks/vault-secrets-operator/) +- [Image updates (Compose / WUD)](/runbooks/image-updates/), the Compose-path equivalent. Compose keeps its own `latest` publishers. diff --git a/apps/docs/src/content/docs/topics/provisioning-with-k3s.mdx b/apps/docs/src/content/docs/topics/provisioning-with-k3s.mdx index 1dd46441..0997ff49 100644 --- a/apps/docs/src/content/docs/topics/provisioning-with-k3s.mdx +++ b/apps/docs/src/content/docs/topics/provisioning-with-k3s.mdx @@ -1,15 +1,15 @@ --- title: Provisioning with k3s (GitOps) -description: Ship BoringStack to any ArgoCD-managed k3s/Kubernetes cluster. Register one Application, seed secrets, and every CI build auto-deploys via argocd-image-updater. +description: Ship BoringStack to any ArgoCD-managed k3s/Kubernetes cluster. Register one Application, seed secrets, and every main-branch release deploys the API, migrations and UI together. --- import { Aside } from "@astrojs/starlight/components"; The cluster deployment path. Where [OpenTofu](/topics/provisioning-with-tofu/) gives you a single VPS running docker-compose, this gives you a GitOps loop on a -Kubernetes cluster you already run: push code, CI publishes GHCR images, -argocd-image-updater bumps the tag, and ArgoCD syncs. HA, autoscaling, and TLS -come with it. +Kubernetes cluster you already run: push code, the release workflow publishes the +API, migration and UI images as one set, and ArgoCD syncs that set. HA, +autoscaling, and TLS come with it. Source lives in [`infra/k3s`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/k3s). @@ -24,16 +24,19 @@ Source lives in [`infra/k3s`](https://github.com/boringstack-xyz/boringstack/tre A k3s/Kubernetes cluster with these operators installed: -- ArgoCD + argocd-image-updater (with its `argocd/image-updater-git-creds` write-back secret) +- ArgoCD - the CloudNativePG operator -- cert-manager + a DNS-01-capable `ClusterIssuer` +- cert-manager + a DNS-01-capable `ClusterIssuer` whose credential covers your zone - Traefik (ships with k3s) exposing `web` + `websecure` entrypoints - a default/persistent StorageClass - optional: kube-prometheus-stack (metrics and dashboards), and a secrets backend (Vault + VSO by default, or the SealedSecrets controller) -You'll also need CI that builds and pushes `ghcr.io//-api` and -`-ui` images, the same pipeline the Compose/WUD path uses. +You'll also need the release workflow (`production-release.yml`), which builds and +digest-pins `ghcr.io//-api`, `-migrations` and `-ui`. Set the +repository variable `PRODUCTION_RELEASE_MODE=coordinated` once the cluster is +ready. The [production releases runbook](/runbooks/production-release/) has the +activation steps. ## 1. Rebrand and set the knobs @@ -48,7 +51,7 @@ manifests. Then edit the handful of cluster-specific values (all listed in `infra/k3s/README.md`): - the domain in `overlays/prod/ingress.yaml`, `certificate.yaml`, `glitchtip/` -- the `ClusterIssuer` name in `overlays/prod/certificate.yaml` +- the `ClusterIssuer` name in `overlays/prod/certificate.yaml` (its credential must cover every domain in `dnsNames`) - the `StorageClass` in `base/postgres/cluster.yaml` and `base/valkey/statefulset.yaml` (optional; defaults to the cluster default) - `source.repoURL` in `argocd/boringstack-prod.yaml` @@ -80,19 +83,21 @@ app-of-apps repo. It points ArgoCD at your fork's `infra/k3s/argocd` directory. ```mermaid flowchart LR push["git push
(app code)"] - ci["CI builds + pushes
GHCR images"] - iu["argocd-image-updater
bumps kustomization tag"] + ci["CI passes for the commit"] + build["Build api, migrations, ui
as candidates"] + pin["One commit pins
all three digests"] argo["ArgoCD syncs
overlays/prod"] - presync["PreSync Job
db:migrate + db:seed"] - k8s["Rolling deploy
api · ui · GlitchTip"] + mig["Sync hook, wave -1
db:migrate only"] + k8s["Rolling deploy
api, then ui, GlitchTip"] - push --> ci --> iu --> argo --> presync --> k8s + push --> ci --> build --> pin --> argo --> mig --> k8s ``` -ArgoCD renders the prod overlay (base + secrets component + HA patches), runs the -PreSync migration Job, then rolls out the Deployments. CloudNativePG manages -Postgres, cert-manager issues the TLS cert, and the Vault Secrets Operator (or -your chosen backend) materializes the app secrets. +ArgoCD renders the prod overlay (base + secrets component + HA patches) and syncs +in wave order: infrastructure first, then the migration Job, then the API, then +the UI once the API is healthy. CloudNativePG manages Postgres, cert-manager +issues the TLS cert, and the Vault Secrets Operator (or your chosen backend) +materializes the app secrets. ## 4. Verify @@ -102,8 +107,9 @@ curl -sI https:///health # 200 from the api curl -sI https:/// # 200 from the ui (SPA) ``` -In ArgoCD the Application should be Synced and Healthy, and a new commit's image -should auto-roll within an image-updater poll interval. +In ArgoCD the Application should be Synced and Healthy. Each promoted release +should appear there as one sync, with the API, migration and UI digests changing +together. ### First-deploy checks @@ -126,6 +132,6 @@ deploy a second one. ## Related - [Kubernetes (k3s) infra reference](/infra/kubernetes/) -- [ArgoCD image updater](/runbooks/argocd-image-updater/) +- [Production releases](/runbooks/production-release/) - [Secrets backends](/runbooks/vault-secrets-operator/) - [Postgres backups (CNPG)](/runbooks/cnpg-backups/) diff --git a/infra/k3s/README.md b/infra/k3s/README.md index ab31e2d9..12101bf9 100644 --- a/infra/k3s/README.md +++ b/infra/k3s/README.md @@ -5,8 +5,9 @@ k3s/Kubernetes cluster. This is the cluster alternative to the single-host [`infra/compose`](../compose) and [`infra/bootstrap`](../bootstrap) targets. Pick the one that fits; you don't need all three. -Push code, CI builds and pushes GHCR images, argocd-image-updater bumps the tag, -and ArgoCD syncs. The full app (api, ui, Postgres, Valkey, GlitchTip) runs in one +Push code. The production release workflow builds the API, migrations and UI +images for that commit, then pins all three digests in one commit. ArgoCD syncs +that set. The full app (api, ui, Postgres, Valkey, GlitchTip) runs in one namespace with HA, autoscaling, and TLS. > This target is Compose-first BoringStack's opt-in cluster path. It is kept in @@ -17,10 +18,11 @@ namespace with HA, autoscaling, and TLS. ``` infra/k3s/ -├── argocd/ ArgoCD Application (+ image-updater) and registration example +├── argocd/ ArgoCD Application and registration example ├── base/ env-agnostic manifests: namespace, api, ui, valkey, postgres -└── overlays/prod/ HA + TLS + secrets + GlitchTip + monitoring + patches - └── secrets/ swappable secrets backend: vault (default) | sealed | plain +├── overlays/prod/ HA + TLS + secrets + GlitchTip + monitoring + patches +│ └── secrets/ swappable secrets backend: vault (default) | sealed | plain +└── tests/ release invariants, run against the real prod render ``` ## Cluster prerequisites @@ -29,7 +31,7 @@ The target cluster must provide these (all common, operator-installable): | Need | Used for | |------|----------| -| ArgoCD + argocd-image-updater | GitOps sync and auto image bumps | +| ArgoCD | GitOps sync and wave-ordered rollout | | CloudNativePG operator | the Postgres `Cluster` | | cert-manager + a DNS-01 `ClusterIssuer` | TLS certs | | Traefik (k3s default) with `web`/`websecure` entrypoints | ingress | @@ -46,26 +48,39 @@ The target cluster must provide these (all common, operator-installable): `glitchtip.boringstack.example.com`) in `overlays/prod/ingress.yaml`, `certificate.yaml`, and `glitchtip/`. 3. ClusterIssuer. Set `issuerRef.name` in `overlays/prod/certificate.yaml` to - your cluster's DNS-01 issuer (`kubectl get clusterissuer`). + the DNS-01 issuer for your zone (`kubectl get clusterissuer`). See + [the issuer pitfall](#clusterissuer-pitfall) below. 4. StorageClass. Uncomment/set `storageClass` in `base/postgres/cluster.yaml` and `base/valkey/statefulset.yaml` if you don't want the cluster default. 5. Repo URL. Set `source.repoURL` in `argocd/boringstack-prod.yaml` to your fork, and add it to ArgoCD as a repo credential. -6. Image tag strategy. `argocd/boringstack-prod.yaml` assumes CI tags images with - the git SHA. If your CI uses semver, switch the `update-strategy` / - `allow-tags` annotations. +6. Public UI settings. `VITE_PUBLIC_URL`, `VITE_SENTRY_DSN` and + `VITE_VAPID_PUBLIC_KEY` are GitHub repository variables read by + `.github/workflows/production-release.yml`. No domain is hard-coded there. + +### ClusterIssuer pitfall + +A `ClusterIssuer` carries one DNS-01 credential, usually scoped to one zone. If +the Certificate's `dnsNames` include a zone that credential cannot write, the +`_acme-challenge` TXT record is never created. The Certificate then stays +`Ready=False` forever, and Traefik keeps serving its self-signed default +certificate. Cloudflare tokens are per account, so two zones in two accounts +need two issuers. Pick the issuer whose token covers every `dnsName`, and check +with `kubectl describe certificate` and the challenge events before the first +sync. ## Secrets Workloads only ever read two k8s Secrets, `boringstack-secrets` (app env) and `ghcr-registry-secret` (GHCR pull), plus the CNPG-generated `boringstack-db-app` -(`DATABASE_URL`). How those get populated is the swappable component in -`overlays/prod/secrets/`: +(`DATABASE_URL`) and `boringstack-db-ca` (the CNPG CA, `ca.crt`). How those get +populated is the swappable component in `overlays/prod/secrets/`: - `vault` (default). Vault + Vault Secrets Operator. Seed KV-v2 paths `secret/boringstack`, `secret/boringstack-registry` (and `secret/boringstack-backup` for backups), and bind a Vault role `boringstack-prod-role` to the - `boringstack-prod/default` ServiceAccount. + `boringstack-prod/default` ServiceAccount. The VSO secrets set `excludeRaw`, so + the `_raw` Vault response is not added as one JSON env var. - `sealed`. Bitnami SealedSecrets (commit encrypted manifests). See its README. - `plain`. Kustomize `secretGenerator` over a gitignored `secret.env`. See its README. @@ -80,6 +95,9 @@ Populate from there rather than copying a list that drifts. The k3s-specific points: - `DATABASE_URL` is injected by CloudNativePG, so it is NOT a key here. +- `DATABASE_SSL_CA` is injected from `boringstack-db-ca`, so it is not a key here + either. The API and the migration Job verify the database certificate against + it. Leave `DATABASE_SSL_REJECT_UNAUTHORIZED` at its default (`true`). - `VALKEY_PASSWORD` is shared: the api, GlitchTip, and the Valkey StatefulSet's `--requirepass` all read it. - The GlitchTip overlay needs `GLITCHTIP_SECRET_KEY` plus `GLITCHTIP_SUPERUSER_EMAIL` @@ -88,19 +106,98 @@ points: `FRONTEND_URL`, `PUBLIC_API_URL`, `QUEUES_ENABLED=true`, `CACHE_PROVIDER=valkey`. Email, OAuth, Stripe, Web Push, and AI keys follow the reference. +## Images and the coordinated release + +`.github/workflows/production-release.yml` is the only publisher of images for +this target. On every push to `main` that touches the app or this target, it: + +1. Waits, up to 45 minutes, for every applicable push workflow at the same + commit to succeed. Missing, cancelled, failed and timed-out checks block the + release. A newer `main` commit supersedes an older one. +2. Builds three **candidate** images for that commit: `boringstack-api`, + `boringstack-migrations` and `boringstack-ui`, tagged `candidate-`. + Nothing moves `latest` or `sha-*` for this target. +3. Only after all three builds succeed, makes **one** commit that pins all three + digests in `overlays/prod/kustomization.yaml`. A failed or interrupted build + leaves production unchanged. A non-fast-forward push fails without retry. + +The Compose/WUD path is unchanged. `apps-api-release.yml` and +`apps-ui-release.yml` still publish `latest` and `sha-*` for WUD. Kubernetes never +references those tags, so a partial Compose publish cannot reach this target. + +### Activation (required before the first release) + +1. Fresh fork: nothing to remove. +2. Existing Argo Application that still has `argocd-image-updater.argoproj.io/*` + annotations, or an image-updater watching these images: remove those + annotations and the updater's write-back for this app first. Otherwise it can + overwrite the coordinated digests with per-image tags. Let any running + per-image release finish, and record the current digests for rollback. +3. Set the GitHub repository variable `PRODUCTION_RELEASE_MODE=coordinated`. + Without it, the release fails before any image is built. The variable is an + operator assertion, not a live check. +4. Repository policy must allow `github-actions[bot]` to push the promotion + commit to `main`. If branch protection blocks it, promotion fails closed. + Do not disable protection to make a release pass. + +### Release contract and rollback + +- Argo performs a **full application sync** in wave order: infrastructure at + -4/-2, the migration Job at -1, the API at 0, the UI at 1 once the API is + healthy. Do not selectively sync Deployments: that bypasses the migration hook + and wave ordering. +- A successful workflow means the digests were promoted, not that Argo has + finished. Check Synced/Healthy, then smoke-test sign-in and the main screens. +- API and database changes must stay compatible with the previous UI during the + rollout. +- To roll back application images, restore **all three digests from one previous + promotion** in a reviewed commit, then sync the whole Application. +- Migrations are not reversed automatically. Confirm the previous API works with + the current schema before rolling back. + +## Migrations and seeds + +The migration Job runs `bun run db:migrate` (drizzle-kit migrate) and nothing +else. It is an Argo `Sync` hook at wave -1, so it runs on **every** sync, and any +data it writes lands on a live database. For that reason: + +- The Job never runs `db:seed` or `db:prepare`. `db:seed` upserts reference + data, so it would revert admin edits on each sync. A test enforces this. +- Reference data that the product needs must be insert-if-missing, and it is + applied deliberately, not on every sync. +- The first administrator is a one-off, and it is idempotent. Run it once with + the migrations image from the same release. Use the candidate tag for the + promoted commit (the promotion commit names it): + + ```bash + SHA= + kubectl -n boringstack-prod run superuser-bootstrap --rm -i --restart=Never \ + --image=ghcr.io/boringstack-xyz/boringstack-migrations:candidate-$SHA \ + --overrides='{"spec":{"serviceAccountName":"api","nodeSelector":{"kubernetes.io/arch":"amd64"},"imagePullSecrets":[{"name":"ghcr-registry-secret"}],"containers":[{"name":"superuser-bootstrap","image":"ghcr.io/boringstack-xyz/boringstack-migrations:candidate-'"$SHA"'","envFrom":[{"secretRef":{"name":"boringstack-secrets"}}],"env":[{"name":"NODE_ENV","value":"production"},{"name":"DATABASE_URL","valueFrom":{"secretKeyRef":{"name":"boringstack-db-app","key":"uri"}}},{"name":"DATABASE_SSL_CA","valueFrom":{"secretKeyRef":{"name":"boringstack-db-ca","key":"ca.crt"}}}]}]}}' \ + -- bun run db:seed + ``` + + It needs `SUPERUSER_EMAIL` and `SUPERUSER_PASSWORD` in `boringstack-secrets`. + It creates the admin only if that user does not exist. Without those values it + does nothing, and users can sign up instead. +- A from-scratch rebuild of reference data is also a one-off run of the seed + command, never a change to the Job. + ## Deploy 1. Rebrand and edit the knobs above. -2. Ensure CI builds and pushes `ghcr.io//-api` and `-ui`. The UI - build must pass the `VITE_*` build args (`VITE_API_URL` empty for same-origin, - `VITE_SENTRY_DSN` set to your GlitchTip DSN). This is the same image pipeline - the Compose/WUD path uses. -3. Populate secrets via your chosen backend. -4. Register with ArgoCD, either: +2. Populate secrets via your chosen backend. +3. Register with ArgoCD, either: - `kubectl apply -f infra/k3s/argocd/boringstack-prod.yaml`, or - add `argocd/app-of-apps-registration.example.yaml` to your app-of-apps repo. -5. ArgoCD runs the PreSync migration Job (`db:migrate && db:seed`), then rolls - out the deployments. Watch it reach Healthy. +4. Complete [activation](#activation-required-before-the-first-release). +5. Merge to `main`. The release workflow publishes the three candidates and + promotes them in one commit. Argo then syncs. Watch the Application reach + Healthy. +6. Run the one-off superuser bootstrap if you want a first admin. + +Startup order is set by waves: namespace and secrets (-4), Postgres and Valkey +(-2), migrations (-1), API (0), UI (1). ## Verify @@ -108,12 +205,27 @@ points: # Structural render (no cluster needed) kubectl kustomize infra/k3s/overlays/prod | head +# Release invariants on the real render (digest pins, no image-updater, +# migrate-only Job, read-only API, CA wiring, network policy, release script) +bun test infra/k3s/tests scripts/release + # Live kubectl -n boringstack-prod get pods curl -sI https:///health # 200 from the api curl -sI https:/// # 200 from the ui (SPA) ``` +## Runtime notes + +- The API image runs on a read-only root filesystem. Its CMD is + `bun --no-install run dist/index.js`, so bun never tries to install packages at + startup (that attempt fails with `EROFS`). Only `/tmp` (256 MiB) is writable, + and Bun's transpiler cache lives there. +- The API and the migration Job share one TLS policy for Postgres + (`apps/api/src/lib/postgres/connection.ts`), driven by `DATABASE_SSL_CA`. Keep + TLS settings in `DATABASE_SSL_*`, not in `sslmode` on the URL. +- Valkey uses AOF with `everysec` fsync so queued BullMQ jobs survive a restart. + ## What's intentionally not here - In-cluster Prometheus/Grafana/Loki. Integrate with the cluster's stack From be7a020785dce77f7d7320dd4bce8425a1527efa Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 9 Oct 2026 00:02:27 +0200 Subject: [PATCH 12/25] docs(maintenance): record the second Tinkercaster report in the feedback ledger --- docs/maintenance/product-feedback.md | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/maintenance/product-feedback.md b/docs/maintenance/product-feedback.md index 1d6518d4..dc9d2259 100644 --- a/docs/maintenance/product-feedback.md +++ b/docs/maintenance/product-feedback.md @@ -94,3 +94,33 @@ agent-built product. Existing security and coverage gates remain enforced. | 81 | Wins. | Preserve: reported win; no corrective change requested. | | 82 | `component-folder-structure` treats every `const X: FC` in a folder as a component needing its own siblings. | Implemented upstream: `eslint-plugin-react-component-architecture` 0.3.1 exempts `.tsx` files that export nothing; the anatomy applies once a file gains an export (boringstack-xyz/eslint-plugins#15). Component anatomy guide updated. | | 83 | Wins. | Preserve: reported win; no corrective change requested. | + +## Report 2: Tinkercaster in production, 2026-09-14 to 2026-10-08 + +Source: the Tinkercaster git history and its `docs/operations`, `docs/audits` +and `docs/reviews` after the first report. The product went to production on +k3s, added billing, a public site and an LLM assistant, and stopped taking +template updates around 2026-09-15. Nothing from this period was reported back +until this review, so the entries come from commits, not from a written +report. The same rules apply: an entry is closed by a reproduction, a +correction or a documented scope decision. + +| Report | Observation | Status / evidence | +| ------ | ----------- | ----------------- | +| 84 | argocd-image-updater bumped api and ui independently; the UI shipped before its API image existed, and one day produced 28 automatic update commits. | Implemented: `production-release.yml` builds api, migrations and ui as `candidate-` after CI passes at that commit, then pins all three digests in one commit; image-updater annotations removed; sync waves order config, data, migrations, api, ui. `scripts/release/release.test.ts`, `infra/k3s/tests/deployment.test.ts`. Activation needs `PRODUCTION_RELEASE_MODE=coordinated`. | +| 85 | The migration Job was a PreSync hook that ran `db:seed` on every sync and reverted admin edits. | Implemented: Sync hook at wave -1 runs `db:migrate` only, from a migrations image, with no app secrets mounted. The superuser bootstrap is a documented one-off in `infra/k3s/README.md`. | +| 86 | Migrations (drizzle-kit, node-postgres) did not verify the CNPG certificate while the runtime (postgres-js) did, so migrations passed and the app was refused. | Implemented: `src/lib/postgres/connection.ts` is the single TLS policy for the runtime client and `drizzle.config.ts`; both receive `DATABASE_SSL_CA` from `boringstack-db-ca`. `tests/lib/postgres/connection.test.ts`. | +| 87 | The API crashed with EROFS on a read-only root filesystem because bun tried to install at startup. | Implemented: `bun --no-install`, a `/tmp` emptyDir with `TMPDIR`, and `readOnlyRootFilesystem: true` on the API and migrations; asserted by the deployment test. Smoke-tested with `docker run --read-only`. | +| 88 | Every Vault key arrived in one env var. | Implemented: VSO `excludeRaw` on the app and registry secrets. | +| 89 | Migration pods could not reach Postgres. | Implemented: the Postgres network policy admits `app: api-migrations`. | +| 90 | The DNS-01 ClusterIssuer covered another zone. | Documented: `overlays/prod/certificate.yaml` and the k3s README. | +| 91 | The public site built inside the container at start and crash-looped on an empty config value. | Scope: the template ships no public site app. Guidance is to build in CI and validate config at build time; open until a site app exists. | +| 92 | Accepting test inventories was a third of the first three days' commits, then was skipped and the protection lapsed. | Implemented: feature verification records additions-only inventory changes itself; removals, renames and empty runs still block and print one acknowledgement command. `tools/agent/inventory.test.ts`. Open: CI does not compare observed cases with the committed inventories. | +| 93 | `packages/wiring-engine` and `apps/site` sat outside every gate; tooling hard-coded the app list. | Implemented: root check and pre-push validate each `packages/*`, pre-push discovers `apps/*`, dependency scans skip missing apps, Dependabot covers packages; `apps/ui/docs/agents/shared-packages.md`. Open: lint-meta does not scan `packages/`. | +| 94 | `.size-limit.json` was raised in 30 commits (CSS 12 KB to 26.5 KB) despite the per-feature review policy (12, 29, 44). | Implemented: `check:size-budget` fails a raise or a new entry without a new reason line in `apps/ui/budgets.md`; runs in validate, pre-push and CI. `tests/scripts/check-size-budget-raises.test.ts`. | +| 95 | After the agent harness changed, conventional commit subjects fell from 98% to 13% and the agent guides were not updated again. | Implemented: `commit-msg` hook and `pr-title` workflow share `scripts/ci/check-commit-title.sh`; squash title is the PR title; AGENTS.md states the harness-neutral rule. `tools/agent/commit-title.test.ts`. | +| 96 | Generated wiring-library content made the largest PRs (+544k lines in one). | Documented: AGENTS.md keeps generated artifacts out of hand-written PRs. | +| 97 | The product moved CI to a self-hosted runner through a `CI_RUNNER` variable. | Open: needs a decision. Self-hosted runners on `pull_request` workflows run fork code, so the template should not offer it without a public-repo guard. | +| 98 | Database restore has never been exercised. | Open: no restore drill in the template. | +| 99 | A release was blocked by an e2e asserting heading copy. | Open: needs a product fixture before any lint or guide change. | +| 100 | Found in this review: in Compose production the API enables TLS for Postgres, but the Compose Postgres has no TLS, and postgres-js refuses a server that declines TLS. Migrations now share the runtime policy, so they fail the same way. | Open: reproduce with the `prod` profile, then either give Compose Postgres TLS or make the policy explicit per target. | From e733a297e465491d75f7f5874d16515fb7145dda Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 9 Oct 2026 00:07:20 +0200 Subject: [PATCH 13/25] feat(api): lint-meta rejects vars.CI_RUNNER without the fork guard --- apps/api/scripts/lint-meta/RULES.md | 2 +- .../rules/ci/github-actions-runner-pinned.ts | 21 +++++++++++++++- apps/api/tests/lint-meta/lint-meta.test.ts | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 81f53b55..eea8692e 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -25,7 +25,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `github-actions-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | | `github-actions-pip-install-pinned` | ci | no | Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases. | -| `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels. | +| `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels, and guard vars.CI_RUNNER against fork pull requests. | | `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | | `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | | `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts index a916d604..76325085 100644 --- a/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts @@ -5,6 +5,7 @@ import type { IMetaRule, IViolation } from "../../types"; const RULE_ID = "github-actions-runner-pinned"; const RUNS_ON_REGEX = /^\s*runs-on:\s*(?