From 6c9fc66f501d1fec4fbb01b0d236dd6ab900c33b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 10 Jun 2026 13:59:01 +0200 Subject: [PATCH 1/4] fix(ci): enforce push-paths vs paths-filter parity across workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lint-meta rule github-actions-paths-filter-parity: workflows pairing a push.paths trigger with an in-job dorny/paths-filter gate must keep the two path sets mutually glob-covered. Drift produces silent failure modes: a push-only path starts the workflow but every gated step no-ops (change lands unverified); a filter-only path never triggers on direct pushes to main. The rule surfaced and this commit fixes three drifted workflows: - apps-docs-linkcheck: wrangler.jsonc/bunfig.toml/osv-scanner.toml were in push.paths but missing from the docs filter — a config-only change skipped the rebuild the workflow's own comment promises. - apps-docs-security-deps: filter narrowed to dep files while push covered apps/docs/** (and osv-scanner.toml was gated out); now mirrors the broad api/ui sibling shape. - infra-compose-validate-compose: setup.sh was push-triggered but absent from every job filter (shellcheck on it never ran on setup.sh-only changes); yamllint lints all workflows but only triggered on edits to itself — push now covers .github/workflows/**. Audit: F001 --- .github/workflows/apps-docs-linkcheck.yml | 3 + .github/workflows/apps-docs-security-deps.yml | 4 +- .../infra-compose-validate-compose.yml | 5 +- apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../ci/github-actions-paths-filter-parity.ts | 163 ++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 92 ++++++++++ apps/docs/src/data/lint-meta-catalog.json | 6 + 9 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-parity.ts diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index 18a67fbd..55c06e43 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -68,6 +68,9 @@ jobs: - 'apps/docs/astro.config.mjs' - 'apps/docs/package.json' - 'apps/docs/bun.lock' + - 'apps/docs/wrangler.jsonc' + - 'apps/docs/bunfig.toml' + - 'apps/docs/osv-scanner.toml' - '.github/workflows/apps-docs-linkcheck.yml' catalog: - 'apps/api/scripts/**' diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index abea68a8..18eefeff 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -41,9 +41,7 @@ jobs: with: filters: | code: - - 'apps/docs/package.json' - - 'apps/docs/bun.lock' - - 'apps/docs/scripts/**' + - 'apps/docs/**' - '.github/workflows/apps-docs-security-deps.yml' - name: Install osv-scanner CLI diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 60623079..b51ef61f 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -14,7 +14,9 @@ on: # trigger in lockstep so direct pushes touching them still run it. - "scripts/**" - "setup.sh" - - ".github/workflows/infra-compose-validate-compose.yml" + # The yamllint job lints every workflow file, so any workflow edit + # must trigger this run — not just edits to this file. + - ".github/workflows/**" # Prod Dockerfiles COPY the whole app context, so any app source # change can break the prod image build — not just Dockerfile or # lockfile edits. @@ -221,6 +223,7 @@ jobs: code: - 'infra/compose/**' - 'scripts/**' + - 'setup.sh' - '.github/workflows/**' - name: ShellCheck diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 68587ab5..48572b3d 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -23,6 +23,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | | `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | +| `github-actions-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | | `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | | `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | | `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 4a876b47..db2d7115 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -35,6 +35,7 @@ import { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; import { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache"; import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; +import { checkWorkflowPathsFilterParity } from "./rules/ci/github-actions-paths-filter-parity"; import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel"; import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; @@ -137,6 +138,7 @@ export { checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, + checkWorkflowPathsFilterParity, checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index fda3d087..050829ff 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -4,6 +4,7 @@ import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache"; import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; +import { githubActionsPathsFilterParityRule } from "./rules/ci/github-actions-paths-filter-parity"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsSecurityNoCancelRule } from "./rules/ci/github-actions-security-no-cancel"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; @@ -43,6 +44,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsTimeoutRequiredRule, githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, + githubActionsPathsFilterParityRule, githubActionsSecurityNoCancelRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-parity.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-parity.ts new file mode 100644 index 00000000..f5a8ca01 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-parity.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const RULE_ID = "github-actions-paths-filter-parity"; + +/* + * Workflows that pair a `push.paths` trigger with an in-job + * dorny/paths-filter gate (PR triggers stay unfiltered so branch + * protection always gets a status) have two lists describing the same + * intent. When they drift, one of two silent failure modes appears: + * + * - a push path no filter entry covers: pushes start the workflow but + * every gated step no-ops, so the change lands unverified; + * - a filter entry no push path covers: the gated work runs on PRs but + * never on direct pushes to main. + * + * Coverage is glob-aware in the only form these workflows use: an exact + * match, or a `/**` entry covering anything under that prefix. + */ +function covers(glob: string, target: string): boolean { + if (glob === target || glob === "**") { + return true; + } + + if (glob.endsWith("/**")) { + const prefix = glob.slice(0, -3); + + return target === prefix || target.startsWith(`${prefix}/`); + } + + return false; +} + +function coveredByAny(globs: readonly string[], target: string): boolean { + return globs.some((glob) => covers(glob, target)); +} + +const LIST_ENTRY_REGEX = /^\s+- ["']([^"']+)["']\s*$/u; +const FILTER_GROUP_KEY_REGEX = /^\s+[\w-]+:\s*$/u; + +function isListInterruption(line: string): boolean { + return line.trim() !== "" && !line.trim().startsWith("#"); +} + +export function collectPushPaths(lines: readonly string[]): string[] { + const pushPaths: string[] = []; + let inPush = false; + let inPaths = false; + + for (const line of lines) { + if (/^\S/u.test(line) || (inPush && /^ {2}\w/u.test(line))) { + inPush = false; + inPaths = false; + } + + if (/^ {2}push:\s*(?:#.*)?$/u.test(line)) { + inPush = true; + continue; + } + + if (inPush && /^\s+paths:\s*(?:#.*)?$/u.test(line)) { + inPaths = true; + continue; + } + + if (!inPush || !inPaths) { + continue; + } + + const entry = LIST_ENTRY_REGEX.exec(line)?.[1]; + + if (entry !== undefined) { + pushPaths.push(entry); + } else if (isListInterruption(line)) { + inPaths = false; + } + } + + return pushPaths; +} + +export function collectFilterEntries(lines: readonly string[]): string[] { + const filterPaths: string[] = []; + let inFilters = false; + + for (const line of lines) { + if (/^\s+filters: \|\s*$/u.test(line)) { + inFilters = true; + continue; + } + + if (!inFilters) { + continue; + } + + const entry = LIST_ENTRY_REGEX.exec(line)?.[1]; + + if (entry !== undefined) { + filterPaths.push(entry); + } else if (!FILTER_GROUP_KEY_REGEX.test(line) && isListInterruption(line)) { + inFilters = false; + } + } + + return filterPaths; +} + +export function checkWorkflowPathsFilterParity(file: string): IViolation[] { + const lines = readFileSync(file, "utf8").split("\n"); + const pushPaths = collectPushPaths(lines); + const filterPaths = [...new Set(collectFilterEntries(lines))]; + + /* + * The rule only relates the two lists; a workflow with one or neither + * (push-only triggers, PR-only filter gating) has nothing to compare. + * Negated filter entries invert coverage semantics this scan cannot + * model, so bail rather than report on guesses. + */ + if ( + pushPaths.length === 0 || + filterPaths.length === 0 || + filterPaths.some((entry) => entry.startsWith("!")) + ) { + return []; + } + + const pushOnly = pushPaths.filter( + (pushPath) => !coveredByAny(filterPaths, pushPath) + ); + const filterOnly = filterPaths.filter( + (filterPath) => !coveredByAny(pushPaths, filterPath) + ); + + return [ + ...pushOnly.map((pushPath) => ({ + file, + rule: RULE_ID, + message: `push trigger path '${pushPath}' is not covered by any paths-filter entry — pushes touching it start the workflow but every filter-gated step no-ops, so the change lands unverified. Add it to a filter block (or drop it from push.paths).`, + })), + ...filterOnly.map((filterPath) => ({ + file, + rule: RULE_ID, + message: `paths-filter entry '${filterPath}' is not covered by push.paths — the work it gates runs on PRs but never on direct pushes to main. Add a covering push trigger path (or drop the filter entry).`, + })), + ]; +} + +/** + * push.paths and the in-job dorny/paths-filter lists describe the same + * intent; when they drift, changes either land unverified (push-only + * path → all gated steps no-op) or skip main-branch validation entirely + * (filter-only path → workflow never starts on push). + */ +export const githubActionsPathsFilterParityRule: IMetaRule = { + id: RULE_ID, + category: "ci", + description: + "Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowPathsFilterParity); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 69a0efef..78db8bfc 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -35,6 +35,7 @@ import { checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, + checkWorkflowPathsFilterParity, checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, @@ -561,6 +562,97 @@ describe("checkWorkflowConcurrencyExplicit", () => { }); }); +describe("checkWorkflowPathsFilterParity", () => { + test("flags push-only and filter-only path drift in both directions", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-filter-parity-")); + + try { + const file = writeNamedWorkflow( + root, + "wf.yml", + [ + "on:", + " push:", + " branches: [main]", + " paths:", + ' - "apps/x/**"', + ' - "setup.sh"', + " pull_request: {}", + "", + "jobs:", + " scan:", + " steps:", + " - uses: dorny/paths-filter@abc", + " with:", + " filters: |", + " code:", + " - 'apps/x/**'", + " - '.github/workflows/**'", + "", + ].join("\n") + ); + + const messages = checkWorkflowPathsFilterParity(file).map( + (row) => row.message + ); + + expect(messages).toHaveLength(2); + expect(messages.some((m) => m.includes("'setup.sh'"))).toBe(true); + expect(messages.some((m) => m.includes("'.github/workflows/**'"))).toBe( + true + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes glob-covered parity and skips workflows missing either list", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-filter-parity-")); + + try { + const covered = writeNamedWorkflow( + root, + "wf.yml", + [ + "on:", + " push:", + " branches: [main]", + " paths:", + ' - "apps/x/**"', + " # comment between entries must not end the list", + ' - ".github/workflows/wf.yml"', + " pull_request: {}", + "", + "jobs:", + " scan:", + " steps:", + " - uses: dorny/paths-filter@abc", + " with:", + " filters: |", + " code:", + " - 'apps/x/sub/**'", + " - 'apps/x/**'", + " other:", + " - '.github/workflows/wf.yml'", + "", + ].join("\n") + ); + + expect(checkWorkflowPathsFilterParity(covered)).toEqual([]); + + const pushOnly = writeNamedWorkflow( + root, + "push-only.yml", + 'on:\n push:\n paths:\n - "apps/x/**"\n\njobs: {}\n' + ); + + expect(checkWorkflowPathsFilterParity(pushOnly)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkWorkflowSecurityNoCancel", () => { test("flags a security workflow with cancel-in-progress: true", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-secnocancel-")); diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 1db4249c..045e4f3c 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -308,6 +308,12 @@ "ciCritical": false, "description": "Workflows with a concurrency block must set cancel-in-progress explicitly." }, + { + "id": "github-actions-paths-filter-parity", + "category": "ci", + "ciCritical": false, + "description": "Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered." + }, { "id": "github-actions-security-no-cancel", "category": "ci", From 39354331a1e89ae22bc0844456eeb00caffd499a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 10 Jun 2026 14:03:02 +0200 Subject: [PATCH 2/4] fix(ci): pin yamllint in CI and warn on local version drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yamllint was installed job-time via unpinned 'pip install --user yamllint', so the compose/workflow lint gate silently tracked the latest PyPI release — rule changes upstream could flip CI with no repo diff. Pin it to 1.38.0 via a YAMLLINT_VERSION env var, and have the local pre-push gate read that pin at runtime (same single-source idiom as GITLEAKS_VERSION) to warn when the brew-installed yamllint diverges. New lint-meta rule github-actions-pip-install-pinned blocks the class: any job-time pip install of a bare package name without an == pin. Audit: F003 --- .../infra-compose-validate-compose.yml | 7 +- apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../ci/github-actions-pip-install-pinned.ts | 67 +++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 49 ++++++++++++++ apps/docs/src/data/lint-meta-catalog.json | 6 ++ infra/compose/scripts/pre-push.sh | 10 +++ 8 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 apps/api/scripts/lint-meta/rules/ci/github-actions-pip-install-pinned.ts diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index b51ef61f..6330366a 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -299,6 +299,11 @@ jobs: name: yamllint (compose + workflows) runs-on: ubuntu-latest timeout-minutes: 2 + env: + # Single source for the yamllint pin — the local pre-push gate + # (infra/compose/scripts/pre-push.sh) reads this value at runtime to + # warn when the brew-installed yamllint diverges from CI. + YAMLLINT_VERSION: "1.38.0" steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -316,7 +321,7 @@ jobs: - name: yamllint if: steps.filter.outputs.code == 'true' run: | - pip install --user yamllint + pip install --user "yamllint==${YAMLLINT_VERSION}" # Document syntax (line length, indentation, truthy values) but # don't fail on style nits — only on real syntax errors. ~/.local/bin/yamllint -d "{extends: relaxed, rules: {line-length: disable}}" \ diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 48572b3d..f3ccbd9f 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -24,6 +24,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `github-actions-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | +| `github-actions-pip-install-pinned` | ci | no | Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases. | | `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | | `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | | `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index db2d7115..b61428fd 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -36,6 +36,7 @@ import { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache"; import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; import { checkWorkflowPathsFilterParity } from "./rules/ci/github-actions-paths-filter-parity"; +import { checkWorkflowPipInstallPinned } from "./rules/ci/github-actions-pip-install-pinned"; import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel"; import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; @@ -139,6 +140,7 @@ export { checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowPathsFilterParity, + checkWorkflowPipInstallPinned, checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 050829ff..0ea7037d 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -5,6 +5,7 @@ import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache"; import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; import { githubActionsPathsFilterParityRule } from "./rules/ci/github-actions-paths-filter-parity"; +import { githubActionsPipInstallPinnedRule } from "./rules/ci/github-actions-pip-install-pinned"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsSecurityNoCancelRule } from "./rules/ci/github-actions-security-no-cancel"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; @@ -45,6 +46,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, githubActionsPathsFilterParityRule, + githubActionsPipInstallPinnedRule, githubActionsSecurityNoCancelRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-pip-install-pinned.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-pip-install-pinned.ts new file mode 100644 index 00000000..2731c692 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-pip-install-pinned.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const RULE_ID = "github-actions-pip-install-pinned"; + +const PIP_INSTALL_REGEX = /\bpip3?\s+install\s+(?[^#]*)/u; + +/* + * A `pip install ` step with no `==` pin runs whatever PyPI + * published last night: a linter's ruleset (yamllint, semgrep, …) can + * change between CI runs with zero repo diff, flipping the gate without + * a commit to blame. Every other version in this repo is exact-pinned + * (deps, actions by SHA, scanner versions) — job-time pip installs get + * the same bar. Flags, requirement files, and local paths/URLs are out + * of scope; bare package names must carry `==` (a `${VAR}` + * interpolation counts — the pin then lives in an env var the local + * pre-push gate can read, like GITLEAKS_VERSION). + */ +export function checkWorkflowPipInstallPinned(file: string): IViolation[] { + const violations: IViolation[] = []; + const lines = readFileSync(file, "utf8").split("\n"); + + for (const line of lines) { + const args = PIP_INSTALL_REGEX.exec(line)?.groups?.args; + + if (args === undefined) { + continue; + } + + const unpinned = args + .trim() + .split(/\s+/u) + .filter( + (token) => + token !== "" && + !token.startsWith("-") && + !token.includes("==") && + !token.includes("/") && + !token.endsWith(".txt") + ); + + for (const pkg of unpinned) { + violations.push({ + file, + rule: RULE_ID, + message: `pip install '${pkg}' has no version pin — CI silently tracks the latest PyPI release and its ruleset can change between runs with no repo diff. Pin it (e.g. ${pkg}==X.Y.Z, ideally via an env var the local pre-push gate can read).`, + }); + } + } + + return violations; +} + +/** + * Job-time `pip install` without `==` tracks latest-from-PyPI, so a tool's + * behavior can flip between CI runs with no commit; pin like everything else. + */ +export const githubActionsPipInstallPinnedRule: IMetaRule = { + id: RULE_ID, + category: "ci", + description: + "Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowPipInstallPinned); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 78db8bfc..77dfed4e 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -36,6 +36,7 @@ import { checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowPathsFilterParity, + checkWorkflowPipInstallPinned, checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, @@ -653,6 +654,54 @@ describe("checkWorkflowPathsFilterParity", () => { }); }); +describe("checkWorkflowPipInstallPinned", () => { + test("flags pip install without a version pin", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-pip-pin-")); + + try { + const file = writeNamedWorkflow( + root, + "wf.yml", + "jobs:\n lint:\n steps:\n - run: |\n pip install --user yamllint\n" + ); + + const messages = checkWorkflowPipInstallPinned(file).map( + (row) => row.message + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("'yamllint'"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes == pins, env-var pins, flags, and requirement files", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-pip-pin-")); + + try { + const file = writeNamedWorkflow( + root, + "wf.yml", + [ + "jobs:", + " lint:", + " steps:", + " - run: |", + " pip install --user yamllint==1.38.0", + ' pip3 install "semgrep==${SEMGREP_VERSION}"', + " pip install -r requirements.txt", + "", + ].join("\n") + ); + + expect(checkWorkflowPipInstallPinned(file)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkWorkflowSecurityNoCancel", () => { test("flags a security workflow with cancel-in-progress: true", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-secnocancel-")); diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 045e4f3c..3c2e8c1c 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -314,6 +314,12 @@ "ciCritical": false, "description": "Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered." }, + { + "id": "github-actions-pip-install-pinned", + "category": "ci", + "ciCritical": false, + "description": "Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases." + }, { "id": "github-actions-security-no-cancel", "category": "ci", diff --git a/infra/compose/scripts/pre-push.sh b/infra/compose/scripts/pre-push.sh index ce4e1c94..e8d5f271 100755 --- a/infra/compose/scripts/pre-push.sh +++ b/infra/compose/scripts/pre-push.sh @@ -83,6 +83,16 @@ step "4/4 yamllint" if ! command -v yamllint >/dev/null 2>&1; then c_blue " skipped — yamllint not installed (brew install yamllint). CI still runs it." else + # Single-source the version from the CI workflow (same idiom as the + # gitleaks check in scripts/ci/pre-push-security.sh): warn when the + # local yamllint diverges from the CI pin — rulesets change between + # releases, so a version gap means lint results can differ from CI. + YAMLLINT_WORKFLOW="$ROOT/.github/workflows/infra-compose-validate-compose.yml" + EXPECTED_YAMLLINT_VERSION="$(grep -m1 'YAMLLINT_VERSION:' "$YAMLLINT_WORKFLOW" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)" + LOCAL_YAMLLINT_VERSION="$(yamllint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + if [[ -n "$EXPECTED_YAMLLINT_VERSION" && "$LOCAL_YAMLLINT_VERSION" != "$EXPECTED_YAMLLINT_VERSION" ]]; then + c_blue " ⚠ local yamllint ${LOCAL_YAMLLINT_VERSION:-unknown} != CI-pinned ${EXPECTED_YAMLLINT_VERSION} — lint results may differ from CI (brew upgrade yamllint)." + fi # Mirror CI exactly (validate-compose.yml yamllint job): same relaxed # config, same targets. Workflows live at the repo root — the old # $INFRA_ROOT/.github/workflows glob matched nothing, so workflow YAML From 669c329aa94ca9bbf236d0872daaa03a71fda32e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 10 Jun 2026 14:05:02 +0200 Subject: [PATCH 3/4] fix(ci): pin all workflow runners to ubuntu-24.04 All 23 workflows ran on floating ubuntu-latest while everything else in the repo is exact-pinned (deps, action SHAs, scanner versions, bun). The runner image migrates on GitHub's schedule, so preinstalled tool versions and OS packages change between runs of the same SHA with no commit to blame. New lint-meta rule github-actions-runner-pinned blocks the class: any runs-on label ending in -latest fails lint:meta (expression labels for matrix strategies are exempt). 26 instances pinned to ubuntu-24.04. Audit: F002 --- .github/workflows/apps-api-acl-drift.yml | 2 +- .github/workflows/apps-api-ci.yml | 2 +- .github/workflows/apps-api-openapi-drift.yml | 2 +- .github/workflows/apps-api-release.yml | 2 +- .github/workflows/apps-api-security-deps.yml | 2 +- .github/workflows/apps-api-security-sast.yml | 2 +- .../workflows/apps-api-security-secrets.yml | 2 +- .github/workflows/apps-docs-linkcheck.yml | 2 +- .github/workflows/apps-docs-security-deps.yml | 2 +- .../workflows/apps-docs-security-secrets.yml | 2 +- .github/workflows/apps-ui-bundle-diff.yml | 2 +- .github/workflows/apps-ui-release.yml | 2 +- .github/workflows/apps-ui-security-deps.yml | 2 +- .github/workflows/apps-ui-security-sast.yml | 2 +- .../workflows/apps-ui-security-secrets.yml | 2 +- .github/workflows/apps-ui-validate.yml | 2 +- .../infra-bootstrap-security-deps.yml | 2 +- .../infra-bootstrap-security-secrets.yml | 2 +- .../workflows/infra-bootstrap-validate.yml | 2 +- .../infra-compose-full-stack-smoke.yml | 2 +- .../infra-compose-playwright-e2e.yml | 2 +- .../infra-compose-security-secrets.yml | 2 +- .../infra-compose-validate-compose.yml | 8 +-- apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../rules/ci/github-actions-runner-pinned.ts | 53 +++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 49 +++++++++++++++++ apps/docs/src/data/lint-meta-catalog.json | 6 +++ 29 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts diff --git a/.github/workflows/apps-api-acl-drift.yml b/.github/workflows/apps-api-acl-drift.yml index bbbddde7..149a9ce3 100644 --- a/.github/workflows/apps-api-acl-drift.yml +++ b/.github/workflows/apps-api-acl-drift.yml @@ -31,7 +31,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/apps-api-ci.yml b/.github/workflows/apps-api-ci.yml index cafe1951..c92d3f64 100644 --- a/.github/workflows/apps-api-ci.yml +++ b/.github/workflows/apps-api-ci.yml @@ -23,7 +23,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 services: diff --git a/.github/workflows/apps-api-openapi-drift.yml b/.github/workflows/apps-api-openapi-drift.yml index 16ed3431..6df94393 100644 --- a/.github/workflows/apps-api-openapi-drift.yml +++ b/.github/workflows/apps-api-openapi-drift.yml @@ -20,7 +20,7 @@ permissions: jobs: drift: name: apps/ui OpenAPI schema is fresh - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 8 # The job always runs (so the required-status-check rule on `main` is diff --git a/.github/workflows/apps-api-release.yml b/.github/workflows/apps-api-release.yml index d83a4ff1..15fcb1bc 100644 --- a/.github/workflows/apps-api-release.yml +++ b/.github/workflows/apps-api-release.yml @@ -29,7 +29,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/apps-api-security-deps.yml b/.github/workflows/apps-api-security-deps.yml index ff3d1699..ab792b52 100644 --- a/.github/workflows/apps-api-security-deps.yml +++ b/.github/workflows/apps-api-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-api-security-sast.yml b/.github/workflows/apps-api-security-sast.yml index 557c84eb..dea4fe83 100644 --- a/.github/workflows/apps-api-security-sast.yml +++ b/.github/workflows/apps-api-security-sast.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 container: image: semgrep/semgrep:1.142.0@sha256:03402a5040a88a570dec58375ef1a19fa777dd61575afdc7d5527ddf308dd765 diff --git a/.github/workflows/apps-api-security-secrets.yml b/.github/workflows/apps-api-security-secrets.yml index 4c1fdfc1..0d87ea6f 100644 --- a/.github/workflows/apps-api-security-secrets.yml +++ b/.github/workflows/apps-api-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index 55c06e43..ba6bfaa0 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -52,7 +52,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index 18eefeff..fd7f78c3 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-docs-security-secrets.yml b/.github/workflows/apps-docs-security-secrets.yml index d5f59136..6c5329aa 100644 --- a/.github/workflows/apps-docs-security-secrets.yml +++ b/.github/workflows/apps-docs-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-ui-bundle-diff.yml b/.github/workflows/apps-ui-bundle-diff.yml index bc83e6d2..d889db18 100644 --- a/.github/workflows/apps-ui-bundle-diff.yml +++ b/.github/workflows/apps-ui-bundle-diff.yml @@ -32,7 +32,7 @@ jobs: # straight bun keeps the toolchain consistent with local dev. size-diff: if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 8 steps: - name: Checkout diff --git a/.github/workflows/apps-ui-release.yml b/.github/workflows/apps-ui-release.yml index d541b199..e9cd92c1 100644 --- a/.github/workflows/apps-ui-release.yml +++ b/.github/workflows/apps-ui-release.yml @@ -34,7 +34,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/apps-ui-security-deps.yml b/.github/workflows/apps-ui-security-deps.yml index 8ec3b292..c2b3dfca 100644 --- a/.github/workflows/apps-ui-security-deps.yml +++ b/.github/workflows/apps-ui-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-ui-security-sast.yml b/.github/workflows/apps-ui-security-sast.yml index d320dd98..1cab9386 100644 --- a/.github/workflows/apps-ui-security-sast.yml +++ b/.github/workflows/apps-ui-security-sast.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 container: image: semgrep/semgrep:1.142.0@sha256:03402a5040a88a570dec58375ef1a19fa777dd61575afdc7d5527ddf308dd765 diff --git a/.github/workflows/apps-ui-security-secrets.yml b/.github/workflows/apps-ui-security-secrets.yml index 1d8a7174..c9f90071 100644 --- a/.github/workflows/apps-ui-security-secrets.yml +++ b/.github/workflows/apps-ui-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/apps-ui-validate.yml b/.github/workflows/apps-ui-validate.yml index c04890de..08b88760 100644 --- a/.github/workflows/apps-ui-validate.yml +++ b/.github/workflows/apps-ui-validate.yml @@ -21,7 +21,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout diff --git a/.github/workflows/infra-bootstrap-security-deps.yml b/.github/workflows/infra-bootstrap-security-deps.yml index 09a1d9fb..57178156 100644 --- a/.github/workflows/infra-bootstrap-security-deps.yml +++ b/.github/workflows/infra-bootstrap-security-deps.yml @@ -27,7 +27,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/infra-bootstrap-security-secrets.yml b/.github/workflows/infra-bootstrap-security-secrets.yml index 8ede109d..4b589a43 100644 --- a/.github/workflows/infra-bootstrap-security-secrets.yml +++ b/.github/workflows/infra-bootstrap-security-secrets.yml @@ -28,7 +28,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/infra-bootstrap-validate.yml b/.github/workflows/infra-bootstrap-validate.yml index 2ab4f34b..c24404bb 100644 --- a/.github/workflows/infra-bootstrap-validate.yml +++ b/.github/workflows/infra-bootstrap-validate.yml @@ -19,7 +19,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/infra-compose-full-stack-smoke.yml b/.github/workflows/infra-compose-full-stack-smoke.yml index eece66ae..b542fb2c 100644 --- a/.github/workflows/infra-compose-full-stack-smoke.yml +++ b/.github/workflows/infra-compose-full-stack-smoke.yml @@ -39,7 +39,7 @@ permissions: jobs: smoke: name: register → login → /me through the full stack - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # 8 min upper bound: with Playwright moved out, the curl-only path # completes in ~3–4 min on a warm runner. A short ceiling means a # stuck step fails fast instead of running for 20 min like the old diff --git a/.github/workflows/infra-compose-playwright-e2e.yml b/.github/workflows/infra-compose-playwright-e2e.yml index fdadbea0..d1a89d97 100644 --- a/.github/workflows/infra-compose-playwright-e2e.yml +++ b/.github/workflows/infra-compose-playwright-e2e.yml @@ -39,7 +39,7 @@ permissions: jobs: playwright: name: UI Playwright E2E (browser-based smoke) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # 15 min upper bound: Playwright's own inner timeout is 12 min; the # buffer covers compose build + image pulls + browser install. A # genuine hang surfaces within minutes of the inner timeout rather diff --git a/.github/workflows/infra-compose-security-secrets.yml b/.github/workflows/infra-compose-security-secrets.yml index 56773d4c..127839f7 100644 --- a/.github/workflows/infra-compose-security-secrets.yml +++ b/.github/workflows/infra-compose-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: infra/compose - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 6330366a..d198547a 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -35,7 +35,7 @@ permissions: jobs: compose-config: name: docker compose config (all overlay combinations) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 # Build contexts point at monorepo apps (`../../../apps/api` and @@ -209,7 +209,7 @@ jobs: shellcheck: name: shellcheck (scripts/) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - name: Checkout @@ -241,7 +241,7 @@ jobs: prod-image-build: name: build prod images (sanity) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 # Cheaper than booting the prod profile end-to-end, but still # exercises the actual prod Dockerfiles. Catches broken @@ -297,7 +297,7 @@ jobs: yamllint: name: yamllint (compose + workflows) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 2 env: # Single source for the yamllint pin — the local pre-push gate diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index f3ccbd9f..e3871b0e 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -25,6 +25,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `github-actions-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | | `github-actions-pip-install-pinned` | ci | no | Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases. | +| `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels. | | `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | | `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | | `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index b61428fd..cb177fc3 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -37,6 +37,7 @@ import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-conc import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; import { checkWorkflowPathsFilterParity } from "./rules/ci/github-actions-paths-filter-parity"; import { checkWorkflowPipInstallPinned } from "./rules/ci/github-actions-pip-install-pinned"; +import { checkWorkflowRunnerPinned } from "./rules/ci/github-actions-runner-pinned"; import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel"; import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; @@ -141,6 +142,7 @@ export { checkWorkflowExpressionSyntax, checkWorkflowPathsFilterParity, checkWorkflowPipInstallPinned, + checkWorkflowRunnerPinned, checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 0ea7037d..0818143e 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -6,6 +6,7 @@ import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions- import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; import { githubActionsPathsFilterParityRule } from "./rules/ci/github-actions-paths-filter-parity"; import { githubActionsPipInstallPinnedRule } from "./rules/ci/github-actions-pip-install-pinned"; +import { githubActionsRunnerPinnedRule } from "./rules/ci/github-actions-runner-pinned"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsSecurityNoCancelRule } from "./rules/ci/github-actions-security-no-cancel"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; @@ -47,6 +48,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsConcurrencyExplicitRule, githubActionsPathsFilterParityRule, githubActionsPipInstallPinnedRule, + githubActionsRunnerPinnedRule, githubActionsSecurityNoCancelRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts new file mode 100644 index 00000000..5e5ef8ec --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const RULE_ID = "github-actions-runner-pinned"; + +const RUNS_ON_REGEX = /^\s*runs-on:\s*(?