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 37626bcf..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 @@ -76,6 +77,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/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 18d7d0c6..897af3e0 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -12,28 +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. | -| `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 698ab05c..5daceea8 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -27,6 +27,9 @@ 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 { 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"; @@ -93,6 +96,8 @@ export { parseTypeboxEnvSchemaKeys as parseEnvSchemaKeys } from "./parsers/typeb export { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkDockerfileBaseImageShaPin, + checkEnginePinParity, checkExactDependencyVersions, checkEslintConfigNoWarn, checkEslintOverridePathsExist, @@ -107,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 044e19c0..812bf84c 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -1,5 +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"; @@ -27,8 +29,10 @@ export const META_RULES: readonly IMetaRule[] = [ sharedToolVersionParityRule, githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsConcurrencyExplicitRule, 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/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 36be7565..898dd9fc 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -16,6 +16,8 @@ import { renderRulesMd } from "../../scripts/lint-meta/generate-rules-md"; import { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkDockerfileBaseImageShaPin, + checkEnginePinParity, checkEnvSchemaDrift, checkEslintConfigNoWarn, checkEslintOverridePathsExist, @@ -25,6 +27,7 @@ import { checkNoDirectProcessEnv, checkRouteFilesHaveTests, checkTouchedTests, + checkWorkflowConcurrencyExplicit, checkWorkflowShas, checkWorkflowTimeouts, collectSourceFiles, @@ -310,6 +313,206 @@ 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("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, + 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")); 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", 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/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 }; diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 3e3e86f9..ae0355f4 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -12,36 +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. | -| `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 2e04afcd..6cfcf3cc 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -72,7 +72,9 @@ 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 { 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 7a93b6fc..3b2211c8 100644 --- a/apps/ui/scripts/lint-meta/registry.ts +++ b/apps/ui/scripts/lint-meta/registry.ts @@ -1,6 +1,8 @@ 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 { 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"; @@ -27,8 +29,10 @@ export const META_RULES: readonly IMetaRule[] = [ // --- ci --- githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsConcurrencyExplicitRule, 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/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/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)); } diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 031e3923..8a4875a9 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, @@ -25,6 +26,7 @@ import { checkTestFilesHaveSource, checkUiEnvCascadeDrift, checkWorkflow, + checkWorkflowConcurrencyExplicit, checkWorkflowTimeouts, collectSourceFiles, findWorkflows, @@ -594,6 +596,95 @@ 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-")); + + 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-")); 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: