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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/apps-api-acl-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ on:

concurrency:
group: apps-api-acl-drift-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/apps-api-openapi-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:

concurrency:
group: apps-api-openapi-drift-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/apps-api-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:

concurrency:
group: apps-api-release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/infra-compose-validate-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

concurrency:
group: infra-compose-validate-compose-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
Expand Down Expand Up @@ -76,6 +77,38 @@
-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
Expand Down Expand Up @@ -251,4 +284,4 @@
# don't fail on style nits — only on real syntax errors.
~/.local/bin/yamllint -d "{extends: relaxed, rules: {line-length: disable}}" \
infra/compose/compose/ .github/workflows/

Check warning on line 287 in .github/workflows/infra-compose-validate-compose.yml

View workflow job for this annotation

GitHub Actions / yamllint (compose + workflows)

287:1 [empty-lines] too many blank lines (1 > 0)
52 changes: 27 additions & 25 deletions apps/api/scripts/lint-meta/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
6 changes: 6 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,6 +96,8 @@ export { parseTypeboxEnvSchemaKeys as parseEnvSchemaKeys } from "./parsers/typeb
export {
checkCanonicalHelpersSingleHome,
checkDependencyPairs,
checkDockerfileBaseImageShaPin,
checkEnginePinParity,
checkExactDependencyVersions,
checkEslintConfigNoWarn,
checkEslintOverridePathsExist,
Expand All @@ -107,6 +112,7 @@ export {
checkRouteFilesHaveTests,
checkSharedToolVersionParity,
checkTouchedTests,
checkWorkflowConcurrencyExplicit,
checkWorkflowShas,
checkWorkflowTimeouts,
};
4 changes: 4 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -27,8 +29,10 @@ export const META_RULES: readonly IMetaRule[] = [
sharedToolVersionParityRule,
githubActionsPermissionsRule,
githubActionsTimeoutRequiredRule,
githubActionsConcurrencyExplicitRule,
prePushCiParityRule,
enginePinParityRule,
dockerfileBaseImageShaPinRule,
envCascadeDriftRule,
noDirectProcessEnvRule,
generatedArtifactContractRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();

for (const [index, line] of lines.entries()) {
const fromMatch =
/^\s*FROM\s+(?<image>\S+)(?:\s+AS\s+(?<alias>\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);
},
};
Original file line number Diff line number Diff line change
@@ -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);
},
};
Loading
Loading