diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index 9be9d11a..f1eb5a6b 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -38,6 +38,7 @@ jobs: run: working-directory: apps/docs runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/infra-bootstrap-validate.yml b/.github/workflows/infra-bootstrap-validate.yml index 58aa51c1..f678a9e2 100644 --- a/.github/workflows/infra-bootstrap-validate.yml +++ b/.github/workflows/infra-bootstrap-validate.yml @@ -20,6 +20,7 @@ jobs: run: working-directory: infra/bootstrap runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index d8ee4ebc..a48db2fc 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -16,9 +16,11 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | ----------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `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. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 4c51528b..4de907f8 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -20,12 +20,14 @@ import { META_RULES } from "./registry"; import { printRuleCatalog, runMetaRules, runMetaRulesAsync } from "./runner"; import { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; import { checkExactDependencyVersions } from "./rules/supply-chain/package-json-exact-deps"; +import { checkPackageOverrideParity } from "./rules/supply-chain/package-override-parity"; import { checkSharedToolVersionParity } from "./rules/supply-chain/shared-tool-version-parity"; import { checkEslintConfigNoWarn } from "./rules/config/eslint-config-no-warn"; 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 { 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"; import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home"; import { checkForbiddenText } from "./rules/source-text/forbidden-text"; @@ -98,9 +100,11 @@ export { checkLogicFilesHaveTests, checkNoDirectProcessEnv, checkNoRawRoleLiterals, + checkPackageOverrideParity, checkPrePushParity, checkRouteFilesHaveTests, checkSharedToolVersionParity, checkTouchedTests, checkWorkflowShas, + checkWorkflowTimeouts, }; diff --git a/apps/api/scripts/lint-meta/context.ts b/apps/api/scripts/lint-meta/context.ts index 90df0942..20f04228 100644 --- a/apps/api/scripts/lint-meta/context.ts +++ b/apps/api/scripts/lint-meta/context.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; import type { IMetaContext } from "./types"; @@ -85,6 +85,33 @@ export function findWorkflows(dir: string): string[] { return out; } +/* + * Workflows live at the app root when this template is a standalone repo, but + * in a monorepo checkout they live at the repository root. Walk up from the + * app root to the nearest `.github/workflows` so the CI rules + * (github-actions-permissions, github-actions-timeout-required) always scan + * the workflows that actually run for this code instead of silently no-oping. + */ +export function resolveWorkflowsDir(root: string): string { + let current = root; + + for (;;) { + const candidate = join(current, ".github", "workflows"); + + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + + if (parent === current) { + return join(root, ".github", "workflows"); + } + + current = parent; + } +} + export function buildContext(root: string): IMetaContext { const sourceFiles = [ ...SOURCE_DIRS.flatMap((dir) => @@ -99,6 +126,6 @@ export function buildContext(root: string): IMetaContext { return { root, sourceFiles, - workflowFiles: findWorkflows(join(root, ".github", "workflows")), + workflowFiles: findWorkflows(resolveWorkflowsDir(root)), }; } diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 8862187b..1102f8a7 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 { enginePinParityRule } from "./rules/ci/engine-pin-parity"; 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"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; import { envCascadeDriftRule } from "./rules/env/env-cascade-drift"; @@ -10,6 +11,7 @@ import { forbiddenTextRule } from "./rules/source-text/forbidden-text"; import { noRawRoleLiteralsRule } from "./rules/source-text/no-raw-role-literals"; import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs"; import { packageJsonExactDepsRule } from "./rules/supply-chain/package-json-exact-deps"; +import { packageOverrideParityRule } from "./rules/supply-chain/package-override-parity"; import { sharedToolVersionParityRule } from "./rules/supply-chain/shared-tool-version-parity"; import { logicFilesRequireTestSiblingRule } from "./rules/testing/logic-files-require-test-sibling"; import { routesRequireTestSiblingRule } from "./rules/testing/routes-require-test-sibling"; @@ -20,8 +22,10 @@ import type { IMetaRule } from "./types"; export const META_RULES: readonly IMetaRule[] = [ packageJsonExactDepsRule, noOverlappingLibsRule, + packageOverrideParityRule, sharedToolVersionParityRule, githubActionsPermissionsRule, + githubActionsTimeoutRequiredRule, prePushCiParityRule, enginePinParityRule, envCascadeDriftRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts new file mode 100644 index 00000000..5f477668 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const JOB_KEY_REGEX = /^ {2}([\w-]+):\s*(?:#.*)?$/u; +const TOP_LEVEL_KEY_REGEX = /^\S/u; + +interface IJobBlock { + readonly name: string; + readonly lines: readonly string[]; +} + +/* + * Line-based scan (same pragmatic idiom as github-actions-permissions): + * collect each `jobs:` child block, then require a job-level + * `timeout-minutes:` unless the job is a reusable-workflow call + * (job-level `uses:` — those cannot set timeout-minutes). + */ +function collectJobBlocks(text: string): IJobBlock[] { + const lines = text.split("\n"); + const blocks: IJobBlock[] = []; + let inJobs = false; + let current: { name: string; lines: string[] } | null = null; + + for (const line of lines) { + if (/^jobs:\s*(?:#.*)?$/u.test(line)) { + inJobs = true; + continue; + } + + if (!inJobs) { + continue; + } + + if (TOP_LEVEL_KEY_REGEX.test(line)) { + inJobs = false; + + if (current !== null) { + blocks.push(current); + current = null; + } + + continue; + } + + const jobMatch = JOB_KEY_REGEX.exec(line); + + if (jobMatch?.[1] !== undefined) { + if (current !== null) { + blocks.push(current); + } + + current = { name: jobMatch[1], lines: [] }; + continue; + } + + if (current !== null) { + current.lines.push(line); + } + } + + if (current !== null) { + blocks.push(current); + } + + return blocks; +} + +export function checkWorkflowTimeouts(file: string): IViolation[] { + const violations: IViolation[] = []; + const text = readFileSync(file, "utf8"); + + for (const job of collectJobBlocks(text)) { + const isReusableCall = job.lines.some((line) => + /^ {4}uses:\s*\S/u.test(line) + ); + + if (isReusableCall) { + continue; + } + + const hasTimeout = job.lines.some((line) => + /^ {4}timeout-minutes:\s*[1-9]\d*\s*(?:#.*)?$/u.test(line) + ); + + if (!hasTimeout) { + violations.push({ + file, + rule: "github-actions-timeout-required", + message: `Job "${job.name}" has no job-level \`timeout-minutes:\` — a hung step runs for GitHub's 6h default and blocks the PR check.`, + }); + } + } + + return violations; +} + +/** + * Every runnable workflow job must declare an explicit `timeout-minutes:` so + * a hang fails fast instead of occupying a runner for GitHub's 6h default. + */ +export const githubActionsTimeoutRequiredRule: IMetaRule = { + id: "github-actions-timeout-required", + category: "ci", + description: + "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt).", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowTimeouts); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts b/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts new file mode 100644 index 00000000..1917e84a --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts @@ -0,0 +1,227 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Security/parity `overrides` in one app's package.json must hold across the + * monorepo: a sibling app that resolves the same package (per its bun.lock) + * either mirrors the override or has consciously pinned the same version. + * Two failure modes are caught: + * + * 1. Stale override — an app's own bun.lock resolves a different version + * than its declared override (the override never took effect; run + * `bun install`). + * 2. Missing mirror — a sibling resolves the package at a different + * version than the override and declares no override of its own + * (e.g. a GHSA patch pinned in one app but not the others). + */ + +interface IAppOverrides { + readonly app: string; + readonly file: string; + readonly overrides: Record; + readonly lockfileText: string | null; +} + +function toStringRecord(value: unknown): Record { + if (typeof value !== "object" || value === null) { + return {}; + } + + const out: Record = {}; + + for (const [k, v] of Object.entries(value)) { + if (typeof v === "string") { + out[k] = v; + } + } + + return out; +} + +function readApps(appsDir: string): IAppOverrides[] { + const out: IAppOverrides[] = []; + let entries: string[]; + + try { + entries = readdirSync(appsDir); + } catch { + return out; + } + + for (const entry of entries) { + const dir = join(appsDir, entry); + + let isDir: boolean; + + try { + isDir = statSync(dir).isDirectory(); + } catch { + continue; + } + + if (!isDir) { + continue; + } + + const file = join(dir, "package.json"); + let parsed: unknown; + + try { + parsed = JSON.parse(readFileSync(file, "utf8")); + } catch { + continue; + } + + if (typeof parsed !== "object" || parsed === null) { + continue; + } + + let overridesValue: unknown; + + for (const [k, v] of Object.entries(parsed)) { + if (k === "overrides") { + overridesValue = v; + } + } + + let lockfileText: string | null; + + try { + lockfileText = readFileSync(join(dir, "bun.lock"), "utf8"); + } catch { + lockfileText = null; + } + + out.push({ + app: entry, + file, + overrides: toStringRecord(overridesValue), + lockfileText, + }); + } + + return out; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +/** Versions the lockfile resolves for `name` (entries look like "name@1.2.3"). */ +function resolvedVersions(lockfileText: string, name: string): string[] { + const regex = new RegExp(`"${escapeRegExp(name)}@(\\d[^"]*)"`, "gu"); + const versions = new Set(); + let match: RegExpExecArray | null = regex.exec(lockfileText); + + while (match !== null) { + const version = match[1]; + + if (version !== undefined) { + versions.add(version); + } + + match = regex.exec(lockfileText); + } + + return [...versions]; +} + +type Reporter = (file: string, key: string, message: string) => void; + +function checkStaleOverrides(app: IAppOverrides, report: Reporter): void { + if (app.lockfileText === null) { + return; + } + + for (const [name, version] of Object.entries(app.overrides)) { + const resolved = resolvedVersions(app.lockfileText, name); + + if (resolved.length > 0 && resolved.join(",") !== version) { + report( + app.file, + `stale:${name}`, + `Override ${name}@${version} is not what bun.lock resolves (${resolved.join(", ")}) — run \`bun install\` to apply it.` + ); + } + } +} + +function checkSiblingMirror( + owner: IAppOverrides, + sibling: IAppOverrides, + name: string, + version: string, + report: Reporter +): void { + if (sibling.lockfileText === null) { + return; + } + + const siblingOverride = sibling.overrides[name]; + + if (siblingOverride !== undefined) { + if (siblingOverride !== version) { + report( + sibling.file, + `drift:${name}`, + `Override ${name}@${siblingOverride} drifts from ${owner.app}'s ${name}@${version} — align the pins or document why they differ.` + ); + } + + return; + } + + const resolved = resolvedVersions(sibling.lockfileText, name); + + if (resolved.length > 0 && resolved.join(",") !== version) { + report( + sibling.file, + `missing:${name}`, + `${owner.app} overrides ${name}@${version} but this app resolves ${resolved.join(", ")} with no override — mirror the pin (it usually exists for a security advisory).` + ); + } +} + +export function checkPackageOverrideParity(appsDir: string): IViolation[] { + const violations: IViolation[] = []; + const reported = new Set(); + const apps = readApps(appsDir); + + const report: Reporter = (file, key, message) => { + if (reported.has(`${file}:${key}`)) { + return; + } + + reported.add(`${file}:${key}`); + violations.push({ file, rule: "package-override-parity", message }); + }; + + for (const app of apps) { + checkStaleOverrides(app, report); + } + + for (const owner of apps) { + for (const [name, version] of Object.entries(owner.overrides)) { + for (const sibling of apps) { + if (sibling.app !== owner.app) { + checkSiblingMirror(owner, sibling, name, version, report); + } + } + } + } + + return violations; +} + +/** Package overrides must be applied and mirrored across sibling apps. */ +export const packageOverrideParityRule: IMetaRule = { + id: "package-override-parity", + category: "supply-chain", + description: + "package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package.", + run({ root }) { + return checkPackageOverrideParity(join(root, "..")); + }, +}; diff --git a/apps/api/src/lib/oauth/oauth.state.ts b/apps/api/src/lib/oauth/oauth.state.ts index c5f042cc..e54f0142 100644 --- a/apps/api/src/lib/oauth/oauth.state.ts +++ b/apps/api/src/lib/oauth/oauth.state.ts @@ -68,7 +68,9 @@ class OAuthStateStore { /** * Read + delete the stored state. Returns `null` if absent (expired, - * forged, or already consumed). Read-and-delete makes replay + * forged, or already consumed) — and equally if the stored value is + * not a JSON object: corrupted state must fail the flow, not pass as + * a valid state with no extras. Read-and-delete makes replay * impossible. */ async consume(state: string): Promise { @@ -83,7 +85,7 @@ class OAuthStateStore { const parsed: unknown = JSON.parse(raw); if (parsed === null || typeof parsed !== "object") { - return {}; + return null; } const result: IStoredState = {}; @@ -98,7 +100,7 @@ class OAuthStateStore { return result; } catch { - return {}; + return null; } } } diff --git a/apps/api/tests/lib/oauth/oauth.state.test.ts b/apps/api/tests/lib/oauth/oauth.state.test.ts new file mode 100644 index 00000000..825eae9e --- /dev/null +++ b/apps/api/tests/lib/oauth/oauth.state.test.ts @@ -0,0 +1,83 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { Redis } from "ioredis"; + +import { getValkeyAppClientOptions } from "../../../src/clients/valkey"; +import { OAUTH_STATE_PREFIX } from "../../../src/lib/oauth/oauth.constants"; +import { oauthStateStore } from "../../../src/lib/oauth/oauth.state"; + +/* + * Seeds raw values under the OAuth state prefix to exercise consume()'s + * handling of corrupted store contents — something store() can never + * produce, so it needs a direct client. + */ +const seedClient = new Redis( + getValkeyAppClientOptions({ connectTimeout: 500 }) +); + +const SEED_TTL_SECONDS = 30; + +afterAll(async () => { + try { + await seedClient.quit(); + } catch { + seedClient.disconnect(); + } + + await oauthStateStore.close(); +}); + +describe("oauthStateStore.consume", () => { + test("round-trips a stored state and burns it on first read", async () => { + await oauthStateStore.store("state-roundtrip", { codeVerifier: "v" }); + + const first = await oauthStateStore.consume("state-roundtrip"); + + expect(first).toEqual({ codeVerifier: "v" }); + + const replay = await oauthStateStore.consume("state-roundtrip"); + + expect(replay).toBeNull(); + }); + + test("returns null for an unknown state", async () => { + const got = await oauthStateStore.consume("state-never-stored"); + + expect(got).toBeNull(); + }); + + test("returns null when the stored value is not JSON", async () => { + await seedClient.setex( + `${OAUTH_STATE_PREFIX}state-garbage`, + SEED_TTL_SECONDS, + "not-json{" + ); + + const got = await oauthStateStore.consume("state-garbage"); + + expect(got).toBeNull(); + }); + + test("returns null when the stored value is a JSON scalar", async () => { + await seedClient.setex( + `${OAUTH_STATE_PREFIX}state-scalar`, + SEED_TTL_SECONDS, + '"just-a-string"' + ); + + const got = await oauthStateStore.consume("state-scalar"); + + expect(got).toBeNull(); + }); + + test("strips unknown fields from a stored object", async () => { + await seedClient.setex( + `${OAUTH_STATE_PREFIX}state-extra-fields`, + SEED_TTL_SECONDS, + JSON.stringify({ codeVerifier: "v", role: "superadmin" }) + ); + + const got = await oauthStateStore.consume("state-extra-fields"); + + expect(got).toEqual({ codeVerifier: "v" }); + }); +}); diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/bun.lock b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/bun.lock new file mode 100644 index 00000000..8703962a --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/bun.lock @@ -0,0 +1,5 @@ +{ + "packages": { + "ws": ["ws@8.21.0", "", {}, "sha512-fixture"] + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json new file mode 100644 index 00000000..feffa478 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json @@ -0,0 +1,6 @@ +{ + "name": "fixture-app-a", + "overrides": { + "ws": "8.21.0" + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/bun.lock b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/bun.lock new file mode 100644 index 00000000..8703962a --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/bun.lock @@ -0,0 +1,5 @@ +{ + "packages": { + "ws": ["ws@8.21.0", "", {}, "sha512-fixture"] + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/package.json new file mode 100644 index 00000000..bc889926 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-b/package.json @@ -0,0 +1,3 @@ +{ + "name": "fixture-app-b" +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/bun.lock b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/bun.lock new file mode 100644 index 00000000..8703962a --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/bun.lock @@ -0,0 +1,5 @@ +{ + "packages": { + "ws": ["ws@8.21.0", "", {}, "sha512-fixture"] + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/package.json new file mode 100644 index 00000000..feffa478 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-a/package.json @@ -0,0 +1,6 @@ +{ + "name": "fixture-app-a", + "overrides": { + "ws": "8.21.0" + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/bun.lock b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/bun.lock new file mode 100644 index 00000000..7ff80809 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/bun.lock @@ -0,0 +1,5 @@ +{ + "packages": { + "ws": ["ws@8.20.1", "", {}, "sha512-fixture"] + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/package.json new file mode 100644 index 00000000..bc889926 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-b/package.json @@ -0,0 +1,3 @@ +{ + "name": "fixture-app-b" +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/bun.lock b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/bun.lock new file mode 100644 index 00000000..7ff80809 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/bun.lock @@ -0,0 +1,5 @@ +{ + "packages": { + "ws": ["ws@8.20.1", "", {}, "sha512-fixture"] + } +} diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/package.json new file mode 100644 index 00000000..a517c3c0 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/override-parity-drift/app-c/package.json @@ -0,0 +1,6 @@ +{ + "name": "fixture-app-c", + "overrides": { + "ws": "8.21.0" + } +} diff --git a/apps/api/tests/lint-meta/fixtures/workflows-good/ci.yml b/apps/api/tests/lint-meta/fixtures/workflows-good/ci.yml index 9bf9de2d..c86f49b6 100644 --- a/apps/api/tests/lint-meta/fixtures/workflows-good/ci.yml +++ b/apps/api/tests/lint-meta/fixtures/workflows-good/ci.yml @@ -4,6 +4,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 - run: echo hi diff --git a/apps/api/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml b/apps/api/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml new file mode 100644 index 00000000..d829cc19 --- /dev/null +++ b/apps/api/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml @@ -0,0 +1,9 @@ +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + reuse: + uses: ./.github/workflows/reusable.yml diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index d09566f1..0ac2b097 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -25,10 +25,12 @@ import { checkRouteFilesHaveTests, checkTouchedTests, checkWorkflowShas, + checkWorkflowTimeouts, collectSourceFiles, findWorkflows, checkGeneratedArtifactContracts, checkNoRawRoleLiterals, + checkPackageOverrideParity, checkPrePushParity, checkSharedToolVersionParity, } from "../../scripts/lint-meta/cli"; @@ -56,6 +58,43 @@ describe("checkSharedToolVersionParity", () => { }); }); +describe("checkPackageOverrideParity", () => { + test("flags a sibling resolving an overridden package without a mirror", () => { + const violations = checkPackageOverrideParity( + join(FIXTURES, "override-parity-drift") + ); + + expect( + violations.some( + (row) => + row.file.includes("app-b") && row.message.includes("mirror the pin") + ) + ).toBe(true); + }); + + test("flags an override the app's own lockfile does not resolve", () => { + const violations = checkPackageOverrideParity( + join(FIXTURES, "override-parity-drift") + ); + + expect( + violations.some( + (row) => + row.file.includes("app-c") && + row.message.includes("run `bun install`") + ) + ).toBe(true); + }); + + test("passes when overrides are applied and siblings resolve the same version", () => { + const violations = checkPackageOverrideParity( + join(FIXTURES, "override-parity-clean") + ); + + expect(violations).toEqual([]); + }); +}); + describe("checkForbiddenText", () => { test("flags inline lint suppression directive", () => { const violations = checkForbiddenText( @@ -191,6 +230,24 @@ describe("checkWorkflowShas", () => { }); }); +describe("checkWorkflowTimeouts", () => { + test("flags a job missing timeout-minutes, exempts reusable-workflow calls", () => { + const workflows = findWorkflows(join(FIXTURES, "workflows-no-timeout")); + const violations = workflows.flatMap(checkWorkflowTimeouts); + + expect(violations).toHaveLength(1); + expect(violations[0]?.rule).toBe("github-actions-timeout-required"); + expect(violations[0]?.message).toContain('"test"'); + }); + + test("job with timeout-minutes passes", () => { + const workflows = findWorkflows(join(FIXTURES, "workflows-good")); + const violations = workflows.flatMap(checkWorkflowTimeouts); + + expect(violations).toEqual([]); + }); +}); + describe("checkEnvSchemaDrift", () => { test("aligned schema and .env.example produces no violations", () => { const violations = checkEnvSchemaDrift(join(FIXTURES, "env-cascade-clean")); diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock index d5026200..77ee1d03 100644 --- a/apps/docs/bun.lock +++ b/apps/docs/bun.lock @@ -485,7 +485,7 @@ "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "3.2.3" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "19.2.14" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 03ea1d4d..29b34fb2 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -24,6 +24,12 @@ "ciCritical": false, "description": "Pinned action SHAs resolve on github.com (lint:meta:verify only)." }, + { + "id": "github-actions-timeout-required", + "category": "ci", + "ciCritical": false, + "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." + }, { "id": "pre-push-ci-parity", "category": "ci", @@ -176,6 +182,12 @@ "ciCritical": false, "description": "package.json must not list forbidden overlapping library pairs." }, + { + "id": "package-override-parity", + "category": "supply-chain", + "ciCritical": false, + "description": "package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package." + }, { "id": "shared-tool-version-parity", "category": "supply-chain", @@ -194,6 +206,12 @@ "ciCritical": false, "description": "Pinned action SHAs resolve on github.com (lint:meta:verify only)." }, + { + "id": "github-actions-timeout-required", + "category": "ci", + "ciCritical": false, + "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." + }, { "id": "pre-push-ci-parity", "category": "ci", diff --git a/apps/ui/bun.lock b/apps/ui/bun.lock index ce44e2c9..5570ac5f 100644 --- a/apps/ui/bun.lock +++ b/apps/ui/bun.lock @@ -99,7 +99,9 @@ "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@typescript-eslint/utils": "8.60.0", + "qs": "6.15.2", "tmp": "0.2.6", + "ws": "8.21.0", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -1838,7 +1840,7 @@ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -2218,7 +2220,7 @@ "write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "0.1.4", "is-typedarray": "1.0.0", "signal-exit": "3.0.7", "typedarray-to-buffer": "3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], - "ws": ["ws@8.20.1", "", {}, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "3.1.1" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -2464,8 +2466,6 @@ "lighthouse/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "lighthouse/ws": ["ws@7.5.10", "", {}, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "lighthouse/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "lighthouse/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/apps/ui/package.json b/apps/ui/package.json index a9bd4fd5..992d9c40 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -146,10 +146,21 @@ "vitest": "4.1.7", "yaml": "2.9.0" }, + "//overrides": { + "_": "Why these transitive deps are pinned. Dev tooling only — lighthouse/lhci and http-server pull the vulnerable ranges; production output is a static bundle.", + "ws": "Pin patched ws (GHSA-58qx-3vcg-4xpx) pulled via lighthouse dev tooling; mirrors the docs app pin.", + "qs": "Pin patched qs (GHSA-q8mj-m7cp-5q26) pulled via dev tooling; mirrors the docs app pin.", + "tmp": "Pin patched tmp symlink arbitrary-write advisory; transitive dev-tooling dep.", + "@types/react": "Single React types resolution across the workspace.", + "@types/react-dom": "Single React DOM types resolution across the workspace.", + "@typescript-eslint/utils": "Match the API app's pin so the shared ESLint plugins resolve one utils version." + }, "overrides": { "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@typescript-eslint/utils": "8.60.0", - "tmp": "0.2.6" + "qs": "6.15.2", + "tmp": "0.2.6", + "ws": "8.21.0" } } diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 9df34fcc..3e3e86f9 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -18,6 +18,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `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. | diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 0d4f35e0..3e1de43a 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -73,6 +73,7 @@ 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 { checkWorkflow } from "./rules/ci/github-actions-permissions"; +export { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; export { checkUiEnvCascadeDrift } from "./rules/env/env-cascade-drift"; export { checkNoDirectImportMetaEnv } from "./rules/env/no-direct-import-meta-env"; export { checkNoSilentErrorSwallow } from "./rules/queries/no-silent-error-swallow"; diff --git a/apps/ui/scripts/lint-meta/context.ts b/apps/ui/scripts/lint-meta/context.ts index 6ab00353..8638e89d 100644 --- a/apps/ui/scripts/lint-meta/context.ts +++ b/apps/ui/scripts/lint-meta/context.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; import type { IMetaContext } from "./types"; @@ -85,6 +85,33 @@ export function findWorkflows(dir: string): string[] { return out; } +/* + * Workflows live at the app root when this template is a standalone repo, but + * in a monorepo checkout they live at the repository root. Walk up from the + * app root to the nearest `.github/workflows` so the CI rules + * (github-actions-permissions, github-actions-timeout-required) always scan + * the workflows that actually run for this code instead of silently no-oping. + */ +export function resolveWorkflowsDir(root: string): string { + let current = root; + + for (;;) { + const candidate = join(current, ".github", "workflows"); + + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + + if (parent === current) { + return join(root, ".github", "workflows"); + } + + current = parent; + } +} + export function buildContext(root: string): IMetaContext { const sourceFiles = [ ...SOURCE_DIRS.flatMap((dir) => @@ -99,6 +126,6 @@ export function buildContext(root: string): IMetaContext { return { root, sourceFiles, - workflowFiles: findWorkflows(join(root, ".github", "workflows")) + workflowFiles: findWorkflows(resolveWorkflowsDir(root)) }; } diff --git a/apps/ui/scripts/lint-meta/parsers/workflow.ts b/apps/ui/scripts/lint-meta/parsers/workflow.ts index c73539c9..e027482f 100644 --- a/apps/ui/scripts/lint-meta/parsers/workflow.ts +++ b/apps/ui/scripts/lint-meta/parsers/workflow.ts @@ -11,6 +11,9 @@ export interface IWorkflowStep { export interface IWorkflowJob { readonly steps?: IWorkflowStep[]; + readonly runsOn?: unknown; + readonly timeoutMinutes?: unknown; + readonly uses?: string; } export interface IWorkflow { @@ -40,8 +43,26 @@ function toWorkflowJob(value: unknown): IWorkflowJob | undefined { } let steps: IWorkflowStep[] | undefined; + let runsOn: unknown; + let timeoutMinutes: unknown; + let uses: string | undefined; for (const [k, v] of Object.entries(value)) { + if (k === "runs-on") { + runsOn = v; + continue; + } + + if (k === "timeout-minutes") { + timeoutMinutes = v; + continue; + } + + if (k === "uses" && typeof v === "string") { + uses = v; + continue; + } + if (k !== "steps" || !Array.isArray(v)) { continue; } @@ -59,7 +80,7 @@ function toWorkflowJob(value: unknown): IWorkflowJob | undefined { steps = collected; } - return { steps }; + return { steps, runsOn, timeoutMinutes, uses }; } function toWorkflowJobs( diff --git a/apps/ui/scripts/lint-meta/registry.ts b/apps/ui/scripts/lint-meta/registry.ts index 98b46e99..7a93b6fc 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 { enginePinParityRule } from "./rules/ci/engine-pin-parity"; 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"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; import { envCascadeDriftRule } from "./rules/env/env-cascade-drift"; @@ -25,6 +26,7 @@ export const META_RULES: readonly IMetaRule[] = [ noOverlappingLibsRule, // --- ci --- githubActionsPermissionsRule, + githubActionsTimeoutRequiredRule, prePushCiParityRule, enginePinParityRule, // --- env --- diff --git a/apps/ui/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts b/apps/ui/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts new file mode 100644 index 00000000..c04b9903 --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/ci/github-actions-timeout-required.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; + +import { parseWorkflow } from "../../parsers/workflow"; +import type { IMetaRule, IViolation } from "../../types"; + +export function checkWorkflowTimeouts(file: string): IViolation[] { + const violations: IViolation[] = []; + const { workflow, parseError } = parseWorkflow(readFileSync(file, "utf8")); + + if (workflow === null) { + violations.push({ + file, + rule: "github-actions-timeout-required", + message: `Could not parse YAML: ${parseError ?? "unknown error"}` + }); + + return violations; + } + + for (const [jobName, job] of Object.entries(workflow.jobs ?? {})) { + /* + * Reusable-workflow calls (job-level `uses:`) cannot set + * timeout-minutes; the called workflow owns its own job timeouts. + */ + if (typeof job.uses === "string") { + continue; + } + + if (job.timeoutMinutes === undefined || job.timeoutMinutes === null) { + violations.push({ + file, + rule: "github-actions-timeout-required", + message: `Job "${jobName}" has no \`timeout-minutes:\` — a hung step runs for GitHub's 6h default and blocks the PR check.` + }); + continue; + } + + if ( + typeof job.timeoutMinutes !== "number" || + !Number.isFinite(job.timeoutMinutes) || + job.timeoutMinutes <= 0 + ) { + violations.push({ + file, + rule: "github-actions-timeout-required", + message: `Job "${jobName}" has a non-numeric or non-positive \`timeout-minutes:\` value.` + }); + } + } + + return violations; +} + +/** + * Every runnable workflow job must declare an explicit `timeout-minutes:` so + * a hang fails fast instead of occupying a runner for GitHub's 6h default. + */ +export const githubActionsTimeoutRequiredRule: IMetaRule = { + id: "github-actions-timeout-required", + category: "ci", + description: + "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt).", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowTimeouts); + } +}; diff --git a/apps/ui/tests/lint-meta/fixtures/workflows-good/ci.yml b/apps/ui/tests/lint-meta/fixtures/workflows-good/ci.yml index a13ece89..cc0a5b7d 100644 --- a/apps/ui/tests/lint-meta/fixtures/workflows-good/ci.yml +++ b/apps/ui/tests/lint-meta/fixtures/workflows-good/ci.yml @@ -3,5 +3,6 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 diff --git a/apps/ui/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml b/apps/ui/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml new file mode 100644 index 00000000..d829cc19 --- /dev/null +++ b/apps/ui/tests/lint-meta/fixtures/workflows-no-timeout/ci.yml @@ -0,0 +1,9 @@ +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + reuse: + uses: ./.github/workflows/reusable.yml diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 07310fc7..1ab66062 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -23,6 +23,7 @@ import { checkTestFilesHaveSource, checkUiEnvCascadeDrift, checkWorkflow, + checkWorkflowTimeouts, collectSourceFiles, findWorkflows, parseDotenvKeys @@ -138,6 +139,24 @@ describe("checkWorkflow", () => { }); }); +describe("checkWorkflowTimeouts", () => { + test("flags a job missing timeout-minutes, exempts reusable-workflow calls", () => { + const workflows = findWorkflows(join(FIXTURES, "workflows-no-timeout")); + const v = workflows.flatMap(checkWorkflowTimeouts); + + expect(v).toHaveLength(1); + expect(v[0]?.rule).toBe("github-actions-timeout-required"); + expect(v[0]?.message).toContain('"test"'); + }); + + test("job with timeout-minutes passes", () => { + const workflows = findWorkflows(join(FIXTURES, "workflows-good")); + const v = workflows.flatMap(checkWorkflowTimeouts); + + expect(v).toEqual([]); + }); +}); + describe("collectSourceFiles", () => { test("skips tests/lint-meta subtree by default", () => { const repoRoot = join(FIXTURES, "../../..");