diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 818c72f25..9fa5d7693 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,6 +27,35 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
+ # ----------------------------------------------------------
+ # -1. WORKFLOW POLICY GATE: runs before any job invokes the governed
+ # ./.github/actions/setup composite (first used by "security" below), so a
+ # PR that tampers with that composite โ or any other workflow/action file โ
+ # is rejected before its own tampered code ever executes. Uses only
+ # external, SHA-pinned actions directly, never the composite it gates.
+ # ----------------------------------------------------------
+ workflow-policy:
+ name: ๐ Workflow Policy Gate
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+ with:
+ version: 11.22.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ # QNBS-v3: no lifecycle/.pnpmfile.cjs hooks โ install must not run repo code before the gate.
+ - name: Install dependencies (no lifecycle scripts)
+ run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
+ # QNBS-v3: node directly โ pnpm run's own pre-run check rejects an --ignore-pnpmfile install.
+ - name: Workflow-policy structural gate (permissions, needs graph, action pins)
+ run: node scripts/workflow-policy-check.mjs
+
# ----------------------------------------------------------
# 0. SECURITY: pnpm audit + gitleaks + dependency review
# ----------------------------------------------------------
@@ -34,6 +63,7 @@ jobs:
name: ๐ Security Audit
runs-on: ubuntu-latest
timeout-minutes: 10
+ needs: [workflow-policy]
# QNBS-v3: dependency-review-action needs readable PR metadata โ otherwise the job fails unnecessarily on external PRs/scopes.
permissions:
contents: read
@@ -138,7 +168,7 @@ jobs:
contents: read
pull-requests: read
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -442,12 +472,26 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
# QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level.
- needs: [security, signatures, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt]
+ needs:
+ [
+ workflow-policy,
+ security,
+ signatures,
+ quality,
+ changes,
+ rust-tauri,
+ core-rust,
+ build,
+ e2e,
+ lighthouse,
+ vrt,
+ ]
if: always()
steps:
- name: Verify all required jobs succeeded
run: |
FAIL=0
+ [ "${{ needs.workflow-policy.result }}" = "success" ] || FAIL=1
[ "${{ needs.security.result }}" = "success" ] || FAIL=1
[ "${{ needs.signatures.result }}" = "success" ] || FAIL=1
[ "${{ needs.quality.result }}" = "success" ] || FAIL=1
@@ -464,6 +508,7 @@ jobs:
[ "${{ needs.vrt.result }}" = "success" ] || FAIL=1
if [ "$FAIL" = "1" ]; then
echo "One or more required jobs did not succeed:"
+ echo " workflow-policy: ${{ needs.workflow-policy.result }}"
echo " security: ${{ needs.security.result }}"
echo " signatures: ${{ needs.signatures.result }}"
echo " quality: ${{ needs.quality.result }}"
diff --git a/AGENTS.md b/AGENTS.md
index cf06c01ec..4cfea1742 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -373,11 +373,11 @@ procedure.
### Pipeline Graph
```
-security โโโบ quality โโโฌโโโบ build โโโบ lighthouse
- โโโโบ e2e
- โโโโบ e2e-deep (non-blocking)
- โโโโบ storybook
- โโโโบ vrt
+workflow-policy โโโบ security โโโบ quality โโโฌโโโบ build โโโบ lighthouse
+ โโโโบ e2e
+ โโโโบ e2e-deep (non-blocking)
+ โโโโบ storybook
+ โโโโบ vrt
build (main, non-PR) โโโบ upload-pages-artifact
deploy (main, non-PR) needs: ci-success โโโบ GitHub Pages
```
@@ -386,6 +386,7 @@ deploy (main, non-PR) needs: ci-success โโโบ GitHub Pages
| Job | Purpose |
|-----|---------|
+| `workflow-policy` | Structural gate on `.github/workflows/*.yml` + `.github/actions/**/action.yml` (`scripts/workflow-policy-check.mjs`) โ permissions, needs graph, SHA-pinned action references, publishing boundary. Runs first, before any job invokes the governed `./.github/actions/setup` composite; sets up pnpm/Node with external SHA-pinned actions directly instead of that composite. |
| `security` | `pnpm audit --audit-level=high`, OSV scanner (pnpm + `src-tauri/` + `crates/` Cargo lockfiles), gitleaks secrets scan, dependency review on PRs |
| `quality` | Node 22 + 24 matrix โ Biome lint, suppression-debt ratchet, `i18n:check`, `docs:check`, `csp:verify`, `parity:check`, `tsgo --noEmit`, Storybook build, Vitest + coverage, Codecov upload |
| `rust-tauri` | `fmt`/`check`/`clippy`/`test` for `src-tauri/`; path-scoped (skips on PRs that don't touch it), needs GTK/WebKit apt-get steps |
diff --git a/README.md b/README.md
index 7c6ff2642..c7935547b 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings โ AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys ร 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7005+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7076+ tests / 579 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
โ โโโ sw.js # PWA Service Worker
โ โโโ manifest.json # PWA Web App Manifest v3
โโโ tests/
-โ โโโ unit/ # Vitest unit tests (7005+ tests, 578 files) โ count spans tests/, components/, packages/*/tests/, not just this folder
+โ โโโ unit/ # Vitest unit tests (7076+ tests, 579 files) โ count spans tests/, components/, packages/*/tests/, not just this folder
โ โ โโโ ai/ # aiSmallModules, aiCoreFallbackPaths
โ โ โโโ settings/ # WebLlmPanel, AiSections
โ โโโ e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard โ SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7005+ unit tests** across **578 test files** โ CI is authoritative for pass/fail
+- **7076+ unit tests** across **579 test files** โ CI is authoritative for pass/fail
- Coverage thresholds: lines โฅ 80 ยท branches โฅ 66 ยท functions โฅ 72 ยท statements โฅ 78 โ enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys ร 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/docs/CI.md b/docs/CI.md
index 25d5dbc4d..a17a7604b 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -37,8 +37,8 @@ CI runs for the affected test path before removing a temporary quarantine.
### Gate authority
-`โ
CI Success` is the required branch-protection status and aggregates `security`, `signatures`,
-`quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and
+`โ
CI Success` is the required branch-protection status and aggregates `workflow-policy`, `security`,
+`signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and
`storybook` are explicitly advisory at job level while their stability criteria are measured. The
`deploy` job depends only on that aggregate and remains main-push-only.
@@ -83,6 +83,8 @@ pnpm/action-setup (explicit patched 11.22.0) โ actions/setup-node (cache: pnpm
Each job that uses the composite must call `actions/checkout@v6` first (local composite actions are resolved from the workspace, so the repo must be checked out before `uses: ./.github/actions/setup` can be used). The `quality` job additionally passes `node-version: ${{ matrix.node-version }}` to cover the LTS matrix.
+**`workflow-policy` deliberately does not use this composite.** It sets up pnpm/Node inline with the same external SHA-pinned actions instead, so a PR that tampers with `.github/actions/setup/action.yml` itself can't have that tampered content execute before `workflow-policy` evaluates it โ see the job table above.
+
---
## Workflow triggers
@@ -98,21 +100,22 @@ Each job that uses the composite must call `actions/checkout@v6` first (local co
## Job graph
```text
-security โโโบ quality โโโฌโโโบ build โโโฌโโโบ lighthouse
- โโโโบ e2e โโโโบ vrt
- โโโโบ e2e-deep (advisory)
- โโโโบ storybook (advisory)
-
-security โโฌ
-signatures โโค
-quality โโโผโโโบ ci-success (required-status aggregator)
-changes โโโค
-rust โโโโโค
-core-rust โค
-build โโโโโค
-e2e โโโโโโโค
-lighthouse โโค
-vrt โโโโโโโ
+workflow-policy โโโบ security โโโบ quality โโโฌโโโบ build โโโฌโโโบ lighthouse
+ โโโโบ e2e โโโโบ vrt
+ โโโโบ e2e-deep (advisory)
+ โโโโบ storybook (advisory)
+
+workflow-policy โโ
+security โโโโโโโโโค
+signatures โโโโโโโค
+quality โโโโโโโโโโผโโโบ ci-success (required-status aggregator)
+changes โโโโโโโโโโค
+rust โโโโโโโโโโโโโค
+core-rust โโโโโโโโค
+build โโโโโโโโโโโโค
+e2e โโโโโโโโโโโโโโค
+lighthouse โโโโโโโค
+vrt โโโโโโโโโโโโโโ
build (main, non-PR) โโโบ upload-pages-artifact
ci-success (main, non-PR) โโโบ deploy โโโบ GitHub Pages
@@ -127,7 +130,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
| Job | Needs | Purpose |
|-----|--------|---------|
-| `security` | โ | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
+| `workflow-policy` | โ | Structural (real-parser, not regex) validation of every `.github/workflows/*.yml` and `.github/actions/**/action.yml` โ permissions, needs graph, SHA-pinned action references, publishing boundary (`scripts/workflow-policy-check.mjs`). Runs first, before any job invokes the governed `./.github/actions/setup` composite, using only external SHA-pinned actions directly (never that composite) and an `--ignore-scripts --ignore-pnpmfile` install, so a PR tampering with the composite (or its own install hooks) can't execute before the gate evaluates it. |
+| `security` | `workflow-policy` | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
| `scheduled-osv` | โ | Separate daily and manually triggerable (`workflow_dispatch`) `.github/workflows/security-scheduled.yml` scan of the same three lockfiles; `contents: read` only; fails closed and writes lockfile/package/advisory details to the step summary |
| `quality` | `security` | Matrix **Node 22** and **24** โ Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact |
| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR |
@@ -137,7 +141,7 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
| `storybook` | `quality` | Cloud-first โ Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` โ see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. |
| `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views ร Chromium); artifacts uploaded always |
| `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. |
-| `ci-success` | `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. |
+| `ci-success` | `workflow-policy`, `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. |
| `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. |
> **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above.
diff --git a/package.json b/package.json
index ddbc47b7b..2f84219d6 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"csp:check": "node scripts/check-csp-policy.mjs",
"csp:verify": "node scripts/sync-csp.mjs && git diff --exit-code -- index.html nginx.conf public/_headers vercel.json src-tauri/tauri.conf.json && node scripts/check-csp-policy.mjs",
"native-readiness:check": "node scripts/check-native-readiness.mjs",
+ "workflow-policy:check": "node scripts/workflow-policy-check.mjs",
"suppressions:check": "node scripts/check-suppressions.mjs",
"token:audit": "node scripts/audit-tokens.mjs",
"guardrail:desktop-imports": "node scripts/check-tauri-import-boundary.mjs",
@@ -202,7 +203,8 @@
"vitest": "^4.1.10",
"wait-on": "^9.1.0",
"wrangler": "^4.120.1",
- "y-protocols": "^1.0.7"
+ "y-protocols": "^1.0.7",
+ "yaml": "^2.9.0"
},
"simple-git-hooks": {
"pre-commit": "node scripts/hooks/pre-commit.mjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5785b812b..383893928 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -328,6 +328,9 @@ importers:
y-protocols:
specifier: ^1.0.7
version: 1.0.7(yjs@13.6.31)
+ yaml:
+ specifier: ^2.9.0
+ version: 2.9.0
packages/ai-core:
optionalDependencies:
@@ -15777,8 +15780,7 @@ snapshots:
yallist@3.1.1: {}
- yaml@2.9.0:
- optional: true
+ yaml@2.9.0: {}
yargs-parser@13.1.2:
dependencies:
diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs
index f74514dea..8d6bf76d2 100644
--- a/scripts/ci-prepush-check-registry.mjs
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -35,6 +35,19 @@ const admissionCheckRegistry = Object.freeze([
'scripts/ci-prepush-range-resolver.mjs',
]),
},
+ {
+ name: 'workflowPolicy',
+ // QNBS-v3: composite actions carry the same uses:-pin risk as workflows themselves.
+ matches: (file) =>
+ file.startsWith('.github/workflows/') || file.startsWith('.github/actions/'),
+ implementationFiles: new Set([
+ routingAuthority,
+ runnerAuthority,
+ 'scripts/ci-prepush-classifier.mjs',
+ 'scripts/ci-prepush-range-resolver.mjs',
+ 'scripts/workflow-policy-check.mjs',
+ ]),
+ },
]);
export function shouldRunAdmissionCheck(name, files) {
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index 197ab5e16..a65fe344f 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -102,6 +102,9 @@ async function main() {
if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
await runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs'));
+ if (shouldRunAdmissionCheck('workflowPolicy', classification.files) || full)
+ await runCheck('Workflow policy', () => runNodeScript('scripts/workflow-policy-check.mjs'));
+
if (typecheckRequired) {
await runCheck('TypeScript (single checker)', () =>
// QNBS-v3: one checker bounds memory use on constrained developer machines.
diff --git a/scripts/workflow-policy-check.d.mts b/scripts/workflow-policy-check.d.mts
new file mode 100644
index 000000000..eb4a06ecf
--- /dev/null
+++ b/scripts/workflow-policy-check.d.mts
@@ -0,0 +1,121 @@
+import type { Document, LineCounter } from 'yaml';
+
+export interface WorkflowPolicyFailure {
+ file: string;
+ message: string;
+}
+
+export interface ListWorkflowFilesDependencies {
+ readdirSync?: (dir: string) => string[];
+}
+
+export function listWorkflowFiles(
+ root?: string,
+ dependencies?: ListWorkflowFilesDependencies,
+): string[];
+
+export interface DirEntryLike {
+ name: string;
+ isDirectory(): boolean;
+}
+
+export interface ListActionFilesDependencies {
+ // QNBS-v3: recursive discovery needs Dirent-shaped entries, not listWorkflowFiles' flat string[].
+ readdirSync?: (dir: string, options: { withFileTypes: true }) => DirEntryLike[];
+}
+
+export function listActionFiles(
+ root?: string,
+ dependencies?: ListActionFilesDependencies,
+): string[];
+
+export interface ParseWorkflowFileDependencies {
+ readFileSync?: (filePath: string, encoding: 'utf8') => string;
+}
+
+export interface ParsedWorkflowFile {
+ filePath: string;
+ content: string;
+ doc: Document;
+ lineCounter: LineCounter;
+}
+
+export function parseWorkflowFile(
+ filePath: string,
+ dependencies?: ParseWorkflowFileDependencies,
+): ParsedWorkflowFile;
+
+export function checkTopLevelPermissions(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+): void;
+
+export function checkJobWriteScopeAllowlist(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+): void;
+
+export function checkNeedsGraph(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+): void;
+
+export interface CheckActionPinsOptions {
+ fileKind?: 'workflow' | 'action';
+ lineCounter?: LineCounter;
+}
+
+export function checkActionPins(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+ options?: CheckActionPinsOptions,
+): void;
+
+export function checkAggregatorNeeds(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+): void;
+
+export function checkPublishingBoundary(
+ fileName: string,
+ doc: Document,
+ failures: WorkflowPolicyFailure[],
+): void;
+
+export interface WorkflowTriggers {
+ workflowDispatch: boolean;
+ tagPush: boolean;
+}
+
+export function getTriggers(doc: Document): WorkflowTriggers;
+
+export type CheckWorkflowFileDependencies = ParseWorkflowFileDependencies;
+
+export function checkWorkflowFile(
+ filePath: string,
+ dependencies?: CheckWorkflowFileDependencies,
+): WorkflowPolicyFailure[];
+
+export function checkActionFile(
+ filePath: string,
+ dependencies?: CheckWorkflowFileDependencies,
+): WorkflowPolicyFailure[];
+
+export type CheckAllWorkflowsDependencies = CheckWorkflowFileDependencies &
+ ListWorkflowFilesDependencies &
+ ListActionFilesDependencies & {
+ listWorkflowFiles?: (root: string) => string[];
+ listActionFiles?: (root: string) => string[];
+ };
+
+export function checkAllWorkflows(
+ root?: string,
+ dependencies?: CheckAllWorkflowsDependencies,
+): WorkflowPolicyFailure[];
+
+export function main(): void;
diff --git a/scripts/workflow-policy-check.mjs b/scripts/workflow-policy-check.mjs
new file mode 100644
index 000000000..79dac3801
--- /dev/null
+++ b/scripts/workflow-policy-check.mjs
@@ -0,0 +1,515 @@
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { basename, join, resolve } from 'node:path';
+import process from 'node:process';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { LineCounter, isAlias, parseDocument } from 'yaml';
+
+function resolveProjectRoot() {
+ try {
+ return resolve(fileURLToPath(new URL('..', import.meta.url)));
+ } catch {
+ return process.cwd();
+ }
+}
+const projectRoot = resolveProjectRoot();
+
+// QNBS-v3: exempts only these exact job/write-key pairs from the no-unallowlisted-write policy.
+const WRITE_SCOPE_ALLOWLIST = {
+ 'ci.yml': {
+ build: new Set(['attestations', 'id-token']),
+ deploy: new Set(['pages', 'id-token']),
+ },
+ 'docker.yml': { 'build-push': new Set(['packages']) },
+ 'prune-deployments.yml': { prune: new Set(['deployments']) },
+ 'tauri-build.yml': { release: new Set(['contents']) },
+ 'codeql.yml': { analyze: new Set(['security-events']) },
+ 'scorecard.yml': { analysis: new Set(['security-events', 'id-token']) },
+};
+
+// QNBS-v3: contents:write is release/publish authority; only these jobs may hold it.
+const PUBLISHING_ALLOWLIST = { 'tauri-build.yml': new Set(['release']) };
+
+export function listWorkflowFiles(root = projectRoot, dependencies = {}) {
+ const dir = join(root, '.github/workflows');
+ const listDir = dependencies.readdirSync ?? readdirSync;
+ if (!existsSync(dir)) return [];
+ return listDir(dir)
+ .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
+ .sort()
+ .map((name) => join(dir, name));
+}
+
+// QNBS-v3: composite actions carry the same uses:-pin risk but have no jobs/permissions to check.
+export function listActionFiles(root = projectRoot, dependencies = {}) {
+ const dir = join(root, '.github/actions');
+ const listDir = dependencies.readdirSync ?? readdirSync;
+ if (!existsSync(dir)) return [];
+ const results = [];
+ // QNBS-v3: recurse any depth โ a composite action can nest below its group directory.
+ // QNBS-v3: a symlink here could redirect a governed action outside .github/actions unseen โ reject it.
+ const walk = (currentDir) => {
+ for (const entry of listDir(currentDir, { withFileTypes: true })) {
+ const entryPath = join(currentDir, entry.name);
+ if (entry.isSymbolicLink?.()) {
+ throw new Error(`symlink not allowed under .github/actions: ${entryPath}`);
+ } else if (entry.isDirectory()) {
+ walk(entryPath);
+ } else if (entry.name === 'action.yml' || entry.name === 'action.yaml') {
+ results.push(entryPath);
+ }
+ }
+ };
+ walk(dir);
+ return results.sort();
+}
+
+export function parseWorkflowFile(filePath, dependencies = {}) {
+ const readFile = dependencies.readFileSync ?? readFileSync;
+ const content = readFile(filePath, 'utf8');
+ const lineCounter = new LineCounter();
+ const doc = parseDocument(content, { uniqueKeys: true, lineCounter });
+ return { filePath, content, doc, lineCounter };
+}
+
+// QNBS-v3: single alias-resolution point โ every value read from a parsed tree must pass through here.
+function resolveNode(node, doc) {
+ return node && isAlias(node) ? node.resolve(doc) : node;
+}
+
+// QNBS-v3: resolves each job VALUE so an aliased whole job (ci-success: *base) isn't skipped downstream.
+function jobMap(doc) {
+ const jobs = doc.get('jobs', true);
+ if (!jobs || typeof jobs.items === 'undefined') return new Map();
+ const map = new Map();
+ for (const pair of jobs.items) map.set(String(pair.key), resolveNode(pair.value, doc));
+ return map;
+}
+
+// QNBS-v3: resolves a steps: node (possibly aliased) and each item in it (also possibly aliased).
+function resolveSteps(stepsNode, doc) {
+ const resolved = resolveNode(stepsNode, doc);
+ const items = resolved?.items ?? [];
+ return items.map((item) => resolveNode(item, doc));
+}
+
+// QNBS-v3: resolves a needs: node (possibly aliased, e.g. shared via &deps/*deps) to a string array.
+function resolveNeedsList(needsNode, doc) {
+ const resolved = resolveNode(needsNode, doc);
+ if (!resolved) return [];
+ const value = resolved.toJSON();
+ return Array.isArray(value) ? value : [value];
+}
+
+// QNBS-v3: an aliased *perms block resolves to Alias, not YAMLMap โ must dereference before use.
+function permissionEntries(node, doc) {
+ const resolved = resolveNode(node, doc);
+ if (!resolved) return null;
+ if (typeof resolved.toJSON === 'function' && typeof resolved.items === 'undefined') {
+ return { scalar: resolved.toJSON() };
+ }
+ const map = {};
+ // QNBS-v3: a per-key value can itself be an alias (e.g. contents: *grant) โ resolve each one too.
+ for (const pair of resolved.items ?? []) {
+ const value = resolveNode(pair.value, doc);
+ map[String(pair.key)] = value !== undefined ? String(value) : String(pair.value);
+ }
+ return { map };
+}
+
+export function checkTopLevelPermissions(fileName, doc, failures) {
+ const permissions = permissionEntries(doc.get('permissions', true), doc);
+ if (!permissions) {
+ failures.push({ file: fileName, message: 'missing top-level `permissions:` block' });
+ return;
+ }
+ if (permissions.scalar !== undefined) {
+ if (permissions.scalar !== 'read-all') {
+ failures.push({
+ file: fileName,
+ message: `top-level permissions scalar must be "read-all", found "${permissions.scalar}"`,
+ });
+ }
+ return;
+ }
+ const keys = Object.keys(permissions.map);
+ if (keys.length !== 1 || permissions.map.contents !== 'read') {
+ failures.push({
+ file: fileName,
+ message: 'top-level permissions must be exactly {contents: read} or the scalar "read-all"',
+ });
+ }
+}
+
+export function checkJobWriteScopeAllowlist(fileName, doc, failures) {
+ const allowlist = WRITE_SCOPE_ALLOWLIST[fileName] ?? {};
+ for (const [jobName, jobNode] of jobMap(doc)) {
+ const permissions = permissionEntries(jobNode?.get?.('permissions', true), doc);
+ if (!permissions) continue;
+ // QNBS-v3: scalar write-all grants every scope at once, which no job's allowlist ever lists.
+ if (permissions.scalar !== undefined) {
+ if (permissions.scalar !== 'read-all') {
+ failures.push({
+ file: fileName,
+ message: `job "${jobName}" declares scalar permissions "${permissions.scalar}", which is never allowlisted (only "read-all" is a valid job-level scalar)`,
+ });
+ }
+ continue;
+ }
+ const allowedWrites = allowlist[jobName] ?? new Set();
+ for (const [key, value] of Object.entries(permissions.map)) {
+ if (value === 'write' && !allowedWrites.has(key)) {
+ failures.push({
+ file: fileName,
+ message: `job "${jobName}" declares unallowlisted write permission "${key}"`,
+ });
+ }
+ }
+ }
+}
+
+export function checkNeedsGraph(fileName, doc, failures) {
+ const jobs = jobMap(doc);
+ const jobNames = new Set(jobs.keys());
+ const needsOf = new Map();
+ for (const [jobName, jobNode] of jobs) {
+ const needs = resolveNeedsList(jobNode?.get?.('needs', true), doc);
+ for (const dependency of needs) {
+ if (!jobNames.has(dependency)) {
+ failures.push({
+ file: fileName,
+ message: `job "${jobName}" needs unknown job "${dependency}"`,
+ });
+ }
+ }
+ needsOf.set(jobName, needs.filter((dependency) => jobNames.has(dependency)));
+ }
+ const visiting = new Set();
+ const visited = new Set();
+ const visit = (jobName, path) => {
+ if (visited.has(jobName)) return;
+ if (visiting.has(jobName)) {
+ failures.push({
+ file: fileName,
+ message: `needs graph cycle detected: ${[...path, jobName].join(' -> ')}`,
+ });
+ return;
+ }
+ visiting.add(jobName);
+ for (const dependency of needsOf.get(jobName) ?? []) visit(dependency, [...path, jobName]);
+ visiting.delete(jobName);
+ visited.add(jobName);
+ };
+ for (const jobName of jobNames) visit(jobName, []);
+}
+
+function collectWorkflowSteps(doc) {
+ const steps = [];
+ for (const [, jobNode] of jobMap(doc)) {
+ const stepsNode = jobNode?.get?.('steps', true);
+ steps.push(...resolveSteps(stepsNode, doc));
+ }
+ return steps;
+}
+
+function collectActionSteps(doc) {
+ const runsNode = resolveNode(doc.get('runs', true), doc);
+ const stepsNode = runsNode?.get?.('steps', true);
+ return resolveSteps(stepsNode, doc);
+}
+
+const DOCKER_DIGEST_PATTERN = /@sha256:[0-9a-f]{64}$/;
+
+// QNBS-v3: checks one uses: value, resolving aliases first so *ref hides no bypass step 6/2 found.
+function checkUsesRef({ usesNode, containerNode, doc, fileName, lineCounter, failures }) {
+ const resolvedNode = resolveNode(usesNode, doc);
+ if (!resolvedNode || typeof resolvedNode.value !== 'string') return;
+ const ref = resolvedNode.value;
+ const line =
+ lineCounter && Array.isArray(usesNode.range)
+ ? lineCounter.linePos(usesNode.range[0]).line
+ : undefined;
+ const loc = line ? `line ${line}: ` : '';
+ // QNBS-v3: build-from-source has no registry pin concept โ already pinned by being in the commit.
+ if (ref === 'Dockerfile') return;
+ if (ref.startsWith('./')) {
+ // QNBS-v3: only .github/actions/** is scanned by listActionFiles โ any other local ref is unchecked.
+ if (ref.startsWith('./.github/actions/')) return;
+ failures.push({
+ file: fileName,
+ message: `${loc}local action reference "${ref}" is outside the governed .github/actions/ directory and is never pin-checked`,
+ });
+ return;
+ }
+ if (ref.startsWith('docker://')) {
+ // QNBS-v3: a mutable docker tag is as unpinned as a floating action tag โ require a digest.
+ if (!DOCKER_DIGEST_PATTERN.test(ref)) {
+ failures.push({
+ file: fileName,
+ message: `${loc}docker reference "${ref}" must pin an immutable @sha256 digest, not a mutable tag`,
+ });
+ }
+ return;
+ }
+ const atIndex = ref.indexOf('@');
+ if (atIndex === -1) {
+ failures.push({ file: fileName, message: `${loc}action reference "${ref}" is missing an @ pin` });
+ return;
+ }
+ const pin = ref.slice(atIndex + 1);
+ if (!/^[0-9a-f]{40}$/.test(pin)) {
+ failures.push({
+ file: fileName,
+ message: `${loc}action reference "${ref}" must pin a 40-hex-char SHA`,
+ });
+ return;
+ }
+ // QNBS-v3: an alias's comment can live at the anchor definition or the use site โ accept either.
+ const comment =
+ usesNode.comment ?? resolvedNode.comment ?? (containerNode?.flow ? containerNode.comment : undefined);
+ if (!comment || !/\S/.test(comment)) {
+ failures.push({
+ file: fileName,
+ message: `${loc}SHA-pinned action "${ref}" is missing a trailing # comment`,
+ });
+ }
+}
+
+// QNBS-v3: walks the parsed tree (not raw text) so flow-mapping/alias steps can't bypass enforcement.
+export function checkActionPins(fileName, doc, failures, options = {}) {
+ const { fileKind = 'workflow', lineCounter } = options;
+ const steps = fileKind === 'action' ? collectActionSteps(doc) : collectWorkflowSteps(doc);
+ for (const step of steps) {
+ const usesNode = step?.get?.('uses', true);
+ if (!usesNode) continue;
+ checkUsesRef({ usesNode, containerNode: step, doc, fileName, lineCounter, failures });
+ }
+ // QNBS-v3: a job can itself call a reusable workflow via jobs..uses โ same pin risk as a step.
+ if (fileKind === 'workflow') {
+ for (const [, jobNode] of jobMap(doc)) {
+ const usesNode = jobNode?.get?.('uses', true);
+ if (!usesNode) continue;
+ checkUsesRef({ usesNode, containerNode: jobNode, doc, fileName, lineCounter, failures });
+ }
+ }
+ // QNBS-v3: a Docker action (runs.using: docker) has no steps โ its own image: needs the same pin.
+ if (fileKind === 'action') {
+ const runsNode = resolveNode(doc.get('runs', true), doc);
+ const imageNode = runsNode?.get?.('image', true);
+ if (imageNode) {
+ checkUsesRef({ usesNode: imageNode, containerNode: runsNode, doc, fileName, lineCounter, failures });
+ }
+ }
+}
+
+// QNBS-v3: always()/failure()/cancelled() (incl. negated !cancelled()) all bypass default success-gating.
+const NON_DEFAULT_GATING_PATTERN = /\b(?:always|failure|cancelled)\s*\(\)/;
+
+function jobHasNonDefaultGatingCondition(jobNode, doc) {
+ const ifNode = resolveNode(jobNode?.get?.('if', true), doc);
+ return typeof ifNode?.value === 'string' && NON_DEFAULT_GATING_PATTERN.test(ifNode.value);
+}
+
+const NEEDS_RESULT_PATTERN = /needs\.([A-Za-z0-9_-]+)\.result/g;
+const FAILURE_EXIT_PATTERN = /\bexit\s+(?:\$\S+|[1-9]\d*)/;
+
+// QNBS-v3: a whole-line shell comment can't affect control flow โ strip before pattern-matching.
+function stripCommentLines(script) {
+ return script
+ .split('\n')
+ .filter((line) => !/^\s*#/.test(line))
+ .join('\n');
+}
+
+// QNBS-v3: text heuristic, not a shell parser โ an exit in an unrelated branch can still false-pass.
+function collectNeedsResultReferences(jobNode, doc) {
+ const stepsNode = jobNode?.get?.('steps', true);
+ const references = new Set();
+ for (const step of resolveSteps(stepsNode, doc)) {
+ const runNode = step?.get?.('run', true);
+ if (typeof runNode?.value !== 'string') continue;
+ const script = stripCommentLines(runNode.value);
+ // QNBS-v3: a bare reference (e.g. echo) can't fail the job โ only count it alongside a real exit.
+ if (!FAILURE_EXIT_PATTERN.test(script)) continue;
+ for (const match of script.matchAll(NEEDS_RESULT_PATTERN)) references.add(match[1]);
+ }
+ return references;
+}
+
+// QNBS-v3: excludes the full downstream closure (not just direct dependents) from ci-success.needs.
+function computeAggregatorDescendants(jobs, doc) {
+ const dependents = new Map();
+ for (const [jobName, jobNode] of jobs) {
+ const needs = resolveNeedsList(jobNode?.get?.('needs', true), doc);
+ for (const dependency of needs) {
+ if (!dependents.has(dependency)) dependents.set(dependency, []);
+ dependents.get(dependency).push(jobName);
+ }
+ }
+ const descendants = new Set();
+ const queue = ['ci-success'];
+ while (queue.length > 0) {
+ const current = queue.pop();
+ for (const dependent of dependents.get(current) ?? []) {
+ if (!descendants.has(dependent)) {
+ descendants.add(dependent);
+ queue.push(dependent);
+ }
+ }
+ }
+ return descendants;
+}
+
+export function checkAggregatorNeeds(fileName, doc, failures) {
+ const jobs = jobMap(doc);
+ if (!jobs.has('ci-success')) return;
+ const aggregatorNode = jobs.get('ci-success');
+ // QNBS-v3: needs: quality (bare string) must not decompose into per-character Set entries.
+ const declaredNeeds = new Set(resolveNeedsList(aggregatorNode?.get?.('needs', true), doc));
+ const descendants = computeAggregatorDescendants(jobs, doc);
+ const expectedNeeds = new Set();
+ for (const [jobName, jobNode] of jobs) {
+ if (jobName === 'ci-success') continue;
+ const continueOnError = jobNode?.get?.('continue-on-error', true);
+ if (continueOnError?.toJSON?.() === true) continue; // advisory job, not gating
+ if (descendants.has(jobName)) continue; // downstream of the aggregator, direct or transitive
+ expectedNeeds.add(jobName);
+ }
+ const missing = [...expectedNeeds].filter((name) => !declaredNeeds.has(name));
+ const extra = [...declaredNeeds].filter((name) => !expectedNeeds.has(name));
+ for (const name of missing) {
+ failures.push({ file: fileName, message: `ci-success.needs is missing gating job "${name}"` });
+ }
+ for (const name of extra) {
+ failures.push({
+ file: fileName,
+ message: `ci-success.needs lists "${name}", which is not a gating job (advisory or self-referential)`,
+ });
+ }
+ if (jobHasNonDefaultGatingCondition(aggregatorNode, doc)) {
+ const checkedResults = collectNeedsResultReferences(aggregatorNode, doc);
+ for (const name of expectedNeeds) {
+ if (declaredNeeds.has(name) && !checkedResults.has(name)) {
+ failures.push({
+ file: fileName,
+ message: `ci-success's if: condition overrides default success-gating but its run steps never check needs.${name}.result โ a failure of "${name}" would not fail the aggregator`,
+ });
+ }
+ }
+ }
+}
+
+// QNBS-v3: a tag-restricted ref check โ allowlisting by name alone can't survive that gate loosening.
+const TAG_ONLY_CONDITION_PATTERN = /refs\/tags\/|ref_type\s*==\s*['"]tag['"]/;
+
+export function checkPublishingBoundary(fileName, doc, failures) {
+ const allowlist = PUBLISHING_ALLOWLIST[fileName] ?? new Set();
+ for (const [jobName, jobNode] of jobMap(doc)) {
+ const permissions = permissionEntries(jobNode?.get?.('permissions', true), doc);
+ // QNBS-v3: scalar write-all implicitly grants contents:write too โ must not evade this check.
+ const hasContentsWrite =
+ permissions?.map?.contents === 'write' ||
+ (permissions?.scalar !== undefined && permissions.scalar !== 'read-all');
+ if (!hasContentsWrite) continue;
+ if (!allowlist.has(jobName)) {
+ failures.push({
+ file: fileName,
+ message: `job "${jobName}" declares contents:write but is not on the publishing allowlist`,
+ });
+ continue;
+ }
+ // QNBS-v3: allowlisting by name is only sound while the job itself stays tag-push-restricted.
+ const ifNode = resolveNode(jobNode?.get?.('if', true), doc);
+ if (typeof ifNode?.value !== 'string' || !TAG_ONLY_CONDITION_PATTERN.test(ifNode.value)) {
+ failures.push({
+ file: fileName,
+ message: `publishing job "${jobName}" is on the allowlist but its if: condition no longer restricts it to a tag push โ loosening or removing that condition would expose contents:write outside a verified release`,
+ });
+ }
+ }
+}
+
+export function getTriggers(doc) {
+ const on = doc.get('on', true) ?? doc.get(true, true);
+ const triggers = { workflowDispatch: false, tagPush: false };
+ if (!on) return triggers;
+ const onValue = on.toJSON ? on.toJSON() : on;
+ if (Array.isArray(onValue)) {
+ triggers.workflowDispatch = onValue.includes('workflow_dispatch');
+ return triggers;
+ }
+ if (onValue && typeof onValue === 'object') {
+ triggers.workflowDispatch = 'workflow_dispatch' in onValue;
+ const push = onValue.push;
+ if (push && typeof push === 'object' && Array.isArray(push.tags) && push.tags.length > 0) {
+ triggers.tagPush = true;
+ }
+ }
+ return triggers;
+}
+
+export function checkWorkflowFile(filePath, dependencies = {}) {
+ const fileName = basename(filePath);
+ const failures = [];
+ const { doc, lineCounter } = parseWorkflowFile(filePath, dependencies);
+ if (doc.errors.length > 0) {
+ for (const error of doc.errors) {
+ failures.push({ file: fileName, message: `YAML parse error: ${error.message}` });
+ }
+ return failures; // QNBS-v3: structural checks below assume a parseable document.
+ }
+ checkTopLevelPermissions(fileName, doc, failures);
+ checkJobWriteScopeAllowlist(fileName, doc, failures);
+ checkNeedsGraph(fileName, doc, failures);
+ checkActionPins(fileName, doc, failures, { fileKind: 'workflow', lineCounter });
+ checkAggregatorNeeds(fileName, doc, failures);
+ checkPublishingBoundary(fileName, doc, failures);
+ return failures;
+}
+
+// QNBS-v3: composite actions have no jobs/permissions โ only the SHA-pin check applies to them.
+export function checkActionFile(filePath, dependencies = {}) {
+ const fileName = basename(filePath);
+ const failures = [];
+ const { doc, lineCounter } = parseWorkflowFile(filePath, dependencies);
+ if (doc.errors.length > 0) {
+ for (const error of doc.errors) {
+ failures.push({ file: fileName, message: `YAML parse error: ${error.message}` });
+ }
+ return failures;
+ }
+ checkActionPins(fileName, doc, failures, { fileKind: 'action', lineCounter });
+ return failures;
+}
+
+export function checkAllWorkflows(root = projectRoot, dependencies = {}) {
+ const workflowFiles = dependencies.listWorkflowFiles?.(root) ?? listWorkflowFiles(root, dependencies);
+ // QNBS-v3: fail-closed โ a rejected symlink must surface as a failure, never crash the whole check.
+ let actionFiles;
+ try {
+ actionFiles = dependencies.listActionFiles?.(root) ?? listActionFiles(root, dependencies);
+ } catch (error) {
+ return [
+ ...workflowFiles.flatMap((filePath) => checkWorkflowFile(filePath, dependencies)),
+ { file: '.github/actions', message: error.message },
+ ];
+ }
+ return [
+ ...workflowFiles.flatMap((filePath) => checkWorkflowFile(filePath, dependencies)),
+ ...actionFiles.flatMap((filePath) => checkActionFile(filePath, dependencies)),
+ ];
+}
+
+export function main() {
+ const failures = checkAllWorkflows();
+ if (failures.length > 0) {
+ console.error('Workflow-policy check failed:');
+ for (const failure of failures) console.error(`- [${failure.file}] ${failure.message}`);
+ process.exitCode = 1;
+ } else {
+ console.log(
+ 'Workflow-policy check passed: permissions, needs graph, action pins, aggregator sync, and publishing boundary are all structurally sound.',
+ );
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
diff --git a/tests/unit/tooling/workflowPolicyCheck.test.ts b/tests/unit/tooling/workflowPolicyCheck.test.ts
new file mode 100644
index 000000000..8f8b0273f
--- /dev/null
+++ b/tests/unit/tooling/workflowPolicyCheck.test.ts
@@ -0,0 +1,784 @@
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { parseDocument } from 'yaml';
+import {
+ checkActionFile,
+ checkActionPins,
+ checkAggregatorNeeds,
+ checkAllWorkflows,
+ checkJobWriteScopeAllowlist,
+ checkNeedsGraph,
+ checkPublishingBoundary,
+ checkTopLevelPermissions,
+ checkWorkflowFile,
+ getTriggers,
+ listActionFiles,
+} from '../../../scripts/workflow-policy-check.mjs';
+import type { WorkflowPolicyFailure } from '../../../scripts/workflow-policy-check.d.mts';
+
+const doc = (yaml: string) => parseDocument(yaml, { uniqueKeys: true });
+// QNBS-v3: split like strykerWorkflowPolicy.test.ts's helper so Biome doesn't misread this as a JS template.
+const githubExpression = (expression: string) => '$' + '{{ ' + expression + ' }}';
+
+// QNBS-v3: contents:read is the only safe top-level default โ every other form is a policy gap.
+describe('checkTopLevelPermissions', () => {
+ it('passes for the canonical {contents: read} form', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkTopLevelPermissions('x.yml', doc('permissions:\n contents: read\njobs: {}\n'), failures);
+ expect(failures).toEqual([]);
+ });
+
+ it('passes for the scalar "read-all" form (OSSF Scorecard convention)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkTopLevelPermissions('x.yml', doc('permissions: read-all\njobs: {}\n'), failures);
+ expect(failures).toEqual([]);
+ });
+
+ it('fails when a top-level write permission is declared', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkTopLevelPermissions(
+ 'x.yml',
+ doc('permissions:\n contents: write\njobs: {}\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/contents: read/);
+ });
+
+ it('fails when the permissions block is missing entirely', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkTopLevelPermissions('x.yml', doc('jobs: {}\n'), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/missing/);
+ });
+});
+
+// QNBS-v3: job-level scalar write-all must be caught here too, not only rejected at top level.
+describe('checkJobWriteScopeAllowlist', () => {
+ it('allows an allowlisted job/permission pair', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkJobWriteScopeAllowlist(
+ 'ci.yml',
+ doc('jobs:\n build:\n permissions:\n attestations: write\n'),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('fails for a write permission not on the allowlist for that job', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkJobWriteScopeAllowlist(
+ 'ci.yml',
+ doc('jobs:\n quality:\n permissions:\n contents: write\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/unallowlisted write permission "contents"/);
+ });
+
+ it('fails for a write permission on an allowlisted job but not in its allowed set', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkJobWriteScopeAllowlist(
+ 'ci.yml',
+ doc('jobs:\n build:\n permissions:\n contents: write\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ });
+
+ it('passes for the job-level scalar "read-all"', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkJobWriteScopeAllowlist('ci.yml', doc('jobs:\n build:\n permissions: read-all\n'), failures);
+ expect(failures).toEqual([]);
+ });
+
+ it('fails for the job-level scalar "write-all" (grants every scope, never allowlisted)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkJobWriteScopeAllowlist(
+ 'ci.yml',
+ doc('jobs:\n build:\n permissions: write-all\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/scalar permissions "write-all"/);
+ });
+
+ // QNBS-v3: a per-key alias (contents: *grant) must resolve to "write", not the literal "*grant".
+ it('resolves a per-key alias inside a job permissions map', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = ['x: &grant write', 'jobs:', ' build:', ' permissions:', ' contents: *grant', ''].join(
+ '\n',
+ );
+ checkJobWriteScopeAllowlist('ci.yml', doc(content), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/contents/);
+ });
+
+ // QNBS-v3: an aliased whole job (ci-success: *base) is an Alias node, not a map, until resolved.
+ it('resolves an aliased whole-job node before checking its permissions', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = [
+ 'jobs:',
+ ' base: &base',
+ ' permissions:',
+ ' contents: write',
+ ' copy: *base',
+ '',
+ ].join('\n');
+ checkJobWriteScopeAllowlist('ci.yml', doc(content), failures);
+ expect(failures.some((f) => f.message.includes('"copy"'))).toBe(true);
+ });
+});
+
+// QNBS-v3: needs-graph resolution/cycle-detection protects the aggregator sync check downstream.
+describe('checkNeedsGraph', () => {
+ it('passes when every needs reference resolves to a real job', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkNeedsGraph('x.yml', doc('jobs:\n a: {}\n b:\n needs: [a]\n'), failures);
+ expect(failures).toEqual([]);
+ });
+
+ it('fails for an orphaned needs reference', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkNeedsGraph('x.yml', doc('jobs:\n a:\n needs: [ghost]\n'), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/unknown job "ghost"/);
+ });
+
+ it('detects a two-job needs cycle', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkNeedsGraph(
+ 'x.yml',
+ doc('jobs:\n a:\n needs: [b]\n b:\n needs: [a]\n'),
+ failures,
+ );
+ expect(failures.some((f) => f.message.includes('cycle detected'))).toBe(true);
+ });
+
+ // QNBS-v3: an aliased needs: list (c.needs: *deps) is an Alias node until resolved.
+ it('resolves an aliased needs: list shared between two jobs', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkNeedsGraph(
+ 'x.yml',
+ doc('jobs:\n a: {}\n b:\n needs: &deps [a]\n c:\n needs: *deps\n'),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('fails for an orphaned reference inside an aliased needs: list', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkNeedsGraph(
+ 'x.yml',
+ doc('jobs:\n a: {}\n b:\n needs: &deps [ghost]\n c:\n needs: *deps\n'),
+ failures,
+ );
+ expect(failures.filter((f) => f.message.includes('unknown job "ghost"'))).toHaveLength(2);
+ });
+});
+
+// QNBS-v3: walks the parsed step tree so block AND flow-mapping uses: forms both get pin-checked.
+describe('checkActionPins', () => {
+ const wrap = (usesLine: string) => `jobs:\n a:\n steps:\n - ${usesLine}\n`;
+ const runsWrap = (usesLine: string) => `runs:\n using: composite\n steps:\n - ${usesLine}\n`;
+
+ it('passes a 40-hex SHA pin with a trailing comment', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc(wrap('uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1')),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('passes a non-SemVer trailing comment (e.g. a channel name)', () => {
+ // QNBS-v3: dtolnay/rust-toolchain pins "# stable", not vX.Y.Z โ must not be a false positive.
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc(wrap('uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable')),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('skips a local composite action reference under the governed directory', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap('uses: ./.github/actions/setup')), failures);
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: only .github/actions/** is scanned by listActionFiles โ anywhere else is unchecked.
+ it('fails for a local action reference outside .github/actions/', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap('uses: ./ci/setup')), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/outside the governed \.github\/actions\//);
+ });
+
+ it('fails for a floating tag instead of a SHA', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap('uses: actions/checkout@v4 # v4')), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ it('fails for a SHA pin missing its trailing comment', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc(wrap('uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1')),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/missing a trailing # comment/);
+ });
+
+ it('fails for an unpinned action reference written as a flow-mapping step', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap('{ uses: actions/checkout@v4 }')), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ it('passes a SHA-pinned flow-mapping step whose comment trails the flow map', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc(wrap('{ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 } # v7.0.1')),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('checks a composite action\'s runs.steps when fileKind is "action"', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('action.yml', doc(runsWrap('uses: actions/checkout@v4 # v4')), failures, {
+ fileKind: 'action',
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ it('ignores jobs.*.steps when fileKind is "action" (composite actions have no jobs)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('action.yml', doc(wrap('uses: actions/checkout@v4 # v4')), failures, {
+ fileKind: 'action',
+ });
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: a Docker action has no steps: at all โ its own runs.image needs the same pin check.
+ it('fails for a mutable docker image on a Docker action (runs.using: docker)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('action.yml', doc('runs:\n using: docker\n image: docker://alpine:latest\n'), failures, {
+ fileKind: 'action',
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/@sha256/);
+ });
+
+ it('passes a Docker action image pinned to an immutable @sha256 digest', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'action.yml',
+ doc(`runs:\n using: docker\n image: docker://alpine@sha256:${'a'.repeat(64)}\n`),
+ failures,
+ { fileKind: 'action' },
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('passes a Docker action built from a local Dockerfile (no registry pin concept)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('action.yml', doc('runs:\n using: docker\n image: Dockerfile\n'), failures, {
+ fileKind: 'action',
+ });
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: a job calling a reusable workflow carries the same mutable-ref risk as a step's uses:.
+ it('fails for an unpinned job-level reusable-workflow reference', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc('jobs:\n call:\n uses: owner/repo/.github/workflows/file.yml@main\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ it('passes a SHA-pinned job-level reusable-workflow reference', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins(
+ 'x.yml',
+ doc(
+ 'jobs:\n call:\n uses: owner/repo/.github/workflows/file.yml@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n',
+ ),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: a mutable docker tag is as unpinned as a floating action tag โ must require a digest.
+ it('fails for a mutable docker:// tag reference', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap('uses: docker://alpine:latest')), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/@sha256/);
+ });
+
+ it('passes a docker:// reference pinned to an immutable @sha256 digest', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkActionPins('x.yml', doc(wrap(`uses: docker://alpine@sha256:${'a'.repeat(64)}`)), failures);
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: GitHub resolves *action_ref before running the step โ the checker must do the same.
+ it('fails for an unpinned action reference hidden behind an alias', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = ['x: &action_ref actions/checkout@v4', 'jobs:', ' a:', ' steps:', ' - uses: *action_ref', ''].join(
+ '\n',
+ );
+ checkActionPins('x.yml', doc(content), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ it('passes a SHA-pinned action reference behind an alias, comment at the anchor definition', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = [
+ 'x: &action_ref actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1',
+ 'jobs:',
+ ' a:',
+ ' steps:',
+ ' - uses: *action_ref',
+ '',
+ ].join('\n');
+ checkActionPins('x.yml', doc(content), failures);
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: an aliased whole steps: list (steps: *shared) is an Alias node, not a sequence, until resolved.
+ it('checks an aliased steps: list, not just an aliased individual step', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = [
+ 'x: &shared_steps',
+ ' - uses: actions/checkout@v4',
+ 'jobs:',
+ ' a:',
+ ' steps: *shared_steps',
+ '',
+ ].join('\n');
+ checkActionPins('x.yml', doc(content), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+});
+
+// QNBS-v3: needs: quality (bare string) must resolve as one dependency, not per-character Set entries.
+describe('checkAggregatorNeeds', () => {
+ it('no-ops when the workflow has no ci-success aggregator', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds('x.yml', doc('jobs:\n a: {}\n'), failures);
+ expect(failures).toEqual([]);
+ });
+
+ it('passes when needs exactly matches the gating job set', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(
+ 'jobs:\n a: {}\n b:\n continue-on-error: true\n deploy:\n needs: [ci-success]\n ci-success:\n needs: [a]\n',
+ ),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('fails when a gating job is missing from needs', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds('ci.yml', doc('jobs:\n a: {}\n ci-success:\n needs: []\n'), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/missing gating job "a"/);
+ });
+
+ it('fails when needs lists an advisory (continue-on-error) job', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(
+ 'jobs:\n a: {}\n storybook:\n continue-on-error: true\n ci-success:\n needs: [a, storybook]\n',
+ ),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/not a gating job/);
+ });
+
+ it('treats a bare-string ci-success.needs as a single one-item dependency, not characters', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc('jobs:\n quality: {}\n ci-success:\n needs: quality\n'),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('fails when if: always() is set but the run script never checks a gating job\'s result', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(
+ 'jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: echo ok\n',
+ ),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/never check needs\.a\.result/);
+ });
+
+ it('passes when if: always() is set and the run script checks every gating job\'s result', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const runLine = `[ \\"${githubExpression('needs.a.result')}\\" = success ] || exit 1`;
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(`jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: "${runLine}"\n`),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('does not require a result check when if: always() is absent (default GH gating applies)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc('jobs:\n a: {}\n ci-success:\n needs: [a]\n steps:\n - run: echo ok\n'),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it('finds a result check split across multiple run steps', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const stepA = `- run: "[ \\"${githubExpression('needs.a.result')}\\" = success ] || exit 1"`;
+ const stepB = `- run: "[ \\"${githubExpression('needs.b.result')}\\" = success ] || exit 1"`;
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(
+ `jobs:\n a: {}\n b: {}\n ci-success:\n if: always()\n needs: [a, b]\n steps:\n ${stepA}\n ${stepB}\n`,
+ ),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: a bare reference (e.g. echo) logs the result but can't fail the job โ must be rejected.
+ it('rejects a result reference that is only logged, never used to control failure', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const stepA = `- run: "echo ${githubExpression('needs.a.result')}"`;
+ const stepB = `- run: "echo ${githubExpression('needs.b.result')}"`;
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(
+ `jobs:\n a: {}\n b: {}\n ci-success:\n if: always()\n needs: [a, b]\n steps:\n ${stepA}\n ${stepB}\n`,
+ ),
+ failures,
+ );
+ expect(failures).toHaveLength(2);
+ });
+
+ // QNBS-v3: a commented-out exit can't affect real control flow โ must not count as a check.
+ it('rejects a result reference whose only exit is inside a shell comment', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const runLine = `echo ${githubExpression('needs.a.result')}\\n# exit 1`;
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc(`jobs:\n a: {}\n ci-success:\n if: always()\n needs: [a]\n steps:\n - run: "${runLine}"\n`),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ });
+
+ // QNBS-v3: smoke needs deploy needs ci-success โ smoke is a transitive, not just direct, descendant.
+ it('excludes a transitive descendant of ci-success, not only a direct one', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc('jobs:\n a: {}\n ci-success:\n needs: [a]\n deploy:\n needs: [ci-success]\n smoke:\n needs: [deploy]\n'),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: failure()/cancelled() bypass default gating exactly like always() โ must be caught too.
+ it('fails when if: failure() is set but the run script never checks a gating job\'s result', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc('jobs:\n a: {}\n ci-success:\n if: failure()\n needs: [a]\n steps:\n - run: echo ok\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ });
+
+ it('fails when if: !cancelled() is set but the run script never checks a gating job\'s result', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkAggregatorNeeds(
+ 'ci.yml',
+ doc('jobs:\n a: {}\n ci-success:\n if: "!cancelled()"\n needs: [a]\n steps:\n - run: echo ok\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ });
+
+ // QNBS-v3: an aliased if: condition (if: *cond) is an Alias node until resolved.
+ it('fails when an aliased if: condition resolves to always()', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ const content = [
+ 'x: &cond always()',
+ 'jobs:',
+ ' a: {}',
+ ' ci-success:',
+ ' if: *cond',
+ ' needs: [a]',
+ ' steps:',
+ ' - run: echo ok',
+ '',
+ ].join('\n');
+ checkAggregatorNeeds('ci.yml', doc(content), failures);
+ expect(failures).toHaveLength(1);
+ });
+});
+
+// QNBS-v3: scalar write-all implicitly grants contents:write and must not evade this boundary.
+describe('checkPublishingBoundary', () => {
+ it('passes for contents:write on an allowlisted, tag-restricted publishing job', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkPublishingBoundary(
+ 'tauri-build.yml',
+ doc(
+ `jobs:\n release:\n if: ${githubExpression("github.ref_type == 'tag'")}\n permissions:\n contents: write\n`,
+ ),
+ failures,
+ );
+ expect(failures).toEqual([]);
+ });
+
+ // QNBS-v3: allowlisting by (file, job) name alone survives the gate being loosened/removed.
+ it('fails for an allowlisted publishing job whose tag-only if: condition was removed', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkPublishingBoundary(
+ 'tauri-build.yml',
+ doc('jobs:\n release:\n permissions:\n contents: write\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/no longer restricts it to a tag push/);
+ });
+
+ it('fails for contents:write on a job not on the publishing allowlist', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkPublishingBoundary(
+ 'ci.yml',
+ doc('jobs:\n build:\n permissions:\n contents: write\n'),
+ failures,
+ );
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/not on the publishing allowlist/);
+ });
+
+ it('fails for the job-level scalar "write-all" on a job not on the publishing allowlist', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkPublishingBoundary('ci.yml', doc('jobs:\n build:\n permissions: write-all\n'), failures);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/not on the publishing allowlist/);
+ });
+
+ it('passes for the job-level scalar "read-all" (no implied contents:write)', () => {
+ const failures: WorkflowPolicyFailure[] = [];
+ checkPublishingBoundary('ci.yml', doc('jobs:\n build:\n permissions: read-all\n'), failures);
+ expect(failures).toEqual([]);
+ });
+});
+
+// QNBS-v3: workflow_dispatch/tag-push recognition feeds the S5 qualification-lane boundary check.
+describe('getTriggers', () => {
+ it('recognizes workflow_dispatch', () => {
+ expect(getTriggers(doc('on:\n workflow_dispatch: {}\njobs: {}\n')).workflowDispatch).toBe(
+ true,
+ );
+ });
+
+ it('recognizes a tag-push trigger', () => {
+ expect(getTriggers(doc('on:\n push:\n tags: ["v*"]\njobs: {}\n')).tagPush).toBe(true);
+ });
+
+ it('reports both false when neither trigger is present', () => {
+ const triggers = getTriggers(doc('on:\n pull_request: {}\njobs: {}\n'));
+ expect(triggers).toEqual({ workflowDispatch: false, tagPush: false });
+ });
+});
+
+describe('checkWorkflowFile (duplicate keys / YAML-level errors)', () => {
+ it('reports a YAML parse error for duplicate top-level keys and skips structural checks', () => {
+ const content = 'permissions:\n contents: read\npermissions:\n contents: write\njobs: {}\n';
+ const failures = checkWorkflowFile('duplicate.yml', {
+ readFileSync: () => content,
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/YAML parse error/);
+ });
+
+ it('resolves an anchor/alias pair and validates the resolved content', () => {
+ // QNBS-v3: proves anchors/aliases (core YAML) are resolved before structural checks run.
+ const content = [
+ 'permissions: &perms',
+ ' contents: read',
+ 'jobs:',
+ ' a:',
+ ' permissions: *perms',
+ '',
+ ].join('\n');
+ const failures = checkWorkflowFile('anchors.yml', { readFileSync: () => content });
+ expect(failures).toEqual([]);
+ });
+
+ it('extracts the filename via basename(), not a hardcoded "/" split, given a nested path', () => {
+ // QNBS-v3: basename() is symmetric with join()'s platform separator; a bare split('/') was not.
+ const failures = checkWorkflowFile('/repo/.github/workflows/missing-perms.yml', {
+ readFileSync: () => 'jobs: {}\n',
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.file).toBe('missing-perms.yml');
+ });
+});
+
+// QNBS-v3: composite actions are a distinct file kind โ only the pin check applies to them.
+describe('checkActionFile', () => {
+ it('reports an unpinned action reference in a composite action', () => {
+ const content = 'runs:\n using: composite\n steps:\n - uses: actions/checkout@v4\n';
+ const failures = checkActionFile('.github/actions/setup/action.yml', {
+ readFileSync: () => content,
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.file).toBe('action.yml');
+ });
+
+ it('passes a fully SHA-pinned composite action', () => {
+ const content =
+ 'runs:\n using: composite\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n';
+ const failures = checkActionFile('.github/actions/setup/action.yml', {
+ readFileSync: () => content,
+ });
+ expect(failures).toEqual([]);
+ });
+
+ it('reports a YAML parse error for a malformed composite action', () => {
+ const content = 'runs: [\n';
+ const failures = checkActionFile('.github/actions/broken/action.yml', {
+ readFileSync: () => content,
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/YAML parse error/);
+ });
+});
+
+describe('listActionFiles', () => {
+ it('discovers the repository\'s real .github/actions/setup/action.yml', () => {
+ // QNBS-v3: existsSync isn't DI'd (matches listWorkflowFiles), so this hits the real fs.
+ const files = listActionFiles();
+ expect(
+ files.some((filePath) => filePath.endsWith(join('.github', 'actions', 'setup', 'action.yml'))),
+ ).toBe(true);
+ });
+
+ // QNBS-v3: existsSync isn't DI'd, so the real dir must exist for this fake readdirSync to fire.
+ // QNBS-v3: join(), not a literal "/", so the suffix matches join()'s separator on every platform.
+ it('discovers a composite action nested below its group directory (recursive)', () => {
+ const readdirSync = (dirPath: string) => {
+ if (dirPath.endsWith(join('.github', 'actions'))) {
+ return [{ name: 'release', isDirectory: () => true }];
+ }
+ if (dirPath.endsWith(join('.github', 'actions', 'release'))) {
+ return [{ name: 'setup', isDirectory: () => true }];
+ }
+ if (dirPath.endsWith(join('.github', 'actions', 'release', 'setup'))) {
+ return [{ name: 'action.yml', isDirectory: () => false }];
+ }
+ return [];
+ };
+ const files = listActionFiles(undefined, { readdirSync });
+ expect(
+ files.some((f) => f.endsWith(join('.github', 'actions', 'release', 'setup', 'action.yml'))),
+ ).toBe(true);
+ });
+
+ // QNBS-v3: a symlinked directory could redirect a governed action outside .github/actions unseen.
+ it('throws when a symlink is found under .github/actions', () => {
+ const readdirSync = (dirPath: string) => {
+ if (dirPath.endsWith(join('.github', 'actions'))) {
+ return [{ name: 'setup', isDirectory: () => false, isSymbolicLink: () => true }];
+ }
+ return [];
+ };
+ expect(() => listActionFiles(undefined, { readdirSync })).toThrow(/symlink/i);
+ });
+});
+
+describe('checkAllWorkflows', () => {
+ it('aggregates failures across multiple injected workflow files', () => {
+ const files = new Map([
+ ['/repo/.github/workflows/a.yml', 'jobs: {}\n'],
+ ['/repo/.github/workflows/b.yml', 'permissions:\n contents: read\njobs: {}\n'],
+ ]);
+ const failures = checkAllWorkflows('/repo', {
+ listWorkflowFiles: () => [...files.keys()],
+ listActionFiles: () => [],
+ readFileSync: (filePath: string) => {
+ const content = files.get(filePath);
+ if (content === undefined) throw new Error(`unexpected path: ${filePath}`);
+ return content;
+ },
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.file).toBe('a.yml');
+ });
+
+ it('also aggregates failures from injected composite action files', () => {
+ const files = new Map([
+ ['/repo/.github/workflows/a.yml', 'permissions:\n contents: read\njobs: {}\n'],
+ [
+ '/repo/.github/actions/setup/action.yml',
+ 'runs:\n using: composite\n steps:\n - uses: actions/checkout@v4\n',
+ ],
+ ]);
+ const failures = checkAllWorkflows('/repo', {
+ listWorkflowFiles: () => ['/repo/.github/workflows/a.yml'],
+ listActionFiles: () => ['/repo/.github/actions/setup/action.yml'],
+ readFileSync: (filePath: string) => {
+ const content = files.get(filePath);
+ if (content === undefined) throw new Error(`unexpected path: ${filePath}`);
+ return content;
+ },
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.file).toBe('action.yml');
+ expect(failures[0]?.message).toMatch(/40-hex-char SHA/);
+ });
+
+ // QNBS-v3: a thrown symlink rejection must surface as a failure, never crash the whole check.
+ it('fail-closes a symlink rejection from listActionFiles into a failure, not a crash', () => {
+ const failures = checkAllWorkflows('/repo', {
+ listWorkflowFiles: () => [],
+ listActionFiles: () => {
+ throw new Error('symlink not allowed under .github/actions: /repo/.github/actions/setup');
+ },
+ });
+ expect(failures).toHaveLength(1);
+ expect(failures[0]?.message).toMatch(/symlink/i);
+ });
+});
diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts
index 05928d2cd..6fcc1da41 100644
--- a/tests/unit/workflowPolicy.test.ts
+++ b/tests/unit/workflowPolicy.test.ts
@@ -134,6 +134,7 @@ describe('CI workflow policy', () => {
'Every unconditional job is either required here or explicitly advisory',
);
expect(extractNeeds(workflowSource, 'ci-success')).toEqual([
+ 'workflow-policy',
'security',
'signatures',
'quality',