Skip to content

Commit 7b62f8c

Browse files
committed
fix(ci): require timeout-minutes on workflow jobs via new lint-meta rule
New github-actions-timeout-required rule in both apps' lint-meta; workflow discovery now walks up to the repo root .github/workflows in the monorepo layout, un-orphaning github-actions-permissions which silently scanned an empty set. Adds the two missing job timeouts the rule surfaced (apps-docs-linkcheck, infra-bootstrap-validate). Audit: F001
1 parent f25a192 commit 7b62f8c

19 files changed

Lines changed: 327 additions & 7 deletions

File tree

.github/workflows/apps-docs-linkcheck.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ jobs:
3838
run:
3939
working-directory: apps/docs
4040
runs-on: ubuntu-latest
41+
timeout-minutes: 20
4142
steps:
4243
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
4344

.github/workflows/infra-bootstrap-validate.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
run:
2121
working-directory: infra/bootstrap
2222
runs-on: ubuntu-latest
23+
timeout-minutes: 10
2324
steps:
2425
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2526

apps/api/scripts/lint-meta/RULES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi
1919
| `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. |
2020
| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. |
2121
| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). |
22+
| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). |
2223
| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. |
2324
| `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. |
2425
| `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. |

apps/api/scripts/lint-meta/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift";
2626
import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env";
2727
import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract";
2828
import { checkWorkflowShas } from "./rules/ci/github-actions-permissions";
29+
import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required";
2930
import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity";
3031
import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home";
3132
import { checkForbiddenText } from "./rules/source-text/forbidden-text";
@@ -103,4 +104,5 @@ export {
103104
checkSharedToolVersionParity,
104105
checkTouchedTests,
105106
checkWorkflowShas,
107+
checkWorkflowTimeouts,
106108
};

apps/api/scripts/lint-meta/context.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { readdirSync, statSync } from "node:fs";
2-
import { extname, join } from "node:path";
1+
import { existsSync, readdirSync, statSync } from "node:fs";
2+
import { dirname, extname, join } from "node:path";
33

44
import type { IMetaContext } from "./types";
55

@@ -85,6 +85,33 @@ export function findWorkflows(dir: string): string[] {
8585
return out;
8686
}
8787

88+
/*
89+
* Workflows live at the app root when this template is a standalone repo, but
90+
* in a monorepo checkout they live at the repository root. Walk up from the
91+
* app root to the nearest `.github/workflows` so the CI rules
92+
* (github-actions-permissions, github-actions-timeout-required) always scan
93+
* the workflows that actually run for this code instead of silently no-oping.
94+
*/
95+
export function resolveWorkflowsDir(root: string): string {
96+
let current = root;
97+
98+
for (;;) {
99+
const candidate = join(current, ".github", "workflows");
100+
101+
if (existsSync(candidate)) {
102+
return candidate;
103+
}
104+
105+
const parent = dirname(current);
106+
107+
if (parent === current) {
108+
return join(root, ".github", "workflows");
109+
}
110+
111+
current = parent;
112+
}
113+
}
114+
88115
export function buildContext(root: string): IMetaContext {
89116
const sourceFiles = [
90117
...SOURCE_DIRS.flatMap((dir) =>
@@ -99,6 +126,6 @@ export function buildContext(root: string): IMetaContext {
99126
return {
100127
root,
101128
sourceFiles,
102-
workflowFiles: findWorkflows(join(root, ".github", "workflows")),
129+
workflowFiles: findWorkflows(resolveWorkflowsDir(root)),
103130
};
104131
}

apps/api/scripts/lint-meta/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { generatedArtifactContractRule } from "./rules/artifacts/generated-artifact-contract";
22
import { enginePinParityRule } from "./rules/ci/engine-pin-parity";
33
import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions";
4+
import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required";
45
import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity";
56
import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn";
67
import { envCascadeDriftRule } from "./rules/env/env-cascade-drift";
@@ -22,6 +23,7 @@ export const META_RULES: readonly IMetaRule[] = [
2223
noOverlappingLibsRule,
2324
sharedToolVersionParityRule,
2425
githubActionsPermissionsRule,
26+
githubActionsTimeoutRequiredRule,
2527
prePushCiParityRule,
2628
enginePinParityRule,
2729
envCascadeDriftRule,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { readFileSync } from "node:fs";
2+
3+
import type { IMetaRule, IViolation } from "../../types";
4+
5+
const JOB_KEY_REGEX = /^ {2}([\w-]+):\s*(?:#.*)?$/u;
6+
const TOP_LEVEL_KEY_REGEX = /^\S/u;
7+
8+
interface IJobBlock {
9+
readonly name: string;
10+
readonly lines: readonly string[];
11+
}
12+
13+
/*
14+
* Line-based scan (same pragmatic idiom as github-actions-permissions):
15+
* collect each `jobs:` child block, then require a job-level
16+
* `timeout-minutes:` unless the job is a reusable-workflow call
17+
* (job-level `uses:` — those cannot set timeout-minutes).
18+
*/
19+
function collectJobBlocks(text: string): IJobBlock[] {
20+
const lines = text.split("\n");
21+
const blocks: IJobBlock[] = [];
22+
let inJobs = false;
23+
let current: { name: string; lines: string[] } | null = null;
24+
25+
for (const line of lines) {
26+
if (/^jobs:\s*(?:#.*)?$/u.test(line)) {
27+
inJobs = true;
28+
continue;
29+
}
30+
31+
if (!inJobs) {
32+
continue;
33+
}
34+
35+
if (TOP_LEVEL_KEY_REGEX.test(line)) {
36+
inJobs = false;
37+
38+
if (current !== null) {
39+
blocks.push(current);
40+
current = null;
41+
}
42+
43+
continue;
44+
}
45+
46+
const jobMatch = JOB_KEY_REGEX.exec(line);
47+
48+
if (jobMatch?.[1] !== undefined) {
49+
if (current !== null) {
50+
blocks.push(current);
51+
}
52+
53+
current = { name: jobMatch[1], lines: [] };
54+
continue;
55+
}
56+
57+
if (current !== null) {
58+
current.lines.push(line);
59+
}
60+
}
61+
62+
if (current !== null) {
63+
blocks.push(current);
64+
}
65+
66+
return blocks;
67+
}
68+
69+
export function checkWorkflowTimeouts(file: string): IViolation[] {
70+
const violations: IViolation[] = [];
71+
const text = readFileSync(file, "utf8");
72+
73+
for (const job of collectJobBlocks(text)) {
74+
const isReusableCall = job.lines.some((line) =>
75+
/^ {4}uses:\s*\S/u.test(line)
76+
);
77+
78+
if (isReusableCall) {
79+
continue;
80+
}
81+
82+
const hasTimeout = job.lines.some((line) =>
83+
/^ {4}timeout-minutes:\s*[1-9]\d*\s*(?:#.*)?$/u.test(line)
84+
);
85+
86+
if (!hasTimeout) {
87+
violations.push({
88+
file,
89+
rule: "github-actions-timeout-required",
90+
message: `Job "${job.name}" has no job-level \`timeout-minutes:\` — a hung step runs for GitHub's 6h default and blocks the PR check.`,
91+
});
92+
}
93+
}
94+
95+
return violations;
96+
}
97+
98+
/**
99+
* Every runnable workflow job must declare an explicit `timeout-minutes:` so
100+
* a hang fails fast instead of occupying a runner for GitHub's 6h default.
101+
*/
102+
export const githubActionsTimeoutRequiredRule: IMetaRule = {
103+
id: "github-actions-timeout-required",
104+
category: "ci",
105+
description:
106+
"GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt).",
107+
run({ workflowFiles }) {
108+
return workflowFiles.flatMap(checkWorkflowTimeouts);
109+
},
110+
};

apps/api/tests/lint-meta/fixtures/workflows-good/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ permissions:
44
jobs:
55
test:
66
runs-on: ubuntu-latest
7+
timeout-minutes: 5
78
steps:
89
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
910
- run: echo hi
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
permissions:
2+
contents: read
3+
jobs:
4+
test:
5+
runs-on: ubuntu-latest
6+
steps:
7+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
8+
reuse:
9+
uses: ./.github/workflows/reusable.yml

apps/api/tests/lint-meta/lint-meta.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
checkRouteFilesHaveTests,
2626
checkTouchedTests,
2727
checkWorkflowShas,
28+
checkWorkflowTimeouts,
2829
collectSourceFiles,
2930
findWorkflows,
3031
checkGeneratedArtifactContracts,
@@ -191,6 +192,24 @@ describe("checkWorkflowShas", () => {
191192
});
192193
});
193194

195+
describe("checkWorkflowTimeouts", () => {
196+
test("flags a job missing timeout-minutes, exempts reusable-workflow calls", () => {
197+
const workflows = findWorkflows(join(FIXTURES, "workflows-no-timeout"));
198+
const violations = workflows.flatMap(checkWorkflowTimeouts);
199+
200+
expect(violations).toHaveLength(1);
201+
expect(violations[0]?.rule).toBe("github-actions-timeout-required");
202+
expect(violations[0]?.message).toContain('"test"');
203+
});
204+
205+
test("job with timeout-minutes passes", () => {
206+
const workflows = findWorkflows(join(FIXTURES, "workflows-good"));
207+
const violations = workflows.flatMap(checkWorkflowTimeouts);
208+
209+
expect(violations).toEqual([]);
210+
});
211+
});
212+
194213
describe("checkEnvSchemaDrift", () => {
195214
test("aligned schema and .env.example produces no violations", () => {
196215
const violations = checkEnvSchemaDrift(join(FIXTURES, "env-cascade-clean"));

0 commit comments

Comments
 (0)