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-docs-linkcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/infra-bootstrap-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 4 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -98,9 +100,11 @@ export {
checkLogicFilesHaveTests,
checkNoDirectProcessEnv,
checkNoRawRoleLiterals,
checkPackageOverrideParity,
checkPrePushParity,
checkRouteFilesHaveTests,
checkSharedToolVersionParity,
checkTouchedTests,
checkWorkflowShas,
checkWorkflowTimeouts,
};
33 changes: 30 additions & 3 deletions apps/api/scripts/lint-meta/context.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) =>
Expand All @@ -99,6 +126,6 @@ export function buildContext(root: string): IMetaContext {
return {
root,
sourceFiles,
workflowFiles: findWorkflows(join(root, ".github", "workflows")),
workflowFiles: findWorkflows(resolveWorkflowsDir(root)),
};
}
4 changes: 4 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -20,8 +22,10 @@ import type { IMetaRule } from "./types";
export const META_RULES: readonly IMetaRule[] = [
packageJsonExactDepsRule,
noOverlappingLibsRule,
packageOverrideParityRule,
sharedToolVersionParityRule,
githubActionsPermissionsRule,
githubActionsTimeoutRequiredRule,
prePushCiParityRule,
enginePinParityRule,
envCascadeDriftRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
};
Loading
Loading