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 18a67fbd..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 @@ -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..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: @@ -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/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 60623079..d198547a 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. @@ -33,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 @@ -207,7 +209,7 @@ jobs: shellcheck: name: shellcheck (scripts/) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - name: Checkout @@ -221,6 +223,7 @@ jobs: code: - 'infra/compose/**' - 'scripts/**' + - 'setup.sh' - '.github/workflows/**' - name: ShellCheck @@ -238,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 @@ -294,8 +297,13 @@ 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 + # (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 @@ -313,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 68587ab5..e3871b0e 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -23,6 +23,9 @@ 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-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 4a876b47..cb177fc3 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -35,6 +35,9 @@ 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 { 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"; @@ -137,6 +140,9 @@ export { checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, 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 fda3d087..0818143e 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -4,6 +4,9 @@ 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 { 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"; @@ -43,6 +46,9 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsTimeoutRequiredRule, githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, + githubActionsPathsFilterParityRule, + githubActionsPipInstallPinnedRule, + githubActionsRunnerPinnedRule, 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/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/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*(?