From d15daee76f27fd8530b8c8e9f800a40844fef7f4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 13:49:56 +0200 Subject: [PATCH 1/7] fix(build): pin ui Dockerfile.prod builder base image by sha256 digest Adds a dockerfile-base-image-sha-pin lint-meta rule to both apps so every Dockerfile FROM must carry an @sha256 digest (stage aliases and scratch exempt), surfaces the unpinned ui builder stage through the rule, then pins it to the same oven/bun digest used by apps/api. Audit: F001 --- apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../rules/ci/dockerfile-base-image-sha-pin.ts | 64 +++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 49 ++++++++++++++ apps/ui/Dockerfile.prod | 2 +- apps/ui/scripts/lint-meta/RULES.md | 1 + apps/ui/scripts/lint-meta/cli.ts | 1 + apps/ui/scripts/lint-meta/registry.ts | 2 + .../rules/ci/dockerfile-base-image-sha-pin.ts | 64 +++++++++++++++++++ apps/ui/tests/lint-meta/lint-meta.test.ts | 44 +++++++++++++ 11 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 apps/api/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts create mode 100644 apps/ui/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 18d7d0c6..36ad2311 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -23,6 +23,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | | `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | | `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | +| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | | `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | | `env-no-direct-process-env` | env | no | Single entry point for env: every source file outside validate.ts must import the typed `env` object instead of reading `process.env` directly. | | `generated-artifact-contract` | artifacts | no | Sibling apps/ui generated ACL and OpenAPI files must carry required banner text. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 698ab05c..9e7cb76f 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -27,6 +27,7 @@ import { checkEslintOverridePathsExist } from "./rules/config/eslint-override-pa import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift"; import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env"; import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract"; +import { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; @@ -93,6 +94,7 @@ export { parseTypeboxEnvSchemaKeys as parseEnvSchemaKeys } from "./parsers/typeb export { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkDockerfileBaseImageShaPin, checkExactDependencyVersions, checkEslintConfigNoWarn, checkEslintOverridePathsExist, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 044e19c0..2a8ebc0c 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -1,4 +1,5 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artifact-contract"; +import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; @@ -29,6 +30,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsTimeoutRequiredRule, prePushCiParityRule, enginePinParityRule, + dockerfileBaseImageShaPinRule, envCascadeDriftRule, noDirectProcessEnvRule, generatedArtifactContractRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts b/apps/api/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts new file mode 100644 index 00000000..7a777a8a --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const DOCKERFILES = ["Dockerfile", "Dockerfile.prod"]; + +export function checkDockerfileBaseImageShaPin(root: string): IViolation[] { + const violations: IViolation[] = []; + + for (const dockerfile of DOCKERFILES) { + const dockerPath = join(root, dockerfile); + + if (!existsSync(dockerPath)) { + continue; + } + + const lines = readFileSync(dockerPath, "utf8").split("\n"); + const stageAliases = new Set(); + + for (const [index, line] of lines.entries()) { + const fromMatch = + /^\s*FROM\s+(?\S+)(?:\s+AS\s+(?\S+))?/iu.exec(line); + const image = fromMatch?.groups?.image; + + if (image === undefined) { + continue; + } + + const normalized = image.toLowerCase(); + const referencesEarlierStage = + normalized === "scratch" || stageAliases.has(normalized); + + const alias = fromMatch?.groups?.alias; + + if (alias !== undefined) { + stageAliases.add(alias.toLowerCase()); + } + + if (referencesEarlierStage || image.includes("@sha256:")) { + continue; + } + + violations.push({ + file: dockerPath, + rule: "dockerfile-base-image-sha-pin", + message: `FROM ${image} (line ${String(index + 1)}) must pin the base image by @sha256 digest.`, + }); + } + } + + return violations; +} + +/** Every Dockerfile FROM must pin its base image by digest. */ +export const dockerfileBaseImageShaPinRule: IMetaRule = { + id: "dockerfile-base-image-sha-pin", + category: "ci", + description: + "Dockerfile base images must be pinned by @sha256 digest, not tag alone.", + run({ root }) { + return checkDockerfileBaseImageShaPin(root); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 36be7565..2d5b2f7f 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -16,6 +16,7 @@ import { renderRulesMd } from "../../scripts/lint-meta/generate-rules-md"; import { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkDockerfileBaseImageShaPin, checkEnvSchemaDrift, checkEslintConfigNoWarn, checkEslintOverridePathsExist, @@ -310,6 +311,54 @@ describe("checkWorkflowTimeouts", () => { }); }); +describe("checkDockerfileBaseImageShaPin", () => { + test("flags a FROM tag without a digest", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-dockerpin-")); + + try { + writeFileSync( + join(root, "Dockerfile.prod"), + "FROM oven/bun:1.3.14-alpine AS builder\n" + ); + + const violations = checkDockerfileBaseImageShaPin(root); + + expect(violations.map((row) => row.rule)).toContain( + "dockerfile-base-image-sha-pin" + ); + expect( + violations.some((row) => + row.message.includes("oven/bun:1.3.14-alpine (line 1)") + ) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes digest-pinned images and skips earlier stage aliases", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-dockerpin-")); + + try { + writeFileSync( + join(root, "Dockerfile.prod"), + [ + "FROM oven/bun:1.3.14-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS builder", + "FROM builder AS assets", + "FROM oven/bun:1.3.14-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS production", + "", + ].join("\n") + ); + + const violations = checkDockerfileBaseImageShaPin(root); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkEnvSchemaDrift", () => { test("aligned schema and .env.example produces no violations", () => { const violations = checkEnvSchemaDrift(join(FIXTURES, "env-cascade-clean")); diff --git a/apps/ui/Dockerfile.prod b/apps/ui/Dockerfile.prod index 5d0e382d..d2a3c00d 100644 --- a/apps/ui/Dockerfile.prod +++ b/apps/ui/Dockerfile.prod @@ -3,7 +3,7 @@ # the top + cache mounts for `bun install`; we keep the portable version.) # ---- Builder --------------------------------------------------------------- -FROM oven/bun:1.3.14-alpine AS builder +FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS builder WORKDIR /app diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 3e3e86f9..6a1b1957 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -21,6 +21,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | | `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | | `engine-pin-parity` | ci | no | Node and Bun version pins must stay aligned across .nvmrc, package.json, Docker, and CI. | +| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | | `env-cascade-drift` | env | no | Vite env keys must align across schema.ts, .env.example, and vite-env.d.ts. | | `env-no-direct-import-meta-env` | env | no | Single entry point for env: every source file outside env.loader.ts must import the typed `env` object instead of reading `import.meta.env` directly. | | `generated-artifact-contract` | artifacts | no | Generated ACL types and OpenAPI schema files must exist with required banner text. | diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 2e04afcd..5e8c571e 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -72,6 +72,7 @@ export { collectSourceFiles, findWorkflows } from "./context"; export { parseDotenvKeys } from "./parsers/dotenv"; export { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; export { checkPackageJson } from "./rules/supply-chain/package-json-exact-deps"; +export { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; export { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; export { checkWorkflow } from "./rules/ci/github-actions-permissions"; export { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; diff --git a/apps/ui/scripts/lint-meta/registry.ts b/apps/ui/scripts/lint-meta/registry.ts index 7a93b6fc..4767e276 100644 --- a/apps/ui/scripts/lint-meta/registry.ts +++ b/apps/ui/scripts/lint-meta/registry.ts @@ -1,5 +1,6 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artifact-contract"; import { modulepreloadSizeLimitRule } from "./rules/artifacts/modulepreload-size-limit"; +import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; @@ -29,6 +30,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsTimeoutRequiredRule, prePushCiParityRule, enginePinParityRule, + dockerfileBaseImageShaPinRule, // --- env --- envCascadeDriftRule, noDirectImportMetaEnvRule, diff --git a/apps/ui/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts b/apps/ui/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts new file mode 100644 index 00000000..9486f25e --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/ci/dockerfile-base-image-sha-pin.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const DOCKERFILES = ["Dockerfile", "Dockerfile.prod"]; + +export function checkDockerfileBaseImageShaPin(root: string): IViolation[] { + const violations: IViolation[] = []; + + for (const dockerfile of DOCKERFILES) { + const dockerPath = join(root, dockerfile); + + if (!existsSync(dockerPath)) { + continue; + } + + const lines = readFileSync(dockerPath, "utf8").split("\n"); + const stageAliases = new Set(); + + for (const [index, line] of lines.entries()) { + const fromMatch = + /^\s*FROM\s+(?\S+)(?:\s+AS\s+(?\S+))?/iu.exec(line); + const image = fromMatch?.groups?.image; + + if (image === undefined) { + continue; + } + + const normalized = image.toLowerCase(); + const referencesEarlierStage = + normalized === "scratch" || stageAliases.has(normalized); + + const alias = fromMatch?.groups?.alias; + + if (alias !== undefined) { + stageAliases.add(alias.toLowerCase()); + } + + if (referencesEarlierStage || image.includes("@sha256:")) { + continue; + } + + violations.push({ + file: dockerPath, + rule: "dockerfile-base-image-sha-pin", + message: `FROM ${image} (line ${String(index + 1)}) must pin the base image by @sha256 digest.` + }); + } + } + + return violations; +} + +/** Every Dockerfile FROM must pin its base image by digest. */ +export const dockerfileBaseImageShaPinRule: IMetaRule = { + id: "dockerfile-base-image-sha-pin", + category: "ci", + description: + "Dockerfile base images must be pinned by @sha256 digest, not tag alone.", + run({ root }) { + return checkDockerfileBaseImageShaPin(root); + } +}; diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 031e3923..612f0048 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -13,6 +13,7 @@ import { describe, expect, test } from "vitest"; import { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkDockerfileBaseImageShaPin, checkEnginePinParity, checkForbiddenText, checkNoCrossRepoImports, @@ -594,6 +595,49 @@ describe("checkEnginePinParity", () => { }); }); +describe("checkDockerfileBaseImageShaPin", () => { + test("flags a FROM tag without a digest", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-dockerpin-")); + + try { + writeFileSync( + join(root, "Dockerfile.prod"), + "FROM oven/bun:1.3.14-alpine AS builder\n" + ); + + const violations = checkDockerfileBaseImageShaPin(root); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("oven/bun:1.3.14-alpine (line 1)") + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes digest-pinned images and skips earlier stage aliases", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-dockerpin-")); + + try { + writeFileSync( + join(root, "Dockerfile.prod"), + [ + "FROM oven/bun:1.3.14-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS builder", + "FROM builder AS assets", + "FROM nginx:1.31-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000 AS runner", + "" + ].join("\n") + ); + + const violations = checkDockerfileBaseImageShaPin(root); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkPrePushParity", () => { test("flags a malformed manifest instead of silently skipping", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-prepush-")); From 078571c641b6dc027cac9b392fcd30fcb83abdf5 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 13:57:18 +0200 Subject: [PATCH 2/7] test(api): cover engine-pin-parity lint-meta rule directly Exports checkEnginePinParity from the lint-meta cli like its sibling checks and adds a four-scenario suite (missing engines.bun, Dockerfile drift, CI workflow drift, aligned) so the parity guardrail is itself guarded. Audit: F003 --- apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/tests/lint-meta/lint-meta.test.ts | 105 +++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 9e7cb76f..5b19a1f9 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -28,6 +28,7 @@ import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift"; import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env"; import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract"; import { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; +import { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; @@ -95,6 +96,7 @@ export { checkCanonicalHelpersSingleHome, checkDependencyPairs, checkDockerfileBaseImageShaPin, + checkEnginePinParity, checkExactDependencyVersions, checkEslintConfigNoWarn, checkEslintOverridePathsExist, diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 2d5b2f7f..0a3d4461 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -17,6 +17,7 @@ import { checkCanonicalHelpersSingleHome, checkDependencyPairs, checkDockerfileBaseImageShaPin, + checkEnginePinParity, checkEnvSchemaDrift, checkEslintConfigNoWarn, checkEslintOverridePathsExist, @@ -359,6 +360,110 @@ describe("checkDockerfileBaseImageShaPin", () => { }); }); +describe("checkEnginePinParity", () => { + function writeEnginePinFixture( + root: string, + options: { + engines?: { bun?: string }; + dockerBun: string; + workflowBun: string; + } + ): void { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ engines: options.engines }) + ); + writeFileSync( + join(root, "Dockerfile"), + `FROM oven/bun:${options.dockerBun}-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n` + ); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(root, ".github", "workflows", "ci.yml"), + `jobs:\n test:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: ${options.workflowBun}\n` + ); + } + + test("flags a missing engines.bun pin", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + writeEnginePinFixture(root, { + dockerBun: "1.3.14", + workflowBun: "1.3.14", + }); + + const violations = checkEnginePinParity(root); + + expect( + violations.some((row) => row.message.includes("engines.bun")) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("flags a Dockerfile bun tag that drifts from engines.bun", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + writeEnginePinFixture(root, { + engines: { bun: "1.3.14" }, + dockerBun: "1.2.0", + workflowBun: "1.3.14", + }); + + const violations = checkEnginePinParity(root); + + expect( + violations.some((row) => + row.message.includes("Dockerfile must pin oven/bun:1.3.14") + ) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("flags a CI workflow bun-version that drifts from engines.bun", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + writeEnginePinFixture(root, { + engines: { bun: "1.3.14" }, + dockerBun: "1.3.14", + workflowBun: "1.2.0", + }); + + const violations = checkEnginePinParity(root); + + expect( + violations.some((row) => row.message.includes("bun-version: 1.3.14")) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when package.json, Dockerfile, and CI agree", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + writeEnginePinFixture(root, { + engines: { bun: "1.3.14" }, + dockerBun: "1.3.14", + workflowBun: "1.3.14", + }); + + const violations = checkEnginePinParity(root); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkEnvSchemaDrift", () => { test("aligned schema and .env.example produces no violations", () => { const violations = checkEnvSchemaDrift(join(FIXTURES, "env-cascade-clean")); From 469775371fa73a7b2a877f7994aebab1e83530d6 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 14:48:25 +0200 Subject: [PATCH 3/7] fix(infra): add healthchecks to prod ui and traefik, guard via CI The prod ui service was the only long-running prod service without a healthcheck (dev/smoke profiles already probe /healthz, which prod nginx.conf serves). Traefik gains an internal --ping healthcheck too. A new validate-compose CI step asserts every long-running prod-profile service in the base compose file defines a healthcheck; one-shot jobs (restart "no") are exempt. Audit: F004 --- .../infra-compose-validate-compose.yml | 32 +++++++++++++++++++ infra/compose/compose/docker-compose.yml | 25 +++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 37626bcf..7f56524a 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -76,6 +76,38 @@ jobs: -f docker-compose.production-labels.yml \ --profile prod config --quiet + # Guardrail: every long-running prod-profile service in the base + # compose file must define a healthcheck so `up --wait` and + # service_healthy dependents have a readiness signal. One-shot jobs + # (restart: "no", e.g. api-migrate-prod) exit by design and are + # exempt; overlays ship sidecars and are not scanned. + - name: Guardrail — prod services define healthchecks + if: steps.filter.outputs.code == 'true' + working-directory: infra/compose/compose + run: | + docker compose \ + -f docker-compose.yml \ + --profile prod config --format json > /tmp/prod-config.json + python3 - <<'EOF' + import json + + with open("/tmp/prod-config.json", encoding="utf-8") as handle: + services = json.load(handle)["services"] + + missing = sorted( + name + for name, svc in services.items() + if svc.get("restart") != "no" and "healthcheck" not in svc + ) + + if missing: + raise SystemExit( + "prod services missing healthcheck: " + ", ".join(missing) + ) + + print("healthcheck present on: " + ", ".join(sorted(services))) + EOF + - name: Validate — dev + observability if: steps.filter.outputs.code == 'true' working-directory: infra/compose/compose diff --git a/infra/compose/compose/docker-compose.yml b/infra/compose/compose/docker-compose.yml index 799846e7..1b3dada1 100644 --- a/infra/compose/compose/docker-compose.yml +++ b/infra/compose/compose/docker-compose.yml @@ -89,6 +89,15 @@ services: - "--metrics.prometheus.entryPoint=metrics" - "--metrics.prometheus.addEntryPointsLabels=true" - "--metrics.prometheus.addServicesLabels=true" + # Internal-only /ping endpoint (default entrypoint :8080, not + # published) backing the container healthcheck below. + - "--ping=true" + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: @@ -494,6 +503,22 @@ services: depends_on: api: condition: service_healthy + # Same /healthz probe as ui-smoke: nginx.conf serves `location = /healthz` + # so `docker compose up --wait` has a readiness signal for the SPA too. + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:8080/healthz", + ] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s security_opt: - no-new-privileges:true deploy: From 92d4800c11721b6199f939a83c02b6cb1782e9af Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 14:50:48 +0200 Subject: [PATCH 4/7] fix(ui): stop emitting structured log entries to the prod console The prod path of emit() printed every masked log entry to the browser console, exposing the app's event stream to anyone with devtools open. Production is now Sentry-breadcrumb-only; a prod-mode test (env.DEV mocked false) locks in breadcrumb capture, PII masking, and console silence. Audit: F002 --- apps/ui/src/lib/logger/logger.utils.test.ts | 48 +++++++++++++++++++++ apps/ui/src/lib/logger/logger.utils.ts | 7 ++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/lib/logger/logger.utils.test.ts b/apps/ui/src/lib/logger/logger.utils.test.ts index ddc42d4f..95f13d06 100644 --- a/apps/ui/src/lib/logger/logger.utils.test.ts +++ b/apps/ui/src/lib/logger/logger.utils.test.ts @@ -85,3 +85,51 @@ describe("emit (logger.utils)", () => { expect(attempts[1]?.password).toBe("[redacted]"); }); }); + +/* + * Production path: `vi.resetModules()` + `vi.doMock` + dynamic import give + * this block a logger module whose `env.DEV` is false, without disturbing + * the dev-mode tests above (same pattern as openapi.test.ts). + */ +describe("emit (logger.utils) in production mode", () => { + afterEach(() => { + vi.doUnmock("@/lib/env"); + vi.doUnmock("@sentry/react"); + vi.restoreAllMocks(); + }); + + it("records a Sentry breadcrumb and never writes to the console", async () => { + vi.resetModules(); + + const addBreadcrumb = vi.fn(); + + vi.doMock("@/lib/env", () => ({ + env: { DEV: false, VITE_APP_NAME: "test-app" } + })); + vi.doMock("@sentry/react", () => ({ addBreadcrumb })); + + const prodLogSpy = vi + .spyOn(console, "log") + .mockImplementation((): void => undefined); + const prodErrorSpy = vi + .spyOn(console, "error") + .mockImplementation((): void => undefined); + + const { emit: emitProd } = await import("./logger.utils"); + + emitProd("info", { event: "auth.login_success", password: "hunter2" }); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + + const breadcrumb = addBreadcrumb.mock.calls[0]?.[0] as Record< + string, + unknown + >; + const data = breadcrumb.data as Record; + + expect(breadcrumb.category).toBe("auth.login_success"); + expect(data.password).toBe("[redacted]"); + expect(prodLogSpy).not.toHaveBeenCalled(); + expect(prodErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/src/lib/logger/logger.utils.ts b/apps/ui/src/lib/logger/logger.utils.ts index 3a1041c9..3f2db342 100644 --- a/apps/ui/src/lib/logger/logger.utils.ts +++ b/apps/ui/src/lib/logger/logger.utils.ts @@ -47,11 +47,14 @@ export function emit(level: ILogLevel, payload: ILogEvent): void { return; } + /* + * Production is breadcrumb-only: entries ride along with Sentry events + * instead of landing in the browser console, where they would expose the + * app's event stream to anyone with devtools open. + */ Sentry.addBreadcrumb({ level: level === "warn" ? "warning" : level, category: typeof masked.event === "string" ? masked.event : "log", data: masked }); - - console.log(JSON.stringify(entry)); } From 8507dfb52135f5b36174f42a4594fb7d5484cb37 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 14:57:36 +0200 Subject: [PATCH 5/7] fix(ci): make cancel-in-progress explicit wherever concurrency is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New github-actions-concurrency-explicit lint-meta rule (both apps) requires an explicit cancel-in-progress on every workflow concurrency block. Surfaced four implicit defaults: apps-api-release now states false (matching apps-ui-release — never cancel an in-flight image push); the acl-drift, openapi-drift, and validate-compose validation workflows opt into true since superseded runs are worthless. Audit: F005 --- .github/workflows/apps-api-acl-drift.yml | 1 + .github/workflows/apps-api-openapi-drift.yml | 1 + .github/workflows/apps-api-release.yml | 1 + .../infra-compose-validate-compose.yml | 1 + apps/api/scripts/lint-meta/RULES.md | 53 +++++++-------- apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../ci/github-actions-concurrency-explicit.ts | 66 +++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 49 ++++++++++++++ apps/ui/scripts/lint-meta/RULES.md | 63 +++++++++--------- apps/ui/scripts/lint-meta/cli.ts | 1 + apps/ui/scripts/lint-meta/registry.ts | 2 + .../ci/github-actions-concurrency-explicit.ts | 66 +++++++++++++++++++ apps/ui/tests/lint-meta/lint-meta.test.ts | 47 +++++++++++++ 14 files changed, 298 insertions(+), 57 deletions(-) create mode 100644 apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts create mode 100644 apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts diff --git a/.github/workflows/apps-api-acl-drift.yml b/.github/workflows/apps-api-acl-drift.yml index c764021f..5bf206bf 100644 --- a/.github/workflows/apps-api-acl-drift.yml +++ b/.github/workflows/apps-api-acl-drift.yml @@ -20,6 +20,7 @@ on: concurrency: group: apps-api-acl-drift-${{ github.ref }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/apps-api-openapi-drift.yml b/.github/workflows/apps-api-openapi-drift.yml index 3d68aeb3..2f21052a 100644 --- a/.github/workflows/apps-api-openapi-drift.yml +++ b/.github/workflows/apps-api-openapi-drift.yml @@ -12,6 +12,7 @@ on: concurrency: group: apps-api-openapi-drift-${{ github.ref }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/apps-api-release.yml b/.github/workflows/apps-api-release.yml index 7414cc2e..2c694335 100644 --- a/.github/workflows/apps-api-release.yml +++ b/.github/workflows/apps-api-release.yml @@ -18,6 +18,7 @@ on: concurrency: group: apps-api-release-${{ github.ref }} + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 7f56524a..c3859c50 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -21,6 +21,7 @@ on: concurrency: group: infra-compose-validate-compose-${{ github.ref }} + cancel-in-progress: true permissions: contents: read diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 36ad2311..897af3e0 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -12,29 +12,30 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi ## Rules -| Rule ID | Category | CI-critical | What it guards | -| ----------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions (no ranges). | -| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | -| `package-override-parity` | supply-chain | no | package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package. | -| `shared-tool-version-parity` | supply-chain | no | Shared dev tooling (ESLint, TypeScript, Prettier, knip, …) must be pinned to the same version in every app that declares it. | -| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | -| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | -| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | -| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | -| `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | -| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | -| `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | -| `env-no-direct-process-env` | env | no | Single entry point for env: every source file outside validate.ts must import the typed `env` object instead of reading `process.env` directly. | -| `generated-artifact-contract` | artifacts | no | Sibling apps/ui generated ACL and OpenAPI files must carry required banner text. | -| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppression comments. | -| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | -| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | -| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | -| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | -| `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | -| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | -| `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | -| `touch-tests-too` | testing | no | Modified logic/route files must include a matching test change (opt-in via LINT_META_TOUCHED_BASE). | -| `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | -| `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | +| Rule ID | Category | CI-critical | What it guards | +| ------------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions (no ranges). | +| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | +| `package-override-parity` | supply-chain | no | package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package. | +| `shared-tool-version-parity` | supply-chain | no | Shared dev tooling (ESLint, TypeScript, Prettier, knip, …) must be pinned to the same version in every app that declares it. | +| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | +| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | +| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | +| `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | +| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | +| `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | +| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | +| `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | +| `env-no-direct-process-env` | env | no | Single entry point for env: every source file outside validate.ts must import the typed `env` object instead of reading `process.env` directly. | +| `generated-artifact-contract` | artifacts | no | Sibling apps/ui generated ACL and OpenAPI files must carry required banner text. | +| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppression comments. | +| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | +| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | +| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | +| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | +| `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | +| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | +| `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | +| `touch-tests-too` | testing | no | Modified logic/route files must include a matching test change (opt-in via LINT_META_TOUCHED_BASE). | +| `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | +| `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 5b19a1f9..5daceea8 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -29,6 +29,7 @@ import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env"; import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract"; import { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; import { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; +import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; @@ -111,6 +112,7 @@ export { checkRouteFilesHaveTests, checkSharedToolVersionParity, checkTouchedTests, + checkWorkflowConcurrencyExplicit, checkWorkflowShas, checkWorkflowTimeouts, }; diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 2a8ebc0c..812bf84c 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -1,6 +1,7 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artifact-contract"; import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; +import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; @@ -28,6 +29,7 @@ export const META_RULES: readonly IMetaRule[] = [ sharedToolVersionParityRule, githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsConcurrencyExplicitRule, prePushCiParityRule, enginePinParityRule, dockerfileBaseImageShaPinRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts new file mode 100644 index 00000000..5e35cc4b --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const TOP_LEVEL_KEY_REGEX = /^\S/u; + +/* + * Line-based scan (same pragmatic idiom as github-actions-timeout-required): + * when a workflow declares a top-level `concurrency:` block, require an + * explicit `cancel-in-progress:` so the queue-or-cancel decision is a + * visible choice instead of GitHub's implicit default. + */ +export function checkWorkflowConcurrencyExplicit(file: string): IViolation[] { + const lines = readFileSync(file, "utf8").split("\n"); + let inConcurrency = false; + let sawConcurrency = false; + let hasCancelKey = false; + + for (const line of lines) { + if (/^concurrency:\s*(?:#.*)?$/u.test(line)) { + inConcurrency = true; + sawConcurrency = true; + continue; + } + + if (!inConcurrency) { + continue; + } + + if (TOP_LEVEL_KEY_REGEX.test(line)) { + inConcurrency = false; + continue; + } + + if (/^\s+cancel-in-progress:\s*(?:true|false)\s*(?:#.*)?$/u.test(line)) { + hasCancelKey = true; + } + } + + if (sawConcurrency && !hasCancelKey) { + return [ + { + file, + rule: "github-actions-concurrency-explicit", + message: + "Workflow declares `concurrency:` without an explicit `cancel-in-progress:` — state the queue-or-cancel choice instead of relying on GitHub's implicit default.", + }, + ]; + } + + return []; +} + +/** + * A `concurrency:` block without `cancel-in-progress:` silently inherits + * GitHub's default and reads as an oversight; sibling workflows then drift. + */ +export const githubActionsConcurrencyExplicitRule: IMetaRule = { + id: "github-actions-concurrency-explicit", + category: "ci", + description: + "Workflows with a concurrency block must set cancel-in-progress explicitly.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowConcurrencyExplicit); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 0a3d4461..898dd9fc 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -27,6 +27,7 @@ import { checkNoDirectProcessEnv, checkRouteFilesHaveTests, checkTouchedTests, + checkWorkflowConcurrencyExplicit, checkWorkflowShas, checkWorkflowTimeouts, collectSourceFiles, @@ -360,6 +361,54 @@ describe("checkDockerfileBaseImageShaPin", () => { }); }); +describe("checkWorkflowConcurrencyExplicit", () => { + function writeWorkflow(root: string, content: string): string { + const file = join(root, "wf.yml"); + + writeFileSync(file, content); + + return file; + } + + test("flags a concurrency block without cancel-in-progress", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-concurrency-")); + + try { + const file = writeWorkflow( + root, + "concurrency:\n group: x-${{ github.ref }}\n\njobs: {}\n" + ); + + const violations = checkWorkflowConcurrencyExplicit(file); + + expect(violations.map((row) => row.rule)).toContain( + "github-actions-concurrency-explicit" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes explicit cancel-in-progress and workflows without concurrency", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-concurrency-")); + + try { + const explicit = writeWorkflow( + root, + "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: false\n\njobs: {}\n" + ); + + expect(checkWorkflowConcurrencyExplicit(explicit)).toEqual([]); + + const none = writeWorkflow(root, "jobs: {}\n"); + + expect(checkWorkflowConcurrencyExplicit(none)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkEnginePinParity", () => { function writeEnginePinFixture( root: string, diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 6a1b1957..ae0355f4 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -12,37 +12,38 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi ## Rules -| Rule ID | Category | CI-critical | What it guards | -| ----------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions; peerDependencies must use caret (^). | -| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | -| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | -| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | -| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | -| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | -| `engine-pin-parity` | ci | no | Node and Bun version pins must stay aligned across .nvmrc, package.json, Docker, and CI. | -| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | -| `env-cascade-drift` | env | no | Vite env keys must align across schema.ts, .env.example, and vite-env.d.ts. | -| `env-no-direct-import-meta-env` | env | no | Single entry point for env: every source file outside env.loader.ts must import the typed `env` object instead of reading `import.meta.env` directly. | -| `generated-artifact-contract` | artifacts | no | Generated ACL types and OpenAPI schema files must exist with required banner text. | -| `modulepreload-size-limit-coverage` | artifacts | no | .size-limit.json must include globs for all modulepreload entry chunks. | -| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | -| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppressions, raw HTML, direct env access, raw fetch, or banned Tailwind dark-mode variant classes. | -| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | -| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | -| `no-dangerous-html` | source-text | no | Raw HTML rendering requires a dedicated sanitizer and security review. | -| `env-access` | source-text | no | Read Vite env through src/lib/env only. | -| `no-raw-fetch` | source-text | no | Use the typed apiClient; raw fetch is restricted to src/lib/api/openapi. | -| `no-inline-object-cast` | source-text | no | Casting to an inline object type (`as { … }`) skips validation. | -| `no-dark-variant` | source-text | no | The `dark:` Tailwind variant is banned. | -| `no-cross-repo-import` | source-text | **yes** | Relative imports must stay inside apps/ui; no backend or infra source paths. | -| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.types instead of raw owner/admin/member/viewer string literals. | -| `no-raw-fetch-scripts` | source-text | no | Scripts must not call global fetch except github-actions-permissions.ts (lint:meta --verify SHA check). | -| `queries-no-silent-error-swallow` | source-text | no | *.queries.ts files must not silently swallow query errors as `null`. Let the typed error propagate so consumers can distinguish auth from outage; opt-out per-catch with `// allow-silent: ` when an explicit null is genuinely the right contract. | -| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a colocated *.test.ts or *.test.tsx sibling. | -| `test-files-require-source-sibling` | testing | no | Colocated test files must mirror a source sibling (no orphaned tests). | -| `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | -| `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | +| Rule ID | Category | CI-critical | What it guards | +| ------------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions; peerDependencies must use caret (^). | +| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | +| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | +| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | +| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | +| `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | +| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | +| `engine-pin-parity` | ci | no | Node and Bun version pins must stay aligned across .nvmrc, package.json, Docker, and CI. | +| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | +| `env-cascade-drift` | env | no | Vite env keys must align across schema.ts, .env.example, and vite-env.d.ts. | +| `env-no-direct-import-meta-env` | env | no | Single entry point for env: every source file outside env.loader.ts must import the typed `env` object instead of reading `import.meta.env` directly. | +| `generated-artifact-contract` | artifacts | no | Generated ACL types and OpenAPI schema files must exist with required banner text. | +| `modulepreload-size-limit-coverage` | artifacts | no | .size-limit.json must include globs for all modulepreload entry chunks. | +| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | +| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppressions, raw HTML, direct env access, raw fetch, or banned Tailwind dark-mode variant classes. | +| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | +| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | +| `no-dangerous-html` | source-text | no | Raw HTML rendering requires a dedicated sanitizer and security review. | +| `env-access` | source-text | no | Read Vite env through src/lib/env only. | +| `no-raw-fetch` | source-text | no | Use the typed apiClient; raw fetch is restricted to src/lib/api/openapi. | +| `no-inline-object-cast` | source-text | no | Casting to an inline object type (`as { … }`) skips validation. | +| `no-dark-variant` | source-text | no | The `dark:` Tailwind variant is banned. | +| `no-cross-repo-import` | source-text | **yes** | Relative imports must stay inside apps/ui; no backend or infra source paths. | +| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.types instead of raw owner/admin/member/viewer string literals. | +| `no-raw-fetch-scripts` | source-text | no | Scripts must not call global fetch except github-actions-permissions.ts (lint:meta --verify SHA check). | +| `queries-no-silent-error-swallow` | source-text | no | *.queries.ts files must not silently swallow query errors as `null`. Let the typed error propagate so consumers can distinguish auth from outage; opt-out per-catch with `// allow-silent: ` when an explicit null is genuinely the right contract. | +| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a colocated *.test.ts or *.test.tsx sibling. | +| `test-files-require-source-sibling` | testing | no | Colocated test files must mirror a source sibling (no orphaned tests). | +| `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | +| `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | ## CI-critical rules diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 5e8c571e..6cfcf3cc 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -74,6 +74,7 @@ export { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; export { checkPackageJson } from "./rules/supply-chain/package-json-exact-deps"; export { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; export { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; +export { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; export { checkWorkflow } from "./rules/ci/github-actions-permissions"; export { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; export { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; diff --git a/apps/ui/scripts/lint-meta/registry.ts b/apps/ui/scripts/lint-meta/registry.ts index 4767e276..3b2211c8 100644 --- a/apps/ui/scripts/lint-meta/registry.ts +++ b/apps/ui/scripts/lint-meta/registry.ts @@ -2,6 +2,7 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artif import { modulepreloadSizeLimitRule } from "./rules/artifacts/modulepreload-size-limit"; import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; +import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; @@ -28,6 +29,7 @@ export const META_RULES: readonly IMetaRule[] = [ // --- ci --- githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsConcurrencyExplicitRule, prePushCiParityRule, enginePinParityRule, dockerfileBaseImageShaPinRule, diff --git a/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts b/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts new file mode 100644 index 00000000..de144835 --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const TOP_LEVEL_KEY_REGEX = /^\S/u; + +/* + * Line-based scan (same pragmatic idiom as github-actions-timeout-required): + * when a workflow declares a top-level `concurrency:` block, require an + * explicit `cancel-in-progress:` so the queue-or-cancel decision is a + * visible choice instead of GitHub's implicit default. + */ +export function checkWorkflowConcurrencyExplicit(file: string): IViolation[] { + const lines = readFileSync(file, "utf8").split("\n"); + let inConcurrency = false; + let sawConcurrency = false; + let hasCancelKey = false; + + for (const line of lines) { + if (/^concurrency:\s*(?:#.*)?$/u.test(line)) { + inConcurrency = true; + sawConcurrency = true; + continue; + } + + if (!inConcurrency) { + continue; + } + + if (TOP_LEVEL_KEY_REGEX.test(line)) { + inConcurrency = false; + continue; + } + + if (/^\s+cancel-in-progress:\s*(?:true|false)\s*(?:#.*)?$/u.test(line)) { + hasCancelKey = true; + } + } + + if (sawConcurrency && !hasCancelKey) { + return [ + { + file, + rule: "github-actions-concurrency-explicit", + message: + "Workflow declares `concurrency:` without an explicit `cancel-in-progress:` — state the queue-or-cancel choice instead of relying on GitHub's implicit default." + } + ]; + } + + return []; +} + +/** + * A `concurrency:` block without `cancel-in-progress:` silently inherits + * GitHub's default and reads as an oversight; sibling workflows then drift. + */ +export const githubActionsConcurrencyExplicitRule: IMetaRule = { + id: "github-actions-concurrency-explicit", + category: "ci", + description: + "Workflows with a concurrency block must set cancel-in-progress explicitly.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowConcurrencyExplicit); + } +}; diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 612f0048..8a4875a9 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -26,6 +26,7 @@ import { checkTestFilesHaveSource, checkUiEnvCascadeDrift, checkWorkflow, + checkWorkflowConcurrencyExplicit, checkWorkflowTimeouts, collectSourceFiles, findWorkflows, @@ -595,6 +596,52 @@ describe("checkEnginePinParity", () => { }); }); +describe("checkWorkflowConcurrencyExplicit", () => { + test("flags a concurrency block without cancel-in-progress", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-concurrency-")); + + try { + const file = join(root, "wf.yml"); + + writeFileSync( + file, + "concurrency:\n group: x-${{ github.ref }}\n\njobs: {}\n" + ); + + const violations = checkWorkflowConcurrencyExplicit(file); + + expect(violations.map((row) => row.rule)).toContain( + "github-actions-concurrency-explicit" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes explicit cancel-in-progress and workflows without concurrency", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-concurrency-")); + + try { + const explicit = join(root, "explicit.yml"); + + writeFileSync( + explicit, + "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: true\n\njobs: {}\n" + ); + + expect(checkWorkflowConcurrencyExplicit(explicit)).toEqual([]); + + const none = join(root, "none.yml"); + + writeFileSync(none, "jobs: {}\n"); + + expect(checkWorkflowConcurrencyExplicit(none)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkDockerfileBaseImageShaPin", () => { test("flags a FROM tag without a digest", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-dockerpin-")); From b04e8343f9c0ebef863c7f1f8c026a439706564c Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 14:58:51 +0200 Subject: [PATCH 6/7] fix(docs): route package.json scripts through bun and refresh catalog Six scripts invoked node directly while the repo standard (and packageManager pin) is bun; bun executes the same .mjs files natively. Also commits the regenerated lint-meta catalog picking up the new dockerfile-base-image-sha-pin and github-actions-concurrency-explicit rules. Audit: F006 --- apps/docs/package.json | 12 ++++++------ apps/docs/src/data/lint-meta-catalog.json | 24 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index e3536488..7f3740a1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -12,14 +12,14 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "generate:lint-meta-docs": "node scripts/generate-lint-meta-docs.mjs", - "generate:scripts-docs": "node scripts/generate-scripts-docs.mjs", - "generate:og-image": "node scripts/generate-og-image.mjs", + "generate:lint-meta-docs": "bun run scripts/generate-lint-meta-docs.mjs", + "generate:scripts-docs": "bun run scripts/generate-scripts-docs.mjs", + "generate:og-image": "bun run scripts/generate-og-image.mjs", "generate:docs-data": "bun run generate:lint-meta-docs && bun run generate:scripts-docs", - "check:lint-meta-docs": "node scripts/generate-lint-meta-docs.mjs --check", - "check:scripts-docs": "node scripts/generate-scripts-docs.mjs --check", + "check:lint-meta-docs": "bun run scripts/generate-lint-meta-docs.mjs --check", + "check:scripts-docs": "bun run scripts/generate-scripts-docs.mjs --check", "check:docs-data": "bun run check:lint-meta-docs && bun run check:scripts-docs", - "check:fragments": "node scripts/check-fragments.mjs", + "check:fragments": "bun run scripts/check-fragments.mjs", "build:site": "bun run generate:og-image && astro build", "build": "bun run generate:og-image && astro build", "build:ci": "bun run check:docs-data && bun run generate:og-image && astro build && bun run check:fragments", diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index ce6688bc..a5986442 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -30,6 +30,12 @@ "ciCritical": false, "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." }, + { + "id": "github-actions-concurrency-explicit", + "category": "ci", + "ciCritical": false, + "description": "Workflows with a concurrency block must set cancel-in-progress explicitly." + }, { "id": "pre-push-ci-parity", "category": "ci", @@ -42,6 +48,12 @@ "ciCritical": false, "description": "Node and Bun version pins must stay aligned across .nvmrc, package.json, Docker, and CI." }, + { + "id": "dockerfile-base-image-sha-pin", + "category": "ci", + "ciCritical": false, + "description": "Dockerfile base images must be pinned by @sha256 digest, not tag alone." + }, { "id": "env-cascade-drift", "category": "env", @@ -212,6 +224,12 @@ "ciCritical": false, "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." }, + { + "id": "github-actions-concurrency-explicit", + "category": "ci", + "ciCritical": false, + "description": "Workflows with a concurrency block must set cancel-in-progress explicitly." + }, { "id": "pre-push-ci-parity", "category": "ci", @@ -224,6 +242,12 @@ "ciCritical": false, "description": "Bun version pin must stay aligned across package.json, Docker, and CI." }, + { + "id": "dockerfile-base-image-sha-pin", + "category": "ci", + "ciCritical": false, + "description": "Dockerfile base images must be pinned by @sha256 digest, not tag alone." + }, { "id": "env-cascade-drift", "category": "env", From 7c5f426692c83bb007e137cfd4ebba00e0826118 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 3 Jun 2026 15:00:36 +0200 Subject: [PATCH 7/7] fix(ui): generate e2e consent timestamp at runtime via now() The auth fixture pinned configuredAt to 2026-01-01, drifting ever further into the past; if consent re-prompt logic ever lands, every e2e run would silently exercise the stale-consent path. The canonical now() helper keeps the fixture a fresh dismissal. Audit: F007 --- apps/ui/e2e/fixtures/auth.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/ui/e2e/fixtures/auth.ts b/apps/ui/e2e/fixtures/auth.ts index e24099fa..082267b8 100644 --- a/apps/ui/e2e/fixtures/auth.ts +++ b/apps/ui/e2e/fixtures/auth.ts @@ -5,6 +5,8 @@ import { } from "@playwright/test"; import { randomUUID } from "node:crypto"; +import { now } from "@/lib/time/now"; + import { DashboardPage } from "../pages/DashboardPage"; import { LoginPage } from "../pages/LoginPage"; @@ -37,11 +39,16 @@ interface ITestUser { * versioned (`.v1`) so an intentional re-prompt later won't break this. */ const CONSENT_STORAGE_KEY = "bs.cookie-consent.v1"; +/* + * configuredAt is computed per run so the fixture always represents a + * fresh dismissal; a hardcoded date would drift into the past and + * silently exercise a stale-consent path if re-prompt logic ever lands. + */ const CONSENT_DISMISSED_STATE = { state: { status: "configured", categories: { essential: true, analytics: false, marketing: false }, - configuredAt: "2026-01-01T00:00:00.000Z" + configuredAt: now() }, version: 0 };