diff --git a/.cursor/rules/800-testing-standards.mdc b/.cursor/rules/800-testing-standards.mdc index 7cf55421a..bdb3dea18 100644 --- a/.cursor/rules/800-testing-standards.mdc +++ b/.cursor/rules/800-testing-standards.mdc @@ -37,7 +37,7 @@ alwaysApply: false | Tier | Befehle | |------|---------| -| **Lokal schnell** | `lint`, `typecheck`, `i18n:check`; optional `pnpm exec vitest run` **ohne** `--coverage` | +| **Lokal schnell** | `pnpm run ci:prepush` (änderungsbewusst; `DEFERRED_TO_REQUIRED_CI` für `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING` und nicht-TS-`TEST_ONLY`); optional `pnpm exec vitest run ` **ohne** `--coverage` | | **CI schwer** | Vitest **mit** Coverage (Schwellen 63/55/54/62 — lines/branches/functions/statements), `CI=true pnpm run test:e2e`, LHCI, `bundle:budget` | - Merge-Bar = **grüner GitHub-Workflow**, nicht voller lokaler E2E auf schwacher Hardware. diff --git a/.cursorrules b/.cursorrules index 6714e1436..49be42acb 100644 --- a/.cursorrules +++ b/.cursorrules @@ -63,7 +63,7 @@ Kurz und konkret: - Wenn der Nutzer **Deutsch** wünscht: Antworten auf **Deutsch**; Code- und API-Bezeichner unverändert nach Repo-Konvention. - Zusätzliche Repo-Leitplanken: **`.cursor/index.mdc`** (Manifest, `alwaysApply`) und **`.cursor/rules/*.mdc`** (001 Security, 100 KI/Storage, 150 i18n/Content, 200 Architektur, 300 UI, 800 Testing, 850 Doku/MCP). Nach Code-Änderungen optional **`pnpm run graphify:update`** bzw. `graphify:bootstrap`. -- Qualität lokal (schnell): `pnpm run lint`, `typecheck`, `i18n:check`; schwere Suites CI-first — siehe **`docs/CI.md`** und Regel **`800-testing-standards.mdc`**. +- Qualität lokal (schnell): `pnpm run ci:prepush` (änderungsbewusst; `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING` und nicht-TS-`TEST_ONLY` melden `DEFERRED_TO_REQUIRED_CI`), `node scripts/ci-prepush-lowend.mjs --full` auf leistungsfähiger Hardware; schwere Suites CI-first — siehe **`docs/CI.md`** und Regel **`800-testing-standards.mdc`**. - Bestehende Projekt-Tools (z. B. **Biome**, **Vitest**, **Playwright**, **Vite**) vor neuen Hilfsmitteln bevorzugen. - Keine unnötigen neuen Markdown-Dateien; Ausnahme: ausdrücklich gewünschte Doku (z. B. diese Rules). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8964bc794..1139cb71e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -120,7 +120,7 @@ types.ts → Core shared interfaces and types 3. Implement the real **root-cause** fix (code **+ tests + i18n + docs**), or reply with evidence if false-positive / by-design. **Never** add a new `biome-ignore` (suppression ratchet fails CI — refactor instead; run `node scripts/check-suppressions.mjs`). - 4. Local gate (sequential): lint + typecheck + targeted vitest green. + 4. Local admission (sequential): `pnpm run ci:prepush`; it is change-aware and may explicitly defer TypeScript to required cloud CI for `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, or non-TypeScript `TEST_ONLY` changes. 5. Commit + push; reply to **every** thread citing the resolving commit, then resolve it → **0 unresolved**. 6. Re-trigger: `gh pr comment --body "@coderabbitai review"`; check the **full** review history, not just the latest status (a rate-limited latest status can hide an earlier real review). @@ -169,7 +169,7 @@ On any non-trivial code change add a single-line comment explaining **why**, not - Conventional Commits format: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` - Pre-commit: after explicit `pnpm run hooks:install`, `simple-git-hooks` runs Biome on staged files; CI is mandatory regardless - **⚠️ Constrained local hardware — do NOT run heavy suites locally.** This machine has ~3–4 GB RAM. **Never** run the full Vitest **coverage** suite, **Playwright E2E**, **Stryker mutation**, **Lighthouse CI**, or the **Storybook test-runner** locally — they are **CI-only by design**. Run **one heavy command at a time** (no parallel `vitest`/`biome`/`tsc`/`vite`). -- Local preflight (sequential, minimal): `pnpm run lint` → `pnpm run typecheck` → `pnpm run i18n:check` (only when locale JSON changed) → **targeted** `pnpm exec vitest run ` (no `--coverage`). Run `pnpm run build && pnpm run smoke:prod` only when you touched `vite.config.ts`, `packages/ai-core`, or `workers/`. Coverage, E2E, Lighthouse, Stryker, and Storybook are **CI gate jobs** — let GitHub Actions run them. +- Local preflight (sequential, minimal): `pnpm run ci:prepush` → optional targeted `pnpm exec vitest run ` (no `--coverage`). The admission gate classifies changes and reports `DEFERRED_TO_REQUIRED_CI` for `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, or non-TypeScript `TEST_ONLY` changes; use `node scripts/ci-prepush-lowend.mjs --full` on capable hardware. Run `pnpm run build && pnpm run smoke:prod` only when you touched `vite.config.ts`, `packages/ai-core`, or `workers/`. Coverage, E2E, Lighthouse, Stryker, and Storybook are **CI gate jobs** — let GitHub Actions run them. - **Vitest watch-mode hard rule:** Never run `pnpm test`, `npm run test`, a bare Vitest command, or an untargeted wrapper. Always use `pnpm exec vitest run `; CI is the only place that runs the full coverage suite. - CI pipeline (see [`docs/CI.md`](../docs/CI.md)): **`security` → `quality`** (Biome + `tsc` + Vitest matrix) **→ `build` / `e2e` / `storybook` in parallel** → **`lighthouse`** after build → **`deploy`** on `main` after build+e2e - Branch protection should require the **`quality`** job (and other checks your team enables); job ids match `.github/workflows/ci.yml` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818c72f25..e8c955152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,9 @@ jobs: - name: Doc metrics drift gate (locale/key counts, stale PLANNED status) run: pnpm run docs:check + - name: Workflow governance policy gate + run: node scripts/check-workflow-policy.mjs + - name: CSP source synchronization and parity run: pnpm run csp:verify @@ -486,6 +489,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 needs: [ci-success] + # QNBS-v3: deployment is explicitly outside the aggregate and gated by its successful result. if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.ci-success.result == 'success' permissions: contents: read diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 91d013a56..d01931079 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -6,8 +6,9 @@ on: - cron: '30 4 * * 1' # every Monday 04:30 UTC workflow_dispatch: -# Minimal read-all default; individual job overrides what it needs. -permissions: read-all +# QNBS-v3: top-level read-only; the analysis job scopes its extra permissions explicitly. +permissions: + contents: read jobs: analysis: diff --git a/.github/workflows/tauri-intel-qualification.yml b/.github/workflows/tauri-intel-qualification.yml new file mode 100644 index 000000000..43d83d316 --- /dev/null +++ b/.github/workflows/tauri-intel-qualification.yml @@ -0,0 +1,167 @@ +# Non-publishing Intel qualification; this workflow never creates a release or latest.json. +name: Tauri Intel qualification + +on: + workflow_dispatch: + inputs: + qualification_ref: + description: Exact branch, tag, or SHA to qualify (defaults to the dispatched ref) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: tauri-intel-qualification-${{ github.ref }}-${{ inputs.qualification_ref || github.sha }} + cancel-in-progress: true + +jobs: + resolve-ref: + name: Resolve qualification ref + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + sha: ${{ steps.resolve.outputs.sha }} + steps: + - name: Resolve requested ref once + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.qualification_ref || github.sha }} + fetch-depth: 1 + persist-credentials: false + - name: Export immutable qualification SHA + id: resolve + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + git cat-file -e "$sha^{commit}" + printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT" + printf 'resolved_sha=%s\n' "$sha" > qualification-resolved-ref.txt + cat qualification-resolved-ref.txt + + qualify: + name: ${{ matrix.role }} (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + needs: [resolve-ref] + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-intel + role: primary-production-candidate + - runner: macos-26-intel + role: advisory-forward-compatibility + + steps: + - name: Check out the exact qualification ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-ref.outputs.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Record source ref + env: + REQUESTED_REF: ${{ inputs.qualification_ref || github.sha }} + RESOLVED_REF: ${{ needs.resolve-ref.outputs.sha }} + run: | + set -euo pipefail + printf 'source_ref=%s\n' "$(git rev-parse HEAD)" > qualification-source.txt + printf 'requested_ref=%s\n' "$REQUESTED_REF" >> qualification-source.txt + printf 'resolved_ref=%s\n' "$RESOLVED_REF" >> qualification-source.txt + cat qualification-source.txt + + - uses: ./.github/actions/setup + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + workspaces: src-tauri + cache-all-crates: true + prefix-key: "intel-qual-v1" + + - name: Install macOS packaging dependency + run: brew install create-dmg + + - name: Prepare non-publishing bundle configuration + run: | + set -euo pipefail + # QNBS-v3: qualification must not publish or mutate release metadata. + jq '.bundle.createUpdaterArtifacts = false' src-tauri/tauri.conf.json > src-tauri/tauri.conf.json.tmp + mv src-tauri/tauri.conf.json.tmp src-tauri/tauri.conf.json + printf 'updater_artifacts=disabled\n' > qualification-config.txt + printf 'release_publication=disabled\n' >> qualification-config.txt + cat qualification-config.txt + + - name: Build non-publishing Tauri bundle + run: | + set -euo pipefail + pnpm exec tauri build + + - name: Verify Intel architecture and deployment target + shell: bash + run: | + set -euo pipefail + test "$(uname -s)" = "Darwin" + host_arch="$(uname -m)" + test "$host_arch" = "x86_64" + + app="$(find src-tauri/target/release/bundle -type d -name '*.app' -print -quit)" + test -n "$app" + executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" + executable="$app/Contents/MacOS/$executable_name" + test -x "$executable" + + file "$executable" | tee qualification-file.txt + grep -Eq 'x86_64' qualification-file.txt + lipo -info "$executable" | tee qualification-lipo.txt + lipo_archs="$(lipo -archs "$executable")" + test "$lipo_archs" = "x86_64" + + load_commands="$(otool -l "$executable")" + printf '%s\n' "$load_commands" > qualification-otool.txt + minimum_os="$(awk '/LC_BUILD_VERSION/{mode="build"; next} /LC_VERSION_MIN_MACOSX/{mode="legacy"; next} mode == "build" && $1 == "minos"{print $2; exit} mode == "legacy" && $1 == "version"{print $2; exit}' qualification-otool.txt)" + test "$minimum_os" = "11.0" + + dmg="$(find src-tauri/target/release/bundle -type f -name '*.dmg' -print -quit)" + test -n "$dmg" + test -s "$dmg" + + updater_bundles="$(find src-tauri/target/release/bundle -type f \( -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' \) -print)" + printf 'updater_bundles=%s\n' "$updater_bundles" > qualification-updater-check.txt + test -z "$updater_bundles" + + cat > qualification-result.json <` **without** `--coverage`. **Hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper; always use an explicit `pnpm exec vitest run ` command to avoid watch-mode hangs on constrained hardware. Never start multiple heavyweight processes concurrently. 4. **Audit cloud CI logs, fix locally, then re-push** – If the cloud CI run fails, inspect the logs via GitHub web UI or `gh run watch`, reproduce the specific failing test or lint error in isolation, fix it locally (quick tier to verify), commit, and push again for another cloud CI run. @@ -295,8 +295,12 @@ procedure. ### Philosophy - **Cloud CI-first:** The canonical quality gate is GitHub Actions. Low-end local machines should run only the "Quick" tier. -- **Quick tier (local, before every push):** `pnpm run ci:prepush` runs the project typecheck with - one checker, i18n parity/quality/bundle/content checks, release/doc truth, and lightweight desktop guardrails sequentially; +- **Quick tier (local, before every push):** `pnpm run ci:prepush` performs change-aware admission with + bounded policy checks. It runs one-checker TypeScript validation only for TypeScript-impacting, + dependency, build, native-contract, mixed, ambiguous, or TypeScript test changes. For + `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript + `TEST_ONLY` changes it reports `DEFERRED_TO_REQUIRED_CI` rather than launching a full project scan. The complete + local tier is `node scripts/ci-prepush-lowend.mjs --full` on capable hardware; the pre-commit hook separately runs staged-file Biome checks. Run the gate again after every correction before re-pushing; do not push based only on a targeted test or a changed-file lint run. Optionally: diff --git a/CLAUDE.md b/CLAUDE.md index 278a73ce7..551347f80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,9 @@ pnpm run build # Production build to dist/ pnpm run smoke:prod # Headless mount check on dist/ (run AFTER build; catches prod-only crashes) pnpm run lint # Biome lint (--error-on-warnings — warnings fail like CI) pnpm run lint:fix # Biome auto-fix (lint + format) -pnpm run typecheck # TypeScript type check — EXACT CI command (tsgo --project tsconfig.tsgo.json --noEmit --checkers 4). typecheck:single = lighter single-checker (may miss errors the gate catches; do not trust for the gate) +pnpm run typecheck # Full TypeScript check (cloud quality authority; tsgo --project tsconfig.tsgo.json --noEmit --checkers 4) +pnpm run ci:prepush # Change-aware local admission; non-code, Rust/Tauri, tooling, and non-TypeScript test-only changes defer TS to required CI +node scripts/ci-prepush-lowend.mjs --full # Complete local admission on capable hardware pnpm exec vitest run # Targeted Vitest single run (CI mode) pnpm exec vitest run --coverage # Targeted Vitest coverage run pnpm run bench # Vitest perf benchmarks (tests/bench) — baseline gate for the Y.Doc-as-SoT / Local-First migration @@ -41,9 +43,9 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b **Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run ` command; watch mode hangs the constrained development hardware. -**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It runs the full repository lint, then the exact CI typecheck and i18n checks sequentially. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. +**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It performs change-aware, bounded local admission and emits explicit `PASS`, `FAIL`, `DEFERRED_TO_REQUIRED_CI`, or `LOCAL_RESOURCE_FAILURE` states. `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes do not launch the full TypeScript project scan; required GitHub CI remains the merge authority. Use `node scripts/ci-prepush-lowend.mjs --full` for complete local admission on capable hardware. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm run deps:reconcile` first. The pre-commit hook does not replace this gate. -**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint followed by the exact CI typecheck and i18n checks; CI additionally runs full-suite coverage and heavy jobs. Locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`). +**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs applicable policy guards and only the TypeScript validation justified by the outgoing change class; `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes explicitly defer TypeScript to required cloud CI. CI additionally runs full-suite coverage and heavy jobs. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`). **CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed). diff --git a/README.md b/README.md index ceaf3c849..f5ab081c7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6954+ tests / 575 files + 6972+ tests / 578 files Codecov Coverage License MIT CI Status @@ -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 (6954+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6972+ tests / 578 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 (6954+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6972+ tests, 578 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):** -- **6954+ unit tests** across **575 test files** — CI is authoritative for pass/fail +- **6972+ unit tests** across **578 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 ad059aac6..7e77b0f8c 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -14,7 +14,7 @@ For historical optimization notes (targets may predate the live workflow), see [ | Tier | Where | Commands / scope | |------|--------|------------------| -| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (single-checker typecheck, i18n quality, release/doc truth, and lightweight guardrails); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke | +| **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (change-aware bounded admission, applicable policy guards, and targeted TypeScript); `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report `DEFERRED_TO_REQUIRED_CI`; optional targeted `pnpm exec vitest run ` for a fast smoke | | **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). | **Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures. @@ -294,7 +294,7 @@ longer runs a root `prepare` command. `pnpm-workspace.yaml` sets `verifyDepsBefo ## Local checks (without Act) -On **low-resource** machines, stop at the **Quick** tier (see [Cloud CI-first vs local development](#cloud-ci-first-vs-local-development)): **`pnpm run ci:prepush`**, and optionally targeted **`pnpm exec vitest run `**. Never run multiple heavyweight local processes concurrently. Treat **`CI=true pnpm run test:e2e`** (desktop + mobile projects in CI), **Lighthouse**, coverage, Storybook, and mutation testing as **CI-owned**. +On **low-resource** machines, stop at the **Quick** tier (see [Cloud CI-first vs local development](#cloud-ci-first-vs-local-development)): **`pnpm run ci:prepush`**, and optionally targeted **`pnpm exec vitest run `**. The local gate classifies the outgoing change set; `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes do not launch a complete project `tsgo` scan and print `DEFERRED_TO_REQUIRED_CI` instead. Any timeout, signal termination, or resource kill is `LOCAL_RESOURCE_FAILURE`, never PASS. Use **`node scripts/ci-prepush-lowend.mjs --full`** only on capable hardware. Never run multiple heavyweight local processes concurrently. Treat **`CI=true pnpm run test:e2e`** (desktop + mobile projects in CI), **Lighthouse**, coverage, Storybook, and mutation testing as **CI-owned**. ```bash pnpm install --frozen-lockfile @@ -303,6 +303,30 @@ pnpm run ci:prepush pnpm exec vitest run # optional targeted smoke, no coverage ``` +### Change-aware local admission + +`ci:prepush` is local admission, not a replacement for merge CI. It always checks dependency +state, toolchain, diff integrity, documentation/release truth, CSP, the DesktopPlatform import +boundary, and native-readiness. Workflow changes also run the offline workflow-policy checker; +locale changes run applicable i18n integrity checks. The change classifier uses the safer class for +mixed or unknown paths. + +The result states are deliberately distinct: + +- `PASS` — the applicable local check completed successfully; +- `FAIL` — the check completed and found a defect; +- `DEFERRED_TO_REQUIRED_CI` — a provably unrelated expensive check was not run locally and remains + mandatory in GitHub CI; +- `LOCAL_RESOURCE_FAILURE` — timeout, signal termination, or resource exhaustion; this is never a + pass and requires recovery or use of the full tier on capable hardware. + +For TypeScript-impacting, dependency, build, native-contract, mixed, or ambiguous changes, the +default gate runs bounded single-checker `tsgo`. For `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, +`RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes it prints +`TypeScript DEFERRED_TO_REQUIRED_CI` with the reason `no TypeScript-impacting changes detected`. +The complete local tier is `node scripts/ci-prepush-lowend.mjs --full`; GitHub Actions remains the authoritative +full TypeScript, quality, security, CodeQL, build, test, and required-aggregate gate. + Playwright E2E, Lighthouse, Storybook, and full-suite coverage are intentionally omitted from the local block above; GitHub Actions owns those heavy checks on this hardware. diff --git a/docs/TAURI-CI.md b/docs/TAURI-CI.md index 30d1aeb46..18288b702 100644 --- a/docs/TAURI-CI.md +++ b/docs/TAURI-CI.md @@ -29,6 +29,29 @@ tolerates a missing arch (per-arch warning, hard-fail only if *no* arch signs), Intel runner option exists again (self-hosted, `macos-latest-large`, or a new hosted image), re-adding it to the matrix needs no other changes. Tracked as a re-opened follow-up in `TODO.md`. +### Intel qualification (non-publishing) + +Intel support is qualified separately from the production bundle matrix by the manual +[`tauri-intel-qualification.yml`](../.github/workflows/tauri-intel-qualification.yml) workflow: + +| Runner | Role | Release authority | +|--------|------|-------------------| +| `macos-15-intel` | Primary `x86_64` production candidate | None during qualification | +| `macos-26-intel` | Advisory forward-compatibility/toolchain probe | None | + +The workflow must be dispatched against an exact branch, tag, or SHA. It builds without publishing +a GitHub Release, updater manifest, or `darwin-x86_64` asset, and uploads only retained qualification +evidence. It mechanically checks the host architecture, app executable architecture, DMG output, +and effective macOS deployment target (`11.0`). The updater `.app.tar.gz` is intentionally not +produced in this non-publishing path; updater trust remains covered by the independent payload +verification evidence in [`docs/audit/H1-E-UPDATER-VERIFICATION-REPORT.md`](audit/H1-E-UPDATER-VERIFICATION-REPORT.md). + +This does not establish production support. Promotion requires at least three clean independent +qualification runs for the selected candidate, exact-ref evidence, architecture and deployment +target inspection, and an explicit decision that exactly one Intel producer may own the future +`darwin-x86_64` release authority. Until that gate passes, `macos-latest` remains the only +production macOS builder and v1.28.1 remains immutable. + ## Outputs Each matrix job uploads **`tauri-bundle-`** containing `src-tauri/target/release/bundle/` (`.deb`, `.msi`/`.exe`, `.dmg`/`.app` depending on OS). diff --git a/docs/audit/H1-DEVOPS-GOVERNANCE-INVENTORY.md b/docs/audit/H1-DEVOPS-GOVERNANCE-INVENTORY.md index 0eb60f0b5..e83cead25 100644 --- a/docs/audit/H1-DEVOPS-GOVERNANCE-INVENTORY.md +++ b/docs/audit/H1-DEVOPS-GOVERNANCE-INVENTORY.md @@ -54,8 +54,8 @@ query in a later session: | `.github/workflows/mutation.yml` | Manual `workflow_dispatch`; incremental/force modes and scoped matrix | CURRENT; MACHINE-DERIVABLE | H2 owns mutation evidence | | Scheduled/support workflows | `security-scheduled.yml`, `scorecard.yml`, `voice-nightly.yml`, `prune-deployments.yml`, `docker.yml`, and debug/deploy surfaces also exist | CURRENT; TIME-SENSITIVE | Include trigger/permission review in H1-F1 | | `.github/actions/setup/action.yml` | Shared Node/pnpm/install setup action | CURRENT; MACHINE-DERIVABLE | Treat as Level 0 toolchain authority | -| `package.json` | pnpm `11.22.0`, Node `>=22`, local `ci:prepush`, `typecheck:single`, i18n/docs/CSP/native guards, build and CI-only heavy commands | CURRENT; MACHINE-DERIVABLE | Derive commands rather than duplicating them in agent files | -| Hooks and signing | `simple-git-hooks`, `scripts/hooks/pre-commit.mjs`, `scripts/hooks/pre-push.mjs`, `pnpm run signing:doctor`; SSH signing enabled | CURRENT; MACHINE-DERIVABLE | Preserve signed-source and GitHub verification distinction | +| `package.json` | pnpm `11.22.0`, Node `>=22`, change-aware local `ci:prepush`, `typecheck:single`, i18n/docs/CSP/native guards, build and CI-only heavy commands | CURRENT; MACHINE-DERIVABLE | Derive commands rather than duplicating them in agent files; full local admission uses `node scripts/ci-prepush-lowend.mjs --full` | +| Hooks and signing | `simple-git-hooks`, bounded `scripts/hooks/pre-commit.mjs`/`pre-push.mjs`, `pnpm run signing:doctor`; SSH signing enabled | CURRENT; MACHINE-DERIVABLE | Preserve signed-source and GitHub verification distinction; timeout/resource failures are never PASS | | Test/build config | `vitest.config.ts` imports `scripts/coverage-thresholds.json`: lines 80, functions 72, branches 66, statements 78 | CURRENT executable truth | Reconcile stale prose in H1-F2/F4; do not lower thresholds | | Desktop/native config | `src-tauri/`, `crates/`, `src-tauri/tauri.conf.json`, native roadmap/ADRs | CURRENT architecture plus future gates | Do not make H1 a product/native implementation stage | @@ -98,7 +98,8 @@ query in a later session: This inventory does not decide the Node canonical lane, required/advisory promotion, build authority, or Intel production support. H1-E updater-payload verification is `PASS` using the production-compatible `minisign-verify 0.2.5` path and its positive/negative artifact matrix. The -remaining H1 decisions require H1-A through H1-D evidence. It does not authorize H2/H3/H4/H7 implementation, release work, +H1-D qualification workflow is now present but has not promoted an Intel producer. The remaining +H1 decisions require H1-A through H1-D evidence. It does not authorize H2/H3/H4/H7 implementation, release work, product changes, Qt/GPUI work, licensing, or #332/#341 remediation. The current evidence record and next tasks are: @@ -118,6 +119,8 @@ The current evidence record and next tasks are: code-signing, notarization, and Intel qualification as separate evidence questions; 5. defer H1-F1 canonical docs and H1-F2 agent synchronization until the relevant H1 decisions are verified, except for the early admin-bypass safety correction already made here. +6. Run the manual Intel qualification workflow on exact refs. Treat `macos-15-intel` as the + primary candidate and `macos-26-intel` as advisory; do not publish either runner's output. ## Resume invariant diff --git a/docs/audit/POST-V1.28.1-PERFECTION-PROGRAM-STATE.md b/docs/audit/POST-V1.28.1-PERFECTION-PROGRAM-STATE.md index 629ce6ff7..86af914c5 100644 --- a/docs/audit/POST-V1.28.1-PERFECTION-PROGRAM-STATE.md +++ b/docs/audit/POST-V1.28.1-PERFECTION-PROGRAM-STATE.md @@ -8,12 +8,12 @@ separates locally implemented evidence from hosted or merge-dependent evidence. | Field | Value | | --- | --- | | Program boundary | Post-v1.28.1; immutable release boundary preserved | -| Current stage | H1-A signal/timing/cache/rerun evidence integrated for the observable sample; H1-E updater-payload verification `PASS`; H1-F0 governance inventory active; H0 remains complete | -| Local state | This evidence branch preserves the original 50-run sample, verifies 0 observed reruns among those IDs, and records separate post-#473 `32648286172`, post-#474 `32654048692`, post-#475 `32657261089`, and post-#476 `32660607709` checkpoints; no Node lane, required-status, DAG, build, or advisory policy change made | -| Last reconciled main checkpoint | `fdd60c9465d7515dabc713d4e11f8ff2662fc5c4` (post-#476 verified main at the recorded checkpoint; not a perpetual live-main claim) | -| Latest completed PR / branch | PR #476 merged from `h1-a-failure-cause-evidence`; final PR head `84b5c187ab2040b1edcc7418ae6ee46ee324ffa1` | -| Latest completed merge | `fdd60c9465d7515dabc713d4e11f8ff2662fc5c4`; resulting tree `f0d24290765c4eb5e132941b2472117e7cfc3316`; merged `2026-08-23T19:14:20Z` | -| Hosted CI / CodeQL / Security | PR #476 final head `84b5c187…` merged normally; PR CI `32659129361`, PR CodeQL `32659129403`; fresh main CI `32660607709` and CodeQL `32660607683` were successful on merge SHA `fdd60c94…`, including `✅ CI Success`; historical sample rerun count is 0 and historical cache distribution remains `UNKNOWN` | +| Current stage | H1-A signal/timing/cache/rerun evidence integrated for the observable sample; H1-E updater-payload verification `PASS`; H1-D Intel qualification workflow is present but unexecuted/not promoted; H1-F0 governance inventory active; H0 remains complete | +| Local state | This branch adds non-publishing H1-D qualification workflow/docs plus resource-safe, change-aware local admission, bounded hook execution, outgoing-diff/workflow policy guards, and their focused tests. The original 50-run sample and separate post-#473 through post-#476 checkpoints remain immutable; no Node lane, required-status, DAG, build authority, release, or advisory policy change made | +| Last reconciled main checkpoint | `5806bd7ec6566661e575833f13f86b8e192f0ff4` (post-#490 verified main at the recorded checkpoint; not a perpetual live-main claim) | +| Latest completed PR / branch | PR #490 merged from `h1-e-updater-verification-evidence`; final PR head `014a3942595f84d4c36ab6de2281d4ff866a3d73` | +| Latest completed merge | `5806bd7ec6566661e575833f13f86b8e192f0ff4`; resulting tree `e10d9c87cb32b0bac2f8835b0315af911f43c814`; merged `2026-08-24T02:32:42Z` | +| Hosted CI / CodeQL / Security | PR #490 final head `014a3942…` merged normally; fresh main CI `32683407818` and CodeQL `32683407903` succeeded on merge SHA `5806bd7e…`, including `✅ CI Success`; historical sample rerun count is 0 and historical cache distribution remains `UNKNOWN` | | Affected issues | None claimed closed or remediated by H0 | | Release impact | None; no tag, release, updater metadata, or published asset changed; H1-E independently verified the three published updater payloads without private-key access | @@ -62,6 +62,7 @@ The first evidence-only H1 slice is also recorded with immutable evidence. Its m | H1-A checkpoint reconciliation | #474 | `d53aabfd9c3d06bd7c02610127548352d6d9b2da` | `8223d04e0b51443c6490695b0d08a4189bffe3ee` | `8223d04e0b51443c6490695b0d08a4189bffe3ee` / tree `6ab3a6143f872be2da2e1509225deca759b0bc7f` | PR CI `32652726821`; PR CodeQL `32652726773`; post-merge main CI `32654048692`; CodeQL `32654048709`; corrected ledger checkpoint, fresh main run ~22m17s, cache hits recorded, GitHub Verified | Continue H1-A evidence only where it can classify unique signal or historical cache/rerun behavior; do not advance H1-B/DAG authority | | H1-A post-#475 checkpoint evidence | #475 | `c088318c4c434b5e2238ce26db1804e290b4f493` | `3378fa4327e79bb77bcd98e1213dfe56acaefd09` | `3378fa4327e79bb77bcd98e1213dfe56acaefd09` / tree `d09334ae0e691d5d5cef8f214717a50634946f68` | PR CI `32655915753`; PR CodeQL `32655915784`; post-merge main CI `32657261089`; CodeQL `32657261084`; fresh main run ~23m09s, explicit cache hits, GitHub Verified | Preserve original sample and separate checkpoints; historical cache distribution and unique signal remain `UNKNOWN`; do not advance H1-B/DAG authority | | H1-A post-#476 checkpoint and rerun reconciliation | #476 | `84b5c187ab2040b1edcc7418ae6ee46ee324ffa1` | `fdd60c9465d7515dabc713d4e11f8ff2662fc5c4` | `fdd60c9465d7515dabc713d4e11f8ff2662fc5c4` / tree `f0d24290765c4eb5e132941b2472117e7cfc3316` | PR CI `32659129361`; PR CodeQL `32659129403`; post-merge main CI `32660607709`; CodeQL `32660607683`; fresh main run ~22m29s, explicit cache hits, GitHub Verified; all 50 original sample IDs are run attempt 1 | Treat sample-scoped rerun count as 0; retain historical cache distribution and unique signal as `UNKNOWN`; no H1-B/DAG authority decision | +| H1-E updater payload verification | #490 | `014a3942595f84d4c36ab6de2281d4ff866a3d73` | `5806bd7ec6566661e575833f13f86b8e192f0ff4` | `5806bd7ec6566661e575833f13f86b8e192f0ff4` / tree `e10d9c87cb32b0bac2f8835b0315af911f43c814` | PR CodeQL and required CI green; fresh main CI `32683407818`, CodeQL `32683407903`; squash commit GitHub Verified; 3 positive and 3 negative manifest-fed verifier cases; all material review threads resolved | Execute non-publishing H1-D Intel qualification on exact refs; do not promote production support from workflow presence alone | ## Next exact resume procedure @@ -77,6 +78,9 @@ The first evidence-only H1 slice is also recorded with immutable evidence. Its m production-compatible `minisign-verify 0.2.5` harness passed all three positive and three negative tests. H1-E is complete for updater-payload verification; platform code-signing, notarization, and Intel qualification remain separate evidence questions. +5. H1-D workflow presence is not qualification evidence. Dispatch it only on exact refs, inspect + both runner results, and require three clean independent `macos-15-intel` candidate runs before + any future `darwin-x86_64` promotion decision; `macos-26-intel` remains advisory. ## Verification record for the current working tree @@ -90,7 +94,8 @@ The first evidence-only H1 slice is also recorded with immutable evidence. Its m | Release/doc truth | Passed after source-synchronized metric update: 7 files match | | CSP, desktop import boundary, native readiness | Passed | | Direct single-checker `tsgo` | Exit 0; no diagnostics | -| `pnpm run ci:prepush` | Exit 0; all sequential low-end checks passed | +| Historical `pnpm run ci:prepush` before change-aware admission | Exit 0; all sequential low-end checks passed on the earlier H1-D working tree | +| Change-aware local admission recovery | Direct `node scripts/ci-prepush-lowend.mjs` passed on the current H1-D working tree as `NON_CODE_ONLY`; policy guards passed; TypeScript was explicitly `DEFERRED_TO_REQUIRED_CI`; no full-project `tsgo` launched. The `pnpm run ci:prepush` wrapper remains unavailable in this sandbox because pnpm cannot open its SQLite store / reports lockfile verification before invoking the script. | | Hosted CI / PR / signing | PR #469 final head was GitHub Verified and all five material review threads were resolved; PR CI `32629355521` passed; fresh main CI `32630810142` and CodeQL `32630810130` passed; normal protected squash merge produced GitHub Verified commit `476c0ce5…` | | H1-A measurement report | Historical replay reconciled omitted success `32613719445`; PR #472 integrated 50 unique runs with 26/9/15 outcomes and explicit skipped advisory jobs; fresh main evidence is green; no lane/DAG policy change | | H1-A failure/signal report | 9 first-attempt failures classified into four root-cause classes; 15 first-attempt cancellations classified as concurrency supersession by same-branch chronology; 0 lane-exclusive failures; advisory failure signal 0, unique signal `UNKNOWN`; post-#473 checkpoint `32648286172` successful with explicit checkpoint cache hits | diff --git a/package.json b/package.json index ddbc47b7b..f9c601f91 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0", + "yaml": "^2.9.0", "wrangler": "^4.120.1", "y-protocols": "^1.0.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5785b812b..a728cfcb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: wait-on: specifier: ^9.1.0 version: 9.1.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 wrangler: specifier: ^4.120.1 version: 4.120.1(@types/node@25.9.2) diff --git a/scripts/check-git-diff.d.mts b/scripts/check-git-diff.d.mts new file mode 100644 index 000000000..9e6a0b921 --- /dev/null +++ b/scripts/check-git-diff.d.mts @@ -0,0 +1 @@ +export function checkUntrackedFile(path: string): string[]; diff --git a/scripts/check-git-diff.mjs b/scripts/check-git-diff.mjs new file mode 100644 index 000000000..7600b3f62 --- /dev/null +++ b/scripts/check-git-diff.mjs @@ -0,0 +1,122 @@ +import { spawnSync } from 'node:child_process'; +import { lstatSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +function runGit(args, env = {}) { + return spawnSync('git', args, { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +function diagnostics(result) { + return [result.stdout, result.stderr] + .filter((value) => value?.trim()) + .flatMap((value) => value.trim().split(/\r?\n/)); +} + +function runGitCheck(args, label, env = {}) { + const result = runGit(args, env); + if (result.status === 0) return true; + const messages = diagnostics(result); + console.error(`${label} failed${messages.length > 0 ? `:\n${messages.join('\n')}` : ''}`); + return false; +} + +function withTemporaryIndex(callback) { + const directory = mkdtempSync(join(process.cwd(), '.tmp-git-index-')); + const index = join(directory, 'index'); + const objectDirectory = join(directory, 'objects'); + mkdirSync(objectDirectory); + const objectStore = runGit(['rev-parse', '--git-path', 'objects']); + if (objectStore.status !== 0) throw new Error('Git object store cannot be resolved'); + const env = { + GIT_INDEX_FILE: index, + GIT_OBJECT_DIRECTORY: objectDirectory, + GIT_ALTERNATE_OBJECT_DIRECTORIES: objectStore.stdout.trim(), + }; + try { + return callback(env); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// QNBS-v3: ask Git to evaluate untracked content through an isolated index instead of reimplementing diff semantics. +export function checkUntrackedFile(filePath) { + if (!lstatSync(filePath).isFile()) return []; + const relativePath = relative(process.cwd(), filePath); + return withTemporaryIndex((env) => { + const initial = runGit(['read-tree', '--empty'], env); + if (initial.status !== 0) return diagnostics(initial); + const add = runGit(['add', '--', relativePath], env); + if (add.status !== 0) return diagnostics(add); + return diagnostics(runGit(['diff', '--cached', '--check', '--', relativePath], env)); + }); +} + +function checkWorkingTree() { + return withTemporaryIndex((env) => { + if (!runGitCheck(['read-tree', 'HEAD'], 'temporary index initialization', env)) return false; + if ( + !runGitCheck( + [ + 'add', + '-A', + '--', + '.', + ':(exclude).worktrees/**', + ':(exclude)recovery-artifacts/**', + ':(exclude).tmp-git-index-*/**', + ':(exclude).tmp-prepush-tree-*/**', + ], + 'temporary index staging', + env, + ) + ) + return false; + return runGitCheck(['diff', '--cached', '--check'], 'working-tree diff check', env); + }); +} + +function runCheck() { + if (!checkWorkingTree()) process.exit(1); + + const explicitRanges = (process.env.WORLD_SCRIPT_PREPUSH_DIFF_RANGES ?? '') + .split('\n') + .map((range) => range.trim()) + .filter(Boolean); + const hasExplicitRanges = Object.hasOwn(process.env, 'WORLD_SCRIPT_PREPUSH_DIFF_RANGES'); + // QNBS-v3: compare exact remote and local tips for outgoing diff integrity. + const ranges = hasExplicitRanges + ? explicitRanges + : (process.env.WORLD_SCRIPT_PREPUSH_UPDATES ?? '') + .split('\n') + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 4) + .filter(([, localSha]) => !/^0+$/.test(localSha)) + .map(([, localSha, , remoteSha]) => { + if (!/^0+$/.test(remoteSha)) return `${remoteSha}..${localSha}`; + const originMain = runGit(['rev-parse', 'origin/main']); + if (originMain.status !== 0) throw new Error('origin/main cannot be resolved'); + return `${originMain.stdout.trim()}..${localSha}`; + }); + for (const range of ranges) { + if (!/^[0-9a-f]+\.\.[0-9a-f]+$/i.test(range)) { + console.error(`outgoing diff check received an invalid range: ${range}`); + process.exit(1); + } + if (!runGitCheck(['diff', '--check', range], 'outgoing diff check')) process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + runCheck(); + } catch { + process.exit(1); + } +} diff --git a/scripts/check-workflow-policy.mjs b/scripts/check-workflow-policy.mjs new file mode 100644 index 000000000..c4afcab14 --- /dev/null +++ b/scripts/check-workflow-policy.mjs @@ -0,0 +1,200 @@ +import { lstatSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import process from 'node:process'; +import { + containsSecretReference, + hasAggregateResultAssertion, + hasExecutableCloudTypecheckCommand, + isDeploymentTimeConditionalIf, + isReleasePublishingCommand, + isSemanticallyUnconditionalIf, +} from './workflow-policy-guards.mjs'; +import { + asRecord, + asStringList, + collectValuesByKey, + parseWorkflow, + workflowJobs, + workflowSteps, +} from './workflow-policy-parser.mjs'; + +const root = join(process.cwd(), '.github'); +const workflowRoot = join(root, 'workflows'); +const files = []; + +function collect(directory) { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) collect(path); + else if (/\.(?:yml|yaml)$/.test(entry)) files.push(path); + } +} + +collect(root); +// QNBS-v3: keep workflow governance checks offline and narrow so CI remains the authoritative execution gate. +const failures = []; +const parsedFiles = new Map(); + +function permissionValues(value) { + if (typeof value === 'string') return [value]; + return Object.values(asRecord(value)).filter((entry) => typeof entry === 'string'); +} + +function hasReadOnlyTopLevelPermissions(workflow) { + const permissions = workflow.permissions; + if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) return false; + return ( + permissions.contents === 'read' && + permissionValues(permissions).every((value) => ['none', 'read'].includes(value)) + ); +} + +function hasWriteAllPermissions(workflow) { + return collectValuesByKey(workflow, 'permissions').some((value) => + permissionValues(value).some((permission) => permission.toLowerCase() === 'write-all'), + ); +} + +function hasWriteCapability(workflow) { + return collectValuesByKey(workflow, 'permissions').some((value) => + permissionValues(value).some((permission) => permission.toLowerCase().endsWith('write')), + ); +} + +function actionReferences(workflow) { + return collectValuesByKey(workflow, 'uses').filter((value) => typeof value === 'string'); +} + +function runBodies(workflow) { + return workflowSteps(workflow) + .map((step) => step.run) + .filter((value) => typeof value === 'string'); +} + +for (const file of files) { + const label = relative(process.cwd(), file); + const content = readFileSync(file, 'utf8'); + let workflow; + try { + workflow = parseWorkflow(content, label); + parsedFiles.set(file, workflow); + } catch (error) { + failures.push(error instanceof Error ? error.message : `${label}: invalid YAML`); + continue; + } + + if (file.startsWith(workflowRoot) && !hasReadOnlyTopLevelPermissions(workflow)) + failures.push(`${label}: top-level permissions must include contents: read`); + if (hasWriteAllPermissions(workflow)) failures.push(`${label}: write-all permissions`); + + // QNBS-v3: inspect parsed action references so comments, quotes, and flow mappings cannot bypass pinning. + for (const reference of actionReferences(workflow)) { + if (reference.startsWith('./') || reference.startsWith('docker://')) continue; + if (!/@[0-9a-f]{40}$/i.test(reference)) failures.push(`${label}: unpinned action ${reference}`); + } +} + +const ciPath = join(workflowRoot, 'ci.yml'); +const ci = parsedFiles.get(ciPath); +if (!ci) failures.push('.github/workflows/ci.yml: workflow could not be parsed'); +const ciJobs = new Map(workflowJobs(ci ?? {})); +const ciSuccess = asRecord(ciJobs.get('ci-success')); +const ciNeeds = asStringList(ciSuccess.needs); +const ciSuccessRuns = workflowSteps({ jobs: { 'ci-success': ciSuccess } }) + .map((step) => step.run) + .filter((value) => typeof value === 'string'); + +// QNBS-v3: deployment-time conditionals also need an explicit aggregate disposition. +const explicitlyOutsideAggregateJobs = new Set(['deploy']); +for (const [jobName, job] of ciJobs) { + if (jobName === 'ci-success') continue; + const conditional = typeof job.if === 'string' && !isSemanticallyUnconditionalIf(job.if); + const deploymentTimeConditional = + typeof job.if === 'string' && isDeploymentTimeConditionalIf(job.if); + const advisory = job['continue-on-error'] === true; + const requiresDisposition = !conditional || deploymentTimeConditional; + if ( + requiresDisposition && + !ciNeeds.includes(jobName) && + !advisory && + !explicitlyOutsideAggregateJobs.has(jobName) + ) + failures.push( + `.github/workflows/ci.yml: job ${jobName} lacks required/advisory/explicit-outside disposition`, + ); +} + +const ciSource = readFileSync(ciPath, 'utf8').replace(/^\s*#.*$/gm, ''); +if (!/name:\s*["']?✅ CI Success/.test(ciSource)) { + failures.push('.github/workflows/ci.yml: missing required aggregate name'); +} +const qualityRuns = workflowSteps({ jobs: { quality: asRecord(ciJobs.get('quality')) } }) + .map((step) => step.run) + .filter((value) => typeof value === 'string'); +if (!hasExecutableCloudTypecheckCommand(qualityRuns)) { + failures.push('.github/workflows/ci.yml: missing full cloud TypeScript authority'); +} + +const requiredAggregateJobs = [ + 'security', + 'signatures', + 'quality', + 'changes', + 'rust-tauri', + 'core-rust', + 'build', + 'e2e', + 'lighthouse', + 'vrt', +]; +for (const dependency of requiredAggregateJobs) { + if (!ciNeeds.includes(dependency)) + failures.push(`.github/workflows/ci.yml: ci-success missing ${dependency} dependency`); +} +for (const dependency of ciNeeds) { + if ( + !hasAggregateResultAssertion( + ciSuccessRuns, + dependency, + ['rust-tauri', 'core-rust'].includes(dependency), + ) + ) + failures.push(`.github/workflows/ci.yml: ci-success does not assert ${dependency} result`); +} + +const intelPath = join(workflowRoot, 'tauri-intel-qualification.yml'); +if (files.includes(intelPath)) { + const intel = parsedFiles.get(intelPath); + const intelSource = readFileSync(intelPath, 'utf8'); + for (const [name, pattern] of [ + ['workflow dispatch', /workflow_dispatch:/], + ['primary Intel runner', /macos-15-intel/], + ['advisory Intel runner', /macos-26-intel/], + ]) { + if (!pattern.test(intelSource)) + failures.push(`${relative(process.cwd(), intelPath)}: missing ${name}`); + } + // QNBS-v3: capability isolation is authoritative; command scanning remains defense-in-depth. + const intelRuns = intel ? runBodies(intel) : []; + const intelRunSource = intelRuns.join('\n'); + if ( + (intel && hasWriteCapability(intel)) || + (intel && containsSecretReference(intel)) || + intelRunSource.includes('latest.json') || + intelRunSource.includes('softprops/action-gh-release') || + isReleasePublishingCommand(intelRuns) + ) { + failures.push( + `${relative(process.cwd(), intelPath)}: qualification workflow may publish release state`, + ); + } +} + +if (failures.length > 0) { + console.error('[workflow-policy] FAIL'); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} +console.log(`[workflow-policy] PASS (${files.length} workflow/action files checked)`); diff --git a/scripts/ci-prepush-check-registry.d.mts b/scripts/ci-prepush-check-registry.d.mts new file mode 100644 index 000000000..4d1708d03 --- /dev/null +++ b/scripts/ci-prepush-check-registry.d.mts @@ -0,0 +1 @@ +export function shouldRunAdmissionCheck(name: string, files: string[]): boolean; diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs new file mode 100644 index 000000000..e17bbf5d5 --- /dev/null +++ b/scripts/ci-prepush-check-registry.mjs @@ -0,0 +1,40 @@ +import { isI18nPolicyFile } from './ci-prepush-classifier.mjs'; + +const routingAuthority = 'scripts/ci-prepush-check-registry.mjs'; +const isGithubYaml = (file) => file.startsWith('.github/') && /\.(?:yml|yaml)$/i.test(file); + +export const admissionCheckRegistry = Object.freeze([ + { + name: 'i18n', + matches: (file) => + file.startsWith('locales/') || file.startsWith('public/locales/') || isI18nPolicyFile(file), + implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']), + }, + { + name: 'workflowPolicy', + // QNBS-v3: route every parsed GitHub YAML policy input through its governing check. + matches: isGithubYaml, + implementationFiles: new Set([ + routingAuthority, + 'scripts/ci-prepush-classifier.mjs', + 'scripts/check-workflow-policy.mjs', + 'scripts/workflow-policy-guards.mjs', + 'scripts/workflow-policy-parser.mjs', + 'scripts/workflow-policy-guards.d.mts', + ]), + }, + { + name: 'contentGuard', + matches: (file) => + file === 'scripts/content-guard.mjs' || + file.startsWith('community-templates/') || + file.startsWith('public/community-templates/'), + implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']), + }, +]); + +export function shouldRunAdmissionCheck(name, files) { + const entry = admissionCheckRegistry.find((candidate) => candidate.name === name); + if (!entry) throw new Error(`unknown local admission check: ${name}`); + return files.some((file) => entry.matches(file) || entry.implementationFiles.has(file)); +} diff --git a/scripts/ci-prepush-classifier.d.mts b/scripts/ci-prepush-classifier.d.mts new file mode 100644 index 000000000..ebe5426fb --- /dev/null +++ b/scripts/ci-prepush-classifier.d.mts @@ -0,0 +1,47 @@ +export type ChangeCategory = + | 'DOCS' + | 'WORKFLOW' + | 'RUST_TAURI' + | 'DESKTOP_NATIVE_CONTRACT' + | 'TEST_ONLY' + | 'TOOLING' + | 'DEPENDENCY_TOOLCHAIN' + | 'BUILD_CONFIGURATION' + | 'TYPESCRIPT_APPLICATION' + | 'UNKNOWN'; + +export type ChangeKind = + | 'NO_CHANGES' + | 'DOCS_ONLY' + | 'WORKFLOW_ONLY' + | 'NON_CODE_ONLY' + | ChangeCategory + | 'MIXED' + | 'AMBIGUOUS'; + +export interface ChangeClassification { + kind: ChangeKind; + categories: ChangeCategory[]; + files: string[]; +} + +export interface ProcessResult { + status: number | null; + signal: string | null; + error?: { code?: string } | null; + timedOut?: boolean; + interrupted?: boolean; +} + +export function classifyFile(file: string): ChangeCategory; +export function isWorkflowPolicyFile(file: string): boolean; +export function isI18nPolicyFile(file: string): boolean; +export function classifyChangedFiles(files: string[]): ChangeClassification; +export function requiresTypecheck( + classification: ChangeClassification, + options?: { full?: boolean }, +): boolean; +export function classifyProcessResult( + result: ProcessResult, +): 'PASS' | 'FAIL' | 'LOCAL_RESOURCE_FAILURE'; +export function classifySignatureResult(verified: boolean): 'PASS' | 'FAIL'; diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs new file mode 100644 index 000000000..1d87a21cf --- /dev/null +++ b/scripts/ci-prepush-classifier.mjs @@ -0,0 +1,170 @@ +const DOC_FILE = /\.(?:md|mdx)$/i; +const TS_FILE = /\.(?:c|m)?tsx?$|\.(?:c|m)?jsx?$/i; + +const DOC_ROOTS = ['docs/', '.cursor/rules/']; +const WORKFLOW_ROOTS = ['.github/workflows/', '.github/actions/']; +const RUST_ROOTS = ['src-tauri/', 'crates/']; +const TOOLING_ROOTS = ['scripts/']; +const TOOLING_FILES = new Set(['.gitleaks.toml']); +const WORKFLOW_POLICY_FILES = new Set([ + 'scripts/check-workflow-policy.mjs', + 'scripts/workflow-policy-guards.mjs', + 'scripts/workflow-policy-guards.d.mts', +]); +const I18N_POLICY_FILES = new Set(['scripts/check-i18n-keys.mjs', 'scripts/i18n-locales.mjs']); +const NATIVE_CONTRACT_ROOTS = [ + 'packages/desktop-contracts/', + 'services/desktop/', + 'services/platform/', +]; +const DEPENDENCY_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + '.npmrc', + '.nvmrc', + 'rust-toolchain', + 'rust-toolchain.toml', +]); +const BUILD_CONFIG_FILES = new Set([ + 'biome.json', + 'index.html', + 'playwright.config.ts', + 'postcss.config.js', + 'postcss.config.mjs', + 'tailwind.config.js', + 'tailwind.config.ts', + 'turbo.json', + 'vite.config.ts', + 'vitest.config.ts', +]); + +function startsWithRoot(file, roots) { + return roots.some((root) => file.startsWith(root)); +} + +export function isWorkflowPolicyFile(file) { + return WORKFLOW_POLICY_FILES.has(file.replaceAll('\\', '/').replace(/^\.\//, '')); +} + +export function isI18nPolicyFile(file) { + return I18N_POLICY_FILES.has(file.replaceAll('\\', '/').replace(/^\.\//, '')); +} + +function isInstructionFile(file) { + return ( + file === 'AGENTS.md' || + file === 'CLAUDE.md' || + file === '.cursorrules' || + file === '.github/copilot-instructions.md' || + file.startsWith('.cursor/rules/') + ); +} + +export function classifyFile(file) { + const normalized = file.replaceAll('\\', '/').replace(/^\.\//, ''); + const base = normalized.split('/').at(-1) ?? normalized; + + if (startsWithRoot(normalized, WORKFLOW_ROOTS)) return 'WORKFLOW'; + if ( + DOC_FILE.test(normalized) || + startsWithRoot(normalized, DOC_ROOTS) || + isInstructionFile(normalized) + ) { + return 'DOCS'; + } + if ( + RUST_ROOTS.some((root) => normalized.startsWith(root)) || + /(?:^|\/)(?:Cargo\.toml|Cargo\.lock)$/.test(normalized) || + normalized.endsWith('.rs') + ) { + return 'RUST_TAURI'; + } + if ( + NATIVE_CONTRACT_ROOTS.some((root) => normalized.startsWith(root)) || + /DesktopPlatform|desktop-contract/i.test(normalized) + ) { + return 'DESKTOP_NATIVE_CONTRACT'; + } + if (normalized.startsWith('tests/')) + return TS_FILE.test(normalized) ? 'TYPESCRIPT_APPLICATION' : 'TEST_ONLY'; + if (TOOLING_FILES.has(normalized)) return 'TOOLING'; + if (startsWithRoot(normalized, TOOLING_ROOTS)) return 'TOOLING'; + if ( + DEPENDENCY_FILES.has(base) || + normalized.startsWith('patches/') || + (normalized.startsWith('packages/') && base === 'package.json') + ) { + return 'DEPENDENCY_TOOLCHAIN'; + } + if (BUILD_CONFIG_FILES.has(base) || normalized.startsWith('scripts/')) + return 'BUILD_CONFIGURATION'; + if (TS_FILE.test(normalized) && !normalized.startsWith('scripts/')) + return 'TYPESCRIPT_APPLICATION'; + return 'UNKNOWN'; +} + +// QNBS-v3: classify only the outgoing impact so local admission stays resource-safe without weakening cloud authority. +export function classifyChangedFiles(files) { + const normalizedFiles = [ + ...new Set( + files.map((file) => file.replaceAll('\\', '/').replace(/^\.\//, '')).filter(Boolean), + ), + ].sort(); + const categories = [...new Set(normalizedFiles.map(classifyFile))]; + + if (normalizedFiles.length === 0) + return { kind: 'NO_CHANGES', categories: [], files: normalizedFiles }; + if (categories.every((category) => category === 'DOCS')) { + return { kind: 'DOCS_ONLY', categories, files: normalizedFiles }; + } + if (categories.every((category) => category === 'WORKFLOW')) { + return { kind: 'WORKFLOW_ONLY', categories, files: normalizedFiles }; + } + if (categories.length === 1) { + if (categories[0] === 'UNKNOWN') + return { kind: 'AMBIGUOUS', categories, files: normalizedFiles }; + return { kind: categories[0], categories, files: normalizedFiles }; + } + if ( + categories.every((category) => ['DOCS', 'WORKFLOW', 'TOOLING', 'TEST_ONLY'].includes(category)) + ) { + return { kind: 'NON_CODE_ONLY', categories, files: normalizedFiles }; + } + if (categories.includes('UNKNOWN')) + return { kind: 'AMBIGUOUS', categories, files: normalizedFiles }; + return { kind: 'MIXED', categories, files: normalizedFiles }; +} + +export function requiresTypecheck(classification, { full = false } = {}) { + if (full) return true; + return ![ + 'NO_CHANGES', + 'DOCS_ONLY', + 'WORKFLOW_ONLY', + 'NON_CODE_ONLY', + 'RUST_TAURI', + 'TOOLING', + 'TEST_ONLY', + ].includes(classification.kind); +} + +export function classifyProcessResult(result) { + // QNBS-v3: classify timeout, interruption, and signal termination as non-pass local resource failures. + if ( + ['EAGAIN', 'ENOMEM'].includes(result.error?.code) || + result.timedOut || + result.interrupted || + result.signal || + result.status === 137 || + result.status === 143 + ) { + return 'LOCAL_RESOURCE_FAILURE'; + } + if (result.status === 0) return 'PASS'; + return 'FAIL'; +} + +export function classifySignatureResult(verified) { + return verified ? 'PASS' : 'FAIL'; +} diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 20ba9d4bb..89cac6307 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,43 +1,448 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; import process from 'node:process'; -import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs'; +import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs'; +import { + classifyChangedFiles, + classifyProcessResult, + requiresTypecheck, +} from './ci-prepush-classifier.mjs'; +import { calculateDependencyFingerprint, writeStoredFingerprint } from './dependency-state.mjs'; +import { + ensureDependencyState, + runLocalBinaryDetailed, + runNodeScriptDetailed, +} from './hooks/shared.mjs'; -const checks = [ - ['toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])], +const projectRoot = process.cwd(); +const full = process.argv.includes('--full'); +const isPrePush = Boolean(process.env.WORLD_SCRIPT_PREPUSH_UPDATES); +const isExactTree = process.env.WORLD_SCRIPT_PREPUSH_EXACT_TREE === '1'; +const pushRemoteName = process.env.WORLD_SCRIPT_PREPUSH_REMOTE_NAME ?? 'origin'; + +function git(args, { allowFailure = false } = {}) { + const result = spawnSync('git', args, { cwd: projectRoot, encoding: 'utf8' }); + if (result.status === 0) return result.stdout?.trim() ?? ''; + if (allowFailure) return ''; + throw result.error ?? new Error(`git ${args.join(' ')} failed with status ${result.status}`); +} + +function gitRaw(args, { allowFailure = false } = {}) { + const result = spawnSync('git', args, { cwd: projectRoot, encoding: 'utf8' }); + if (result.status === 0) return result.stdout ?? ''; + if (allowFailure) return ''; + throw result.error ?? new Error(`git ${args.join(' ')} failed with status ${result.status}`); +} + +function parseNulDelimitedPaths(output) { + return output.split('\0').filter(Boolean); +} + +function changedFilesFromWorkingTree() { + return parseNulDelimitedPaths( + gitRaw(['diff', '--no-renames', '--name-only', '-z', 'HEAD'], { allowFailure: true }), + ) + .concat( + parseNulDelimitedPaths( + gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], { allowFailure: true }), + ), + ) + .filter((file) => !file.startsWith('.worktrees/') && !file.startsWith('recovery-artifacts/')); +} + +function changedFilesFromRef(target, base) { + // QNBS-v3: compare exact pushed tips so local admission matches the outgoing ref update. + if (!target || !base) throw new Error('outgoing comparison base or target is unresolved'); + return parseNulDelimitedPaths( + gitRaw(['diff', '--no-renames', '--name-only', '-z', `${base}..${target}`]), + ); +} + +function resolveComparisonBase(remoteSha) { + if (!/^0+$/.test(remoteSha)) return remoteSha; + const remoteHead = git(['symbolic-ref', '--quiet', `refs/remotes/${pushRemoteName}/HEAD`], { + allowFailure: true, + }); + if (remoteHead) return git(['rev-parse', remoteHead]); + for (const candidate of [`${pushRemoteName}/main`, `${pushRemoteName}/master`]) { + const resolved = git(['rev-parse', candidate], { allowFailure: true }); + if (resolved) return resolved; + } + throw new Error(`default branch for remote ${pushRemoteName} cannot be resolved`); +} + +function parsePrePushUpdates(raw) { + return raw + .split('\n') + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 4) + .map(([localRef, localSha, remoteRef, remoteSha]) => ({ + localRef, + localSha, + remoteRef, + remoteSha, + })); +} + +// QNBS-v3: combine committed outgoing refs with safe working-tree changes without scanning preserved evidence trees. +function resolveChangeSet() { + const exactFiles = (process.env.WORLD_SCRIPT_PREPUSH_EXACT_FILES ?? '') + .split('\n') + .map((file) => file.trim()) + .filter(Boolean); + const files = new Set(isExactTree ? exactFiles : changedFilesFromWorkingTree()); + const ranges = []; + const updates = parsePrePushUpdates(process.env.WORLD_SCRIPT_PREPUSH_UPDATES ?? ''); + let unresolved = false; + + if (isExactTree) + return { + files: [...files], + ranges, + updates, + unresolved: false, + }; + + // QNBS-v3: retain unresolved range state so incomplete change discovery cannot pass. + function addRefFiles(target, base) { + try { + const changed = changedFilesFromRef(target, base); + ranges.push(`${base}..${target}`); + for (const file of changed) files.add(file); + } catch (error) { + unresolved = true; + console.error(`[local-admission] outgoing change range unresolved: ${error.message}`); + } + } + + if (updates.length > 0) { + for (const { localSha, remoteSha } of updates) { + if (/^0+$/.test(localSha)) continue; + let base; + try { + base = resolveComparisonBase(remoteSha); + } catch (error) { + unresolved = true; + console.error(`[local-admission] comparison base unresolved: ${error.message}`); + continue; + } + if (!base) { + unresolved = true; + continue; + } + addRefFiles(localSha, base); + } + } else { + let upstream = ''; + try { + upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); + } catch { + // A detached or new branch may not have an upstream yet; origin/main is the safe fallback. + } + let base = ''; + try { + base = git([...(upstream ? ['rev-parse', upstream] : ['rev-parse', 'origin/main'])]); + } catch (error) { + unresolved = true; + console.error(`[local-admission] comparison base unresolved: ${error.message}`); + } + let head = ''; + try { + head = git(['rev-parse', 'HEAD']); + } catch (error) { + unresolved = true; + console.error(`[local-admission] HEAD unresolved: ${error.message}`); + } + if (head && base) addRefFiles(head, base); + else unresolved = true; + } + + return { files: [...files], ranges, updates, unresolved }; +} + +function report(name, status, detail = '') { + console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`); + return status; +} + +async function runNodeCheck(name, script, args = [], timeoutMs = 120_000, env = {}) { + // QNBS-v3: surface parent interruption distinctly from ordinary check failure. + const result = await runNodeScriptDetailed(script, args, { timeoutMs, env, detached: false }); + const status = classifyProcessResult(result); + const detail = result.timedOut + ? `timeout after ${timeoutMs}ms` + : result.interrupted + ? 'interrupted by parent signal' + : (result.signal ?? ''); + report(name, status, detail); + return status; +} + +function symlinkTargetType(source) { + if (process.platform !== 'win32') return 'dir'; + return statSync(source).isDirectory() ? 'junction' : 'file'; +} + +function mirrorInstalledDependencies(sourceRoot, targetRoot) { + mkdirSync(targetRoot, { recursive: true }); + for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) { + if (entry.name === '.worldscript-deps-fingerprint') continue; + const source = join(sourceRoot, entry.name); + const target = join(targetRoot, entry.name); + symlinkSync(source, target, symlinkTargetType(source)); + } +} + +async function runExactTreeAdmission(localSha, changedFiles) { + const treeRoot = mkdtempSync(join(projectRoot, '.tmp-prepush-tree-')); + let worktreeAdded = false; + try { + const add = spawnSync('git', ['worktree', 'add', '--detach', treeRoot, localSha], { + cwd: projectRoot, + encoding: 'utf8', + stdio: 'inherit', + }); + if (add.status !== 0) { + report('Exact pushed tree', 'FAIL', `cannot materialize ${localSha}`); + return false; + } + worktreeAdded = true; + const sourceNodeModules = join(projectRoot, 'node_modules'); + const exactNodeModules = join(treeRoot, 'node_modules'); + if (!existsSync(sourceNodeModules)) { + report('Exact pushed tree', 'FAIL', 'installed dependencies are missing'); + return false; + } + // QNBS-v3: share immutable package entries but create a fingerprint for this exact tree. + mirrorInstalledDependencies(sourceNodeModules, exactNodeModules); + writeStoredFingerprint(treeRoot, calculateDependencyFingerprint(treeRoot)); + // QNBS-v3: mirror installed workspace links so full-tree typechecking resolves package-local dependencies. + for (const entry of readdirSync(join(projectRoot, 'packages'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const source = join(projectRoot, 'packages', entry.name, 'node_modules'); + if (!existsSync(source)) continue; + symlinkSync( + source, + join(treeRoot, 'packages', entry.name, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } + const exactTypeScriptConfig = join(treeRoot, '.tsconfig-exact-tree.json'); + writeFileSync( + exactTypeScriptConfig, + JSON.stringify({ + extends: './tsconfig.tsgo.json', + // QNBS-v3: validate reverse dependents in the immutable tree, not only changed sources. + include: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + exclude: ['node_modules', 'dist', '.storybook', '.mcp', 'storybook-static'], + compilerOptions: { + types: ['react', 'react-dom', 'node'], + typeRoots: ['./types', './node_modules/@types'], + }, + }), + ); + const result = await runNodeScriptDetailed('scripts/ci-prepush-lowend.mjs', [], { + timeoutMs: 900_000, + cwd: treeRoot, + root: treeRoot, + env: { + ...process.env, + WORLD_SCRIPT_PREPUSH_UPDATES: '', + WORLD_SCRIPT_PREPUSH_EXACT_TREE: '1', + WORLD_SCRIPT_PREPUSH_EXACT_FILES: changedFiles.join('\n'), + WORLD_SCRIPT_PREPUSH_PROJECT_CONFIG: exactTypeScriptConfig, + }, + detached: false, + }); + const status = classifyProcessResult(result); + report('Exact pushed tree', status, localSha.slice(0, 12)); + return status === 'PASS'; + } finally { + if (worktreeAdded) + spawnSync('git', ['worktree', 'remove', '--force', treeRoot], { + cwd: projectRoot, + stdio: 'ignore', + }); + rmSync(treeRoot, { recursive: true, force: true }); + } +} + +async function runGitDiffCheck(ranges) { + return runNodeCheck('Diff integrity', 'scripts/check-git-diff.mjs', [], 15_000, { + WORLD_SCRIPT_PREPUSH_DIFF_RANGES: ranges.join('\n'), + }); +} + +const changes = resolveChangeSet(); +if (isPrePush && !isExactTree && changes.updates.length > 0) { + const workingTreeStatus = await runNodeCheck( + 'Working-tree diff integrity', + 'scripts/check-git-diff.mjs', + [], + 15_000, + { WORLD_SCRIPT_PREPUSH_DIFF_RANGES: '' }, + ); + if (workingTreeStatus !== 'PASS') process.exit(1); + const outgoingRangeStatus = await runGitDiffCheck(changes.ranges); + if (outgoingRangeStatus !== 'PASS') process.exit(1); + for (const { localSha, remoteSha } of changes.updates) { + if (/^0+$/.test(localSha)) continue; + let base; + try { + base = resolveComparisonBase(remoteSha); + } catch (error) { + report('Exact pushed tree', 'FAIL', `comparison base unresolved: ${error.message}`); + process.exit(1); + } + let exactFiles; + try { + exactFiles = changedFilesFromRef(localSha, base); + } catch (error) { + report('Exact pushed tree', 'FAIL', `changed paths unresolved: ${error.message}`); + process.exit(1); + } + if (!(await runExactTreeAdmission(localSha, exactFiles))) process.exit(1); + } + process.exit(0); +} +const baseClassification = classifyChangedFiles(changes.files); +const classification = changes.unresolved + ? { + ...baseClassification, + kind: 'AMBIGUOUS', + categories: [...new Set([...baseClassification.categories, 'UNKNOWN'])], + } + : baseClassification; +if (changes.unresolved) { + // QNBS-v3: fail closed before checks run when outgoing scope is incomplete. + report('Change-set resolution', 'FAIL', 'outgoing tips or comparison base could not be resolved'); + process.exit(1); +} +const typecheckRequired = requiresTypecheck(classification, { full }); +const results = []; + +console.log(`[local-admission] change class: ${classification.kind}`); +console.log(`[local-admission] files considered: ${classification.files.length}`); + +if (!ensureDependencyState()) { + report('Dependency state', 'FAIL'); + process.exit(1); +} +results.push(['Dependency state', 'PASS']); + +const mandatoryChecks = [ + ['Toolchain', () => runNodeCheck('Toolchain', 'scripts/check-pnpm-toolchain.mjs', ['--hook'])], + ['Diff integrity', () => runGitDiffCheck(changes.ranges)], + ['Docs/release truth', () => runNodeCheck('Docs/release truth', 'scripts/check-doc-metrics.mjs')], + ['CSP policy', () => runNodeCheck('CSP policy', 'scripts/check-csp-policy.mjs')], [ - 'typecheck (single checker)', - // QNBS-v3: Make the low-end resource contract explicit; tsgo's default checker count is not a safe local default. - () => - runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']), + 'Desktop import boundary', + () => runNodeCheck('Desktop import boundary', 'scripts/check-tauri-import-boundary.mjs'), ], - ['i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')], - ['i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')], - ['i18n content guard', () => runNodeScript('scripts/content-guard.mjs')], [ - 'i18n translation quality', - () => - runNodeScript('scripts/i18n-quality-report.mjs', [ - '--strict', - '--min-coverage', - '75', - '--max-length-outliers', - '8', - ]), + 'Native readiness', + () => runNodeCheck('Native readiness', 'scripts/check-native-readiness.mjs'), ], - ['release/doc truth', () => runNodeScript('scripts/check-doc-metrics.mjs')], - ['CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')], - ['desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs')], - ['native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')], ]; -if (!ensureDependencyState()) process.exit(1); +for (const [name, check] of mandatoryChecks) { + const status = await check(); + results.push([name, status]); + if (status !== 'PASS') process.exit(1); +} -for (const [name, run] of checks) { - console.log(`[local-lowend] ${name}`); - const status = run(); - if (status !== 0) { - console.error(`[local-lowend] failed: ${name}`); - process.exit(status); +if (shouldRunAdmissionCheck('workflowPolicy', classification.files)) { + const status = await runNodeCheck('Workflow policy', 'scripts/check-workflow-policy.mjs'); + results.push(['Workflow policy', status]); + if (status !== 'PASS') process.exit(1); +} + +if (full || shouldRunAdmissionCheck('i18n', classification.files)) { + const i18nChecks = [ + ['i18n key parity', 'scripts/check-i18n-keys.mjs', [], 180_000], + ...(full + ? [ + ['i18n bundle rebuild', 'scripts/build-i18n.mjs', [], 180_000], + [ + 'i18n translation quality', + 'scripts/i18n-quality-report.mjs', + ['--strict', '--min-coverage', '75', '--max-length-outliers', '8'], + 180_000, + ], + ] + : []), + ]; + for (const [name, script, args, timeoutMs] of i18nChecks) { + const status = await runNodeCheck(name, script, args, timeoutMs); + results.push([name, status]); + if (status !== 'PASS') process.exit(1); } } -console.log('[local-lowend] pre-push checks passed sequentially.'); +if (full || shouldRunAdmissionCheck('contentGuard', classification.files)) { + const status = await runNodeCheck('Content guard', 'scripts/content-guard.mjs', [], 120_000); + results.push(['Content guard', status]); + if (status !== 'PASS') process.exit(1); +} + +if (typecheckRequired) { + const result = await runLocalBinaryDetailed( + 'tsgo', + [ + '--project', + process.env.WORLD_SCRIPT_PREPUSH_PROJECT_CONFIG ?? 'tsconfig.tsgo.json', + '--noEmit', + '--checkers', + full ? '4' : '1', + ], + { timeoutMs: full ? 600_000 : 180_000, detached: false }, + ); + const status = classifyProcessResult(result); + const detail = result.timedOut + ? 'bounded timeout' + : result.signal + ? `terminated by ${result.signal}` + : ''; + report('TypeScript', status, detail); + results.push(['TypeScript', status]); + if (status !== 'PASS') process.exit(1); +} else { + report('TypeScript', 'DEFERRED_TO_REQUIRED_CI', 'no TypeScript-impacting changes detected'); + results.push(['TypeScript', 'DEFERRED_TO_REQUIRED_CI']); +} + +console.log('\nLOCAL ADMISSION RESULT'); +for (const [name, status] of results) console.log(`${name.padEnd(26)} ${status}`); +console.log(`Outgoing signatures ${isPrePush ? 'PASS' : 'SIGNING_HOOK_REQUIRED'}`); +console.log('Cloud validation required YES'); +console.log(`Classification ${classification.kind}`); +console.log( + `TypeScript local tier ${ + typecheckRequired + ? full + ? 'FULL (4 checkers)' + : 'BOUNDED (1 checker)' + : 'DEFERRED_TO_REQUIRED_CI' + }`, +); +console.log( + `LOCAL_ADMISSION_JSON ${JSON.stringify({ + classification, + full, + typecheckMode: typecheckRequired ? (full ? 'FULL' : 'BOUNDED') : 'DEFERRED_TO_REQUIRED_CI', + isPrePush, + results: Object.fromEntries(results), + outgoingSignatures: isPrePush ? 'PASS' : 'SIGNING_HOOK_REQUIRED', + cloudValidationRequired: true, + })}`, +); diff --git a/scripts/hooks/pre-commit.mjs b/scripts/hooks/pre-commit.mjs index 9a6ef9eec..42fae9771 100644 --- a/scripts/hooks/pre-commit.mjs +++ b/scripts/hooks/pre-commit.mjs @@ -1,6 +1,6 @@ import process from 'node:process'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './shared.mjs'; -if (runNodeScript('scripts/signing/doctor.mjs', ['--hook']) !== 0) process.exit(1); +if ((await runNodeScript('scripts/signing/doctor.mjs', ['--hook'])) !== 0) process.exit(1); if (!ensureDependencyState()) process.exit(1); -process.exit(runLocalBinary('lint-staged')); +process.exit(await runLocalBinary('lint-staged')); diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs index 5c8f3aa39..310025b62 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -1,6 +1,21 @@ +import { readFileSync } from 'node:fs'; import process from 'node:process'; import { runNodeScript } from './shared.mjs'; -if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0) +// QNBS-v3: preserve the one-shot ref update stream so admission checks validate the exact outgoing range. +const updates = readFileSync(0, 'utf8'); +const options = { input: updates }; +// QNBS-v3: resolve all-zero remote refs against the remote being pushed, not a hard-coded origin. +process.env.WORLD_SCRIPT_PREPUSH_REMOTE_NAME = process.argv[2] ?? 'origin'; + +if ( + (await runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2), options)) !== 0 +) process.exit(1); -process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs')); +process.env.WORLD_SCRIPT_PREPUSH_UPDATES = updates; +process.exit( + await runNodeScript('scripts/ci-prepush-lowend.mjs', [], { + env: process.env, + timeoutMs: 900_000, + }), +); diff --git a/scripts/hooks/shared.d.mts b/scripts/hooks/shared.d.mts new file mode 100644 index 000000000..68d6d7952 --- /dev/null +++ b/scripts/hooks/shared.d.mts @@ -0,0 +1,22 @@ +export interface BoundedResult { + status: number | null; + signal: string | null; + error: Error | null; + timedOut: boolean; + interrupted: boolean; + command: string; +} + +export function runBounded( + command: string, + args: string[], + options?: { + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + input?: string; + shell?: boolean; + cwd?: string; + root?: string; + detached?: boolean; + }, +): Promise; diff --git a/scripts/hooks/shared.mjs b/scripts/hooks/shared.mjs index 5785f8e92..04b7f5d47 100644 --- a/scripts/hooks/shared.mjs +++ b/scripts/hooks/shared.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import process from 'node:process'; @@ -7,9 +7,9 @@ import { verifyDependencyState } from '../dependency-state.mjs'; const projectRoot = resolve(fileURLToPath(new URL('../..', import.meta.url))); -export function ensureDependencyState() { +export function ensureDependencyState(root = projectRoot) { try { - verifyDependencyState(projectRoot); + verifyDependencyState(root); return true; } catch (error) { console.error(`[hook] ${error instanceof Error ? error.message : String(error)}`); @@ -18,17 +18,179 @@ export function ensureDependencyState() { } } -export function runNodeScript(script, args = []) { - const result = spawnSync(process.execPath, [resolve(projectRoot, script), ...args], { - cwd: projectRoot, - stdio: 'inherit', +// QNBS-v3: bound hook children so timeout or resource termination is observable instead of an implicit pass. +export function runBounded( + command, + args, + { + timeoutMs = 120_000, + env, + input, + shell = false, + cwd = projectRoot, + detached = process.platform !== 'win32', + } = {}, +) { + return new Promise((resolveResult) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + shell, + detached: detached && process.platform !== 'win32', + stdio: input === undefined ? 'inherit' : ['pipe', 'inherit', 'inherit'], + }); + let timedOut = false; + let interrupted = false; + let terminationRequested = false; + let cleanupStarted = false; + let cleanupDeadline = 0; + let pendingFinish = null; + let state = 'RUNNING'; + let settled = false; + let forceTimer; + const terminate = (signal) => { + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall back to the direct child when a process group is unavailable. + } + } else if (process.platform === 'win32' && child.pid) { + const result = spawnSync( + 'taskkill', + ['/pid', String(child.pid), '/t', ...(signal === 'SIGKILL' ? ['/f'] : [])], + { windowsHide: true, stdio: 'ignore' }, + ); + if (result.status === 0) return; + } + try { + child.kill(signal); + } catch { + // The child may have exited between process-group and direct cleanup attempts. + } + }; + const cleanupComplete = () => { + if (!child.pid || process.platform === 'win32') return true; + try { + process.kill(-child.pid, 0); + return false; + } catch (error) { + return error?.code === 'ESRCH'; + } + }; + const finishAfterCleanup = () => { + if (!cleanupComplete() && Date.now() < cleanupDeadline) { + setTimeout(finishAfterCleanup, 20); + return; + } + complete( + pendingFinish?.status ?? null, + pendingFinish?.signal ?? 'SIGKILL', + pendingFinish?.error ?? null, + ); + }; + const beginForceCleanup = () => { + if (cleanupStarted || state === 'SETTLED') return; + cleanupStarted = true; + state = 'FORCE_CLEANUP_RUNNING'; + if (forceTimer) { + clearTimeout(forceTimer); + forceTimer = undefined; + } + terminate('SIGKILL'); + cleanupDeadline = Date.now() + 1_000; + finishAfterCleanup(); + }; + const scheduleForceTermination = () => { + if (forceTimer) clearTimeout(forceTimer); + state = 'FORCE_CLEANUP_PENDING'; + forceTimer = setTimeout(() => { + forceTimer = undefined; + beginForceCleanup(); + }, 1_000); + }; + const requestTermination = (signal, reason, error = null) => { + if (reason === 'timeout') timedOut = true; + else if (reason === 'interrupt') interrupted = true; + if (error) pendingFinish = { status: null, signal: null, error }; + if (terminationRequested) { + // QNBS-v3: a repeated parent signal must force-clean detached children before the grace timer. + beginForceCleanup(); + return; + } + terminationRequested = true; + state = 'TERMINATION_REQUESTED'; + terminate(signal); + scheduleForceTermination(); + }; + const timeoutTimer = setTimeout(() => requestTermination('SIGTERM', 'timeout'), timeoutMs); + const signalHandlers = new Map(); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + const handler = () => { + requestTermination(signal, 'interrupt'); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + const complete = (status, signal, error = null) => { + if (settled) return; + settled = true; + state = 'SETTLED'; + clearTimeout(timeoutTimer); + if (forceTimer) { + clearTimeout(forceTimer); + forceTimer = undefined; + } + for (const [parentSignal, handler] of signalHandlers) { + process.removeListener(parentSignal, handler); + } + resolveResult({ + status: error ? null : status, + signal, + error, + timedOut, + interrupted, + command, + }); + }; + const finish = (status, signal, error = null) => { + if (settled) return; + if (terminationRequested) { + // QNBS-v3: leader close never proves descendants are gone; force cleanup remains authoritative. + pendingFinish ??= { status, signal, error }; + state = 'CLOSED'; + beginForceCleanup(); + return; + } + complete(status, signal, error); + }; + child.once('error', (error) => finish(null, null, error)); + child.once('close', (status, signal) => finish(status, signal)); + if (input !== undefined) { + child.stdin.once('error', (error) => { + if (!['EPIPE', 'ERR_STREAM_DESTROYED'].includes(error.code)) + requestTermination('SIGTERM', 'resource', error); + }); + child.stdin.end(input); + } }); - return result.error ? 1 : (result.status ?? 1); } -export function runLocalBinary(binary, args = []) { +export async function runNodeScriptDetailed(script, args = [], options = {}) { + const root = options.root ?? projectRoot; + return runBounded(process.execPath, [resolve(root, script), ...args], { ...options, cwd: root }); +} + +export async function runNodeScript(script, args = [], options = {}) { + const result = await runNodeScriptDetailed(script, args, options); + return result.error || result.timedOut || result.interrupted ? 1 : (result.status ?? 1); +} + +export async function runLocalBinaryDetailed(binary, args = [], options = {}) { + const root = options.root ?? projectRoot; const command = resolve( - projectRoot, + root, 'node_modules', '.bin', `${binary}${process.platform === 'win32' ? '.cmd' : ''}`, @@ -37,12 +199,19 @@ export function runLocalBinary(binary, args = []) { console.error( `[hook] Required local binary is missing: ${binary}. Run: node scripts/dependency-state.mjs reconcile`, ); - return 1; + return { + status: 1, + signal: null, + error: new Error(`Missing local binary: ${binary}`), + timedOut: false, + interrupted: false, + command, + }; } - const result = spawnSync(command, args, { - cwd: projectRoot, - shell: process.platform === 'win32', - stdio: 'inherit', - }); - return result.error ? 1 : (result.status ?? 1); + return runBounded(command, args, { ...options, cwd: root, shell: process.platform === 'win32' }); +} + +export async function runLocalBinary(binary, args = [], options = {}) { + const result = await runLocalBinaryDetailed(binary, args, options); + return result.error || result.timedOut || result.interrupted ? 1 : (result.status ?? 1); } diff --git a/scripts/workflow-policy-guards.d.mts b/scripts/workflow-policy-guards.d.mts new file mode 100644 index 000000000..cfd2cdb7f --- /dev/null +++ b/scripts/workflow-policy-guards.d.mts @@ -0,0 +1,12 @@ +export function containsSecretReference(value: unknown, contextKey?: string): boolean; +export function hasExecutableCloudTypecheckCommand(value: unknown): boolean; +export function extractActionReferences(source: string): string[]; +export function extractTopLevelJobName(line: string): string | null; +export function isSemanticallyUnconditionalIf(block: string): boolean; +export function isDeploymentTimeConditionalIf(block: string): boolean; +export function hasAggregateResultAssertion( + block: string | string[], + dependency: string, + allowsSkipped: boolean, +): boolean; +export function isReleasePublishingCommand(line: string): boolean; diff --git a/scripts/workflow-policy-guards.mjs b/scripts/workflow-policy-guards.mjs new file mode 100644 index 000000000..9c6527a61 --- /dev/null +++ b/scripts/workflow-policy-guards.mjs @@ -0,0 +1,148 @@ +const releaseTargetPattern = + /\b(?:release|releases|artifact|artifacts|latest\.json|uploads\.github\.com|api\.github\.com)\b/i; + +export function containsSecretReference(value, contextKey = '') { + if (typeof value === 'string') { + return ( + (contextKey === 'secrets' && value.trim().toLowerCase() === 'inherit') || + /\bsecrets(?:\.|\[)/.test(value) + ); + } + if (Array.isArray(value)) return value.some(containsSecretReference); + if (value && typeof value === 'object') + return Object.entries(value).some(([key, entry]) => containsSecretReference(entry, key)); + return false; +} + +export function hasExecutableCloudTypecheckCommand(value) { + const sources = Array.isArray(value) ? value : [value]; + const commandPattern = + /^(?:(?:pnpm\s+exec|npx)\s+)?tsgo\s+--project\s+tsconfig\.tsgo\.json\s+--noEmit\s+--checkers\s+4(?:\s+#.*)?$/; + return sources.some( + (source) => + typeof source === 'string' && + source.split(/\r?\n/).some((line) => commandPattern.test(line.trim())), + ); +} + +// QNBS-v3: detect explicit and implicit GitHub release mutations before qualification can pass. +const mutatingReleaseCommandPattern = + /\b(?:gh\s+release\s+(?:new|create|upload|edit|delete)|gh\s+api\b(?=[^\n]*(?:(?:--method(?:=|\s+)|-X\s+)(?:POST|PUT|PATCH|DELETE)\b|(?:--raw-field|--field|-F|-f|--input)(?:=|\s+)))(?=[^\n]*(?:\breleases?\b|\bassets?\b))[^\n]*|(?:curl|wget)\b[^\n]*(?:--upload-file|-T\s|--data(?:-binary|-raw|-urlencode)?(?:=|\s+)|-d\s|--json(?:=|\s+)|--post-(?:data|file)(?:=|\s+)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b|-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b)[^\n]*(?:release|asset|uploads\.github\.com|api\.github\.com)|(?:cp|mv|install|scp|aws\s+s3\s+cp|az\s+storage\s+blob\s+upload)\b[^\n]*\b(?:release|releases|artifact|artifacts|latest\.json)\b)/i; + +function normalizeYamlFoldedRuns(source) { + const lines = source.split(/\r?\n/); + const normalized = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + normalized.push(line); + const foldedRun = line.match(/^(\s*)run:\s*>\s*[+-]?\s*$/); + if (!foldedRun) continue; + const baseIndent = foldedRun[1].length; + const body = []; + let cursor = index + 1; + while (cursor < lines.length) { + const candidate = lines[cursor]; + if (!candidate.trim()) { + body.push(''); + cursor += 1; + continue; + } + const candidateIndent = candidate.match(/^\s*/)[0].length; + if (candidateIndent <= baseIndent) break; + body.push(candidate.trim()); + cursor += 1; + } + if (body.length > 0) normalized.push(body.join(' ')); + index = cursor - 1; + } + return normalized.join('\n'); +} + +function normalizeShellContinuations(source) { + return normalizeYamlFoldedRuns(source).replace(/\\\r?\n[ \t]*/g, ' '); +} + +export function extractActionReferences(source) { + return [...source.matchAll(/\b["']?uses["']?\s*:\s*([^\s,}]+)/g)].map(([, rawReference]) => + rawReference.replace(/^(['"])(.*)\1$/, '$2'), + ); +} + +export function extractTopLevelJobName(line) { + const match = line.match(/^ {2}(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*$/); + return match?.[1] ?? match?.[2] ?? match?.[3] ?? null; +} + +export function isSemanticallyUnconditionalIf(block) { + const match = block.match(/^\s*if:\s*(.+)$/m); + const rawExpression = match?.[1] ?? (block.trim() ? block : null); + if (!rawExpression) return false; + const expression = rawExpression + .replace(/\s+#.*$/, '') + .trim() + .replace(/^\$\{\{\s*/, '') + .replace(/\s*\}\}$/, '') + .trim(); + return /^(?:always\(\)|true)(?:\s*&&\s*(?:always\(\)|true))*$/i.test(expression); +} + +export function isDeploymentTimeConditionalIf(block) { + const match = block.match(/^\s*if:\s*(.+)$/m); + const rawExpression = match?.[1] ?? (block.trim() ? block : null); + if (!rawExpression) return false; + const expression = rawExpression + .replace(/\s+#.*$/, '') + .trim() + .replace(/^\$\{\{\s*/, '') + .replace(/\s*\}\}$/, '') + .trim(); + return /\bgithub\.(?:ref|ref_name|event_name)\b|\bneeds\.ci-success\.result\b/.test(expression); +} + +export function hasAggregateResultAssertion(block, dependency, allowsSkipped) { + // QNBS-v3: require the result comparison to route failure into FAIL=1, not merely mention a token. + const source = Array.isArray(block) ? block.join('\n') : block; + const lines = source + .split('\n') + .map((line) => line.replace(/^\s*#.*$/, '').replace(/\s+#.*$/, '')); + const token = `needs.${dependency}.result`; + return lines.some((line, index) => { + if (!line.includes(token)) return false; + const context = lines.slice(index, index + 5).join('\n'); + if (!/FAIL\s*=\s*1/.test(context)) return false; + if (allowsSkipped) { + return ( + /!=\s*['"]success['"]/.test(line) && + /!=\s*['"]skipped['"]/.test(line) && + /&&/.test(line) && + !/\|\|/.test(line) && + /\bthen\b/.test(context) && + /^\s*(?:\{\s*)?FAIL\s*=\s*1\s*;?\s*(?:\}\s*)?(?:#.*)?$/m.test(context) + ); + } + return ( + /^\s*(?:\[\[?\s|test\s)/.test(line) && + /\s=\s*['"]success['"]/.test(line) && + /(?:^|\n).*\|\|\s*(?:\{\s*)?FAIL\s*=\s*1\s*(?:;?\s*\}\s*)?(?:#.*)?$/m.test(context) + ); + }); +} + +export function isReleasePublishingCommand(source) { + const normalizedSource = Array.isArray(source) ? source.join('\n') : source; + return normalizeShellContinuations(normalizedSource) + .split(/\r?\n/) + .some((line) => { + const uncommented = line + .replace(/^\s*#.*$/, '') + .replace(/\s+#.*$/, '') + .trim(); + if (!uncommented) return false; + if (mutatingReleaseCommandPattern.test(uncommented)) return true; + + // QNBS-v3: reject shell copies/moves only when their target is release state, not local temp setup. + return ( + /\b(?:cp|mv|install|scp)\b/i.test(uncommented) && releaseTargetPattern.test(uncommented) + ); + }); +} diff --git a/scripts/workflow-policy-parser.mjs b/scripts/workflow-policy-parser.mjs new file mode 100644 index 000000000..0a603b9b9 --- /dev/null +++ b/scripts/workflow-policy-parser.mjs @@ -0,0 +1,60 @@ +import { parseDocument } from 'yaml'; + +export class WorkflowParseError extends Error { + constructor(label, errors) { + super(`${label}: invalid YAML (${errors.map((error) => error.message).join('; ')})`); + this.name = 'WorkflowParseError'; + } +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function parseWorkflow(source, label) { + const document = parseDocument(source, { + prettyErrors: false, + schema: 'core', + uniqueKeys: true, + version: '1.2', + }); + if (document.errors.length > 0) throw new WorkflowParseError(label, document.errors); + const workflow = document.toJS({ mapAsMap: false }); + if (!isRecord(workflow)) + throw new WorkflowParseError(label, [{ message: 'root must be a mapping' }]); + return workflow; +} + +export function asRecord(value) { + return isRecord(value) ? value : {}; +} + +export function asStringList(value) { + if (typeof value === 'string') return [value]; + if (!Array.isArray(value)) return []; + return value.filter((entry) => typeof entry === 'string'); +} + +export function collectValuesByKey(value, key, values = []) { + if (Array.isArray(value)) { + for (const entry of value) collectValuesByKey(entry, key, values); + return values; + } + if (!isRecord(value)) return values; + for (const [entryKey, entryValue] of Object.entries(value)) { + if (entryKey === key) values.push(entryValue); + collectValuesByKey(entryValue, key, values); + } + return values; +} + +export function workflowJobs(workflow) { + const jobs = asRecord(workflow.jobs); + return Object.entries(jobs).filter(([, job]) => isRecord(job)); +} + +export function workflowSteps(workflow) { + return workflowJobs(workflow).flatMap(([, job]) => + Array.isArray(job.steps) ? job.steps.filter((step) => isRecord(step)) : [], + ); +} diff --git a/tests/unit/hooks/shared.test.ts b/tests/unit/hooks/shared.test.ts new file mode 100644 index 000000000..17da3c1f9 --- /dev/null +++ b/tests/unit/hooks/shared.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import process from 'node:process'; +import { describe, expect, it } from 'vitest'; +import { runBounded } from '../../../scripts/hooks/shared.mjs'; + +describe('bounded hook subprocesses', () => { + it('does not treat a clean timeout shutdown as a successful run', async () => { + const startedAt = performance.now(); + const result = await runBounded( + process.execPath, + ['-e', "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 10_000);"], + { timeoutMs: 100 }, + ); + + expect(result.timedOut).toBe(true); + expect(performance.now() - startedAt).toBeLessThan(900); + }); + + // QNBS-v3: keep nested admission checks in the parent's process group for outer cleanup. + it('supports foreground children for nested admission checks', async () => { + const result = await runBounded(process.execPath, ['-e', 'setInterval(() => {}, 10_000);'], { + timeoutMs: 100, + detached: false, + }); + + expect(result.timedOut).toBe(true); + expect(result.status === 0).toBe(false); + }); + + // QNBS-v3: prove repeated parent signals clean detached children without accepting cancellation as pass. + it('preserves parent cancellation and force-cleans after repeated signals', async () => { + const resultPromise = runBounded( + process.execPath, + ['-e', "process.on('SIGINT', () => {}); setInterval(() => {}, 10_000);"], + { timeoutMs: 5_000 }, + ); + const firstSignal = setTimeout(() => process.emit('SIGINT'), 50); + const repeatedSignal = setTimeout(() => process.emit('SIGINT'), 100); + + try { + const result = await resultPromise; + expect(result.interrupted).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.status === 0).toBe(false); + } finally { + clearTimeout(firstSignal); + clearTimeout(repeatedSignal); + } + }); +}); diff --git a/tests/unit/tooling/checkGitDiff.test.ts b/tests/unit/tooling/checkGitDiff.test.ts new file mode 100644 index 000000000..5961e7508 --- /dev/null +++ b/tests/unit/tooling/checkGitDiff.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { checkUntrackedFile } from '../../../scripts/check-git-diff.mjs'; + +const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url)); + +// QNBS-v3: keep untracked-file diff diagnostics aligned with Git's EOF whitespace semantics. +describe('untracked diff integrity', () => { + it.each([ + ['text with one terminating newline', 'content\n', false], + ['text with an extra blank line at EOF', 'content\n\n', true], + ['CRLF line ending matches git whitespace policy', 'content\r\nnext\r\n', true], + ['conflict marker is diagnosed', '<<<<<<< HEAD\nclean content\n=======\n', true], + ])('%s', (_label, content, shouldFail) => { + const directory = mkdtempSync(join(repositoryRoot, '.tmp-check-git-diff-')); + try { + const filePath = join(directory, 'sample.txt'); + writeFileSync(filePath, content); + const diagnostics = checkUntrackedFile(filePath); + expect(diagnostics.length > 0).toBe(shouldFail); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts new file mode 100644 index 000000000..2e94d2d44 --- /dev/null +++ b/tests/unit/tooling/ciPrepushClassifier.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-registry.mjs'; +import { + classifyChangedFiles, + classifyProcessResult, + classifySignatureResult, + isI18nPolicyFile, + isWorkflowPolicyFile, + requiresTypecheck, +} from '../../../scripts/ci-prepush-classifier.mjs'; + +// QNBS-v3: lock the conservative classifier and explicit non-pass result states against regression. +describe('change-aware local admission classification', () => { + it.each([ + [['docs/CI.md'], 'DOCS_ONLY', false], + [['.github/workflows/ci.yml'], 'WORKFLOW_ONLY', false], + [['docs/CI.md', '.github/workflows/ci.yml'], 'NON_CODE_ONLY', false], + [['scripts/ci-prepush-lowend.mjs'], 'TOOLING', false], + [['.gitleaks.toml'], 'TOOLING', false], + [['tests/unit/tooling/ciPrepushClassifier.test.ts'], 'TYPESCRIPT_APPLICATION', true], + [['tests/fixtures/project.txt'], 'TEST_ONLY', false], + [['components/Editor.tsx'], 'TYPESCRIPT_APPLICATION', true], + [['packages/desktop-contracts/src/index.ts'], 'DESKTOP_NATIVE_CONTRACT', true], + [['src-tauri/src/main.rs'], 'RUST_TAURI', false], + [['pnpm-lock.yaml'], 'DEPENDENCY_TOOLCHAIN', true], + [['vite.config.ts'], 'BUILD_CONFIGURATION', true], + [['components/Editor.tsx', 'src-tauri/src/main.rs'], 'MIXED', true], + [['unclassified.bin'], 'AMBIGUOUS', true], + ])('classifies %j as %s', (files, kind, typecheck) => { + const classification = classifyChangedFiles(files); + expect(classification.kind).toBe(kind); + expect(requiresTypecheck(classification)).toBe(typecheck); + }); + + it('forces full TypeScript validation for the explicit full tier', () => { + const classification = classifyChangedFiles(['docs/CI.md']); + expect(requiresTypecheck(classification, { full: true })).toBe(true); + }); + + it('keeps workflow-policy checker changes on the workflow guard path', () => { + const classification = classifyChangedFiles(['scripts/check-workflow-policy.mjs']); + expect(classification.kind).toBe('TOOLING'); + expect(classification.files).toContain('scripts/check-workflow-policy.mjs'); + expect(isWorkflowPolicyFile('scripts/check-workflow-policy.mjs')).toBe(true); + expect(isWorkflowPolicyFile('scripts/workflow-policy-guards.mjs')).toBe(true); + expect(isWorkflowPolicyFile('scripts/workflow-policy-guards.d.mts')).toBe(true); + }); + + it('keeps i18n checker implementations on the i18n guard path', () => { + expect(isI18nPolicyFile('scripts/check-i18n-keys.mjs')).toBe(true); + expect(isI18nPolicyFile('scripts/i18n-locales.mjs')).toBe(true); + }); + + // QNBS-v3: every parsed GitHub YAML input must activate the workflow policy guard. + it('routes non-workflow GitHub YAML through workflow policy admission', () => { + expect(shouldRunAdmissionCheck('workflowPolicy', ['.github/dependabot.yml'])).toBe(true); + expect(shouldRunAdmissionCheck('workflowPolicy', ['.github/ISSUE_TEMPLATE/bug.yml'])).toBe( + true, + ); + expect(shouldRunAdmissionCheck('workflowPolicy', ['README.md'])).toBe(false); + }); +}); + +describe('local admission result semantics', () => { + it('does not turn failures into passes', () => { + expect(classifyProcessResult({ status: 1, signal: null, timedOut: false })).toBe('FAIL'); + expect(classifyProcessResult({ status: null, signal: 'SIGTERM', timedOut: false })).toBe( + 'LOCAL_RESOURCE_FAILURE', + ); + expect(classifyProcessResult({ status: null, signal: null, timedOut: true })).toBe( + 'LOCAL_RESOURCE_FAILURE', + ); + expect(classifyProcessResult({ status: 0, signal: null, timedOut: true })).toBe( + 'LOCAL_RESOURCE_FAILURE', + ); + expect( + classifyProcessResult({ status: 0, signal: null, timedOut: false, interrupted: true }), + ).toBe('LOCAL_RESOURCE_FAILURE'); + expect(classifyProcessResult({ status: null, signal: null, error: { code: 'EAGAIN' } })).toBe( + 'LOCAL_RESOURCE_FAILURE', + ); + expect(classifyProcessResult({ status: null, signal: null, error: { code: 'ENOMEM' } })).toBe( + 'LOCAL_RESOURCE_FAILURE', + ); + expect(classifySignatureResult(false)).toBe('FAIL'); + expect(classifySignatureResult(true)).toBe('PASS'); + }); +}); diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index 05928d2cd..c01bdfaa2 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -2,6 +2,16 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { + containsSecretReference, + extractActionReferences, + extractTopLevelJobName, + hasAggregateResultAssertion, + hasExecutableCloudTypecheckCommand, + isDeploymentTimeConditionalIf, + isReleasePublishingCommand, + isSemanticallyUnconditionalIf, +} from '../../scripts/workflow-policy-guards.mjs'; import { extractJobBlock, extractJobNames, @@ -17,6 +27,9 @@ const workflowPath = fileURLToPath(new URL('../../.github/workflows/ci.yml', imp const tauriWorkflowPath = fileURLToPath( new URL('../../.github/workflows/tauri-build.yml', import.meta.url), ); +const intelWorkflowPath = fileURLToPath( + new URL('../../.github/workflows/tauri-intel-qualification.yml', import.meta.url), +); const setupActionPath = fileURLToPath( new URL('../../.github/actions/setup/action.yml', import.meta.url), ); @@ -29,6 +42,7 @@ const scheduledSecurityWorkflowPath = fileURLToPath( const tauriManifestPath = fileURLToPath(new URL('../../src-tauri/Cargo.toml', import.meta.url)); const workflowSource = readFileSync(workflowPath, 'utf8'); const tauriWorkflowSource = readFileSync(tauriWorkflowPath, 'utf8'); +const intelWorkflowSource = readFileSync(intelWorkflowPath, 'utf8'); const setupActionSource = readFileSync(setupActionPath, 'utf8'); const cloudflareWorkflowSource = readFileSync(cloudflareWorkflowPath, 'utf8'); const scheduledSecurityWorkflowSource = readFileSync(scheduledSecurityWorkflowPath, 'utf8'); @@ -127,6 +141,12 @@ describe('CI workflow policy', () => { expect(visited).toContain('ci-success'); }); + it('gives deployment-time conditionals an explicit aggregate disposition', () => { + const deployBlock = extractJobBlock(workflowSource, 'deploy'); + expect(isDeploymentTimeConditionalIf(deployBlock)).toBe(true); + expect(deployBlock).toContain('deployment is explicitly outside the aggregate'); + }); + // QNBS-v3: Keep every unconditional CI job explicitly required or advisory so deploy cannot false-green. it('keeps required and advisory job authority explicit', () => { const ciSuccessBlock = extractJobBlock(workflowSource, 'ci-success'); @@ -147,6 +167,30 @@ describe('CI workflow policy', () => { ]); expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.signatures\.result\s*\}\}/); expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.lighthouse\.result\s*\}\}/); + // QNBS-v3: cover exact required and success-or-skipped aggregate semantics. + for (const jobName of [ + 'security', + 'signatures', + 'quality', + 'changes', + 'rust-tauri', + 'core-rust', + 'build', + 'e2e', + 'lighthouse', + 'vrt', + ]) { + const resultToken = `needs.${jobName}.result`; + expect(ciSuccessBlock, `${jobName} result assertion`).toContain(resultToken); + const assertionStart = ciSuccessBlock.indexOf(resultToken); + expect(ciSuccessBlock.slice(assertionStart, assertionStart + 180)).toContain('FAIL=1'); + if (['rust-tauri', 'core-rust'].includes(jobName)) { + expect(ciSuccessBlock).toMatch(new RegExp(`${resultToken}[^\\n]*!=\\s*["']success["']`)); + expect(ciSuccessBlock).toMatch(new RegExp(`${resultToken}[^\\n]*!=\\s*["']skipped["']`)); + } else { + expect(ciSuccessBlock).toMatch(new RegExp(`${resultToken}[^\\n]*\\s=\\s*["']success["']`)); + } + } for (const jobName of ['e2e-deep', 'storybook']) { const jobBlock = extractJobBlock(workflowSource, jobName); @@ -194,6 +238,191 @@ describe('CI workflow policy', () => { // QNBS-v3: keep desktop publication causally downstream of independently verified annotated tags. describe('Tauri release workflow policy', () => { + it('normalizes quoted action references and whitespace before mapping colons', () => { + const pinned = `actions/checkout@${'a'.repeat(40)}`; + expect(extractActionReferences(`uses : "${pinned}"`)).toEqual([pinned]); + expect(extractActionReferences(`- { uses : ${pinned} }`)).toEqual([pinned]); + expect(extractActionReferences(`- "uses": ${pinned}`)).toEqual([pinned]); + }); + + it('normalizes quoted top-level job identifiers', () => { + expect(extractTopLevelJobName(' "quoted-gate":')).toBe('quoted-gate'); + expect(extractTopLevelJobName(" 'quoted-gate':")).toBe('quoted-gate'); + expect(extractTopLevelJobName(' ordinary-gate:')).toBe('ordinary-gate'); + expect(extractTopLevelJobName(' nested:')).toBe(null); + }); + + // QNBS-v3: accept compound conditions only when their always-true terms remain conjunctive. + it('distinguishes semantically unconditional job conditions', () => { + expect(isSemanticallyUnconditionalIf(' if: true')).toBe(true); + expect(isSemanticallyUnconditionalIf(' if: $' + '{{ always() }}')).toBe(true); + expect(isSemanticallyUnconditionalIf(' if: true # always run')).toBe(true); + expect(isSemanticallyUnconditionalIf(' if: $' + '{{ always() }} # aggregate')).toBe(true); + expect(isSemanticallyUnconditionalIf(' if: $' + '{{ always() && true }}')).toBe(true); + expect(isSemanticallyUnconditionalIf(' if: $' + '{{ true && always() }}')).toBe(true); + expect(isSemanticallyUnconditionalIf(" if: needs.changes.outputs.tauri == 'true'")).toBe( + false, + ); + }); + + // QNBS-v3: ensure aggregate dependencies fail closed on missing or malformed result checks. + it('requires aggregate success checks to route failures through FAIL=1', () => { + const needsBuild = '$' + '{{ needs.build.result }}'; + expect( + hasAggregateResultAssertion(`[ "${needsBuild}" = "success" ] || FAIL=1`, 'build', false), + ).toBe(true); + expect( + hasAggregateResultAssertion(`[ "${needsBuild}" = "success" ] && FAIL=1`, 'build', false), + ).toBe(false); + expect( + hasAggregateResultAssertion(`# [ "${needsBuild}" = "success" ] || FAIL=1`, 'build', false), + ).toBe(false); + expect( + hasAggregateResultAssertion( + `[ "${needsBuild}" = "success" ] || FAIL=1 # enforce aggregate failure`, + 'build', + false, + ), + ).toBe(true); + expect( + hasAggregateResultAssertion(`echo [ "${needsBuild}" = "success" ] || FAIL=1`, 'build', false), + ).toBe(false); + expect( + hasAggregateResultAssertion(`[ "${needsBuild}" = "success" ] || echo FAIL=1`, 'build', false), + ).toBe(false); + expect( + hasAggregateResultAssertion( + `if [ "${needsBuild}" != "success" ] || [ "${needsBuild}" != "skipped" ]; then\n FAIL=1\nfi`, + 'build', + true, + ), + ).toBe(false); + expect( + hasAggregateResultAssertion( + `if [ "${needsBuild}" != "success" ] && [ "${needsBuild}" != "skipped" ]; then\n FAIL=1\nfi`, + 'build', + true, + ), + ).toBe(true); + }); + + // QNBS-v3: cover dot, bracket, and inherited secret references across parsed YAML shapes. + it('rejects dot- and bracket-indexed secret references', () => { + const dotReference = '$' + '{{ secrets.QUALIFICATION_TOKEN }}'; + const bracketReference = '$' + "{{ secrets['QUALIFICATION_TOKEN'] }}"; + const envReference = '$' + '{{ env.QUALIFICATION_TOKEN }}'; + expect(containsSecretReference(dotReference)).toBe(true); + expect(containsSecretReference(bracketReference)).toBe(true); + expect(containsSecretReference(envReference)).toBe(false); + expect(containsSecretReference({ secrets: 'inherit' })).toBe(true); + expect(containsSecretReference({ env: { QUALIFICATION_TOKEN: 'inherit' } })).toBe(false); + }); + + // QNBS-v3: require the executable cloud typecheck rather than a quoted or echoed token. + it('recognizes only the real four-checker cloud TypeScript command', () => { + expect( + hasExecutableCloudTypecheckCommand( + 'npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4', + ), + ).toBe(true); + expect( + hasExecutableCloudTypecheckCommand( + "echo 'npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4'", + ), + ).toBe(false); + expect( + hasExecutableCloudTypecheckCommand( + 'npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4 || true', + ), + ).toBe(false); + expect( + hasExecutableCloudTypecheckCommand( + 'npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4 # authoritative', + ), + ).toBe(true); + }); + + // QNBS-v3: cover multiline and option-form release mutation detection. + it('rejects mutating release commands in the non-publishing Intel workflow', () => { + expect(isReleasePublishingCommand(' gh release create "$TAG"')).toBe(true); + expect(isReleasePublishingCommand('gh release new v9')).toBe(true); + expect(isReleasePublishingCommand(' gh release upload "$TAG" artifact.dmg')).toBe(true); + expect( + isReleasePublishingCommand( + ' curl --upload-file artifact.dmg https://uploads.github.com/repos/org/repo/releases/assets', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + ' curl --upload-file artifact.dmg \\\n https://uploads.github.com/repos/org/repo/releases/assets', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'curl --request DELETE https://api.github.com/repos/org/repo/releases/42', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'curl --request=PATCH https://api.github.com/repos/org/repo/releases/42', + ), + ).toBe(true); + expect(isReleasePublishingCommand('gh api --method DELETE /repos/org/repo/releases/42')).toBe( + true, + ); + expect(isReleasePublishingCommand('gh api --method=DELETE /repos/org/repo/releases/42')).toBe( + true, + ); + expect(isReleasePublishingCommand('gh api -X DELETE /repos/org/repo/assets/42')).toBe(true); + expect(isReleasePublishingCommand('gh api /repos/org/repo/releases -f tag_name=v9')).toBe(true); + expect(isReleasePublishingCommand('gh api /repos/org/repo/releases --field tag_name=v9')).toBe( + true, + ); + expect(isReleasePublishingCommand('gh api /repos/org/repo/releases --input release.json')).toBe( + true, + ); + expect( + isReleasePublishingCommand( + 'curl --json @release.json https://api.github.com/repos/org/repo/releases', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'curl --data-raw \'{"tag_name":"v9"}\' https://api.github.com/repos/org/repo/releases', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'curl --data-urlencode tag_name=v9 https://api.github.com/repos/org/repo/releases', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'wget --post-data=tag_name=v9 https://api.github.com/repos/org/repo/releases', + ), + ).toBe(true); + expect( + isReleasePublishingCommand( + 'wget --post-file release.json --method POST https://api.github.com/repos/org/repo/releases', + ), + ).toBe(true); + expect( + isReleasePublishingCommand(`run: >- + gh release + create "$TAG"`), + ).toBe(true); + expect( + isReleasePublishingCommand( + ' mv src-tauri/tauri.conf.json.tmp src-tauri/tauri.conf.json', + ), + ).toBe(false); + }); + + it('requires exact Intel architecture evidence for qualification', () => { + expect(intelWorkflowSource).toContain('lipo_archs="$(lipo -archs "$executable")"'); + expect(intelWorkflowSource).toContain('test "$lipo_archs" = "x86_64"'); + }); + it('runs the signature verifier only for real version-tag pushes with read-only access', () => { const verifier = extractJobBlock(tauriWorkflowSource, 'verify-release-tag'); expect(verifier).toMatch( @@ -237,4 +466,11 @@ describe('Tauri release workflow policy', () => { expect(ciSuccess).toContain('✅ CI Success'); expect(workflowSource).toContain('name: ✅ CI Success'); }); + + it('does not continue qualification evidence uploads after cancellation', () => { + const uploadStart = intelWorkflowSource.indexOf('name: Upload qualification evidence'); + const uploadBlock = intelWorkflowSource.slice(uploadStart, uploadStart + 500); + expect(uploadBlock).toContain('if: $' + '{{ !cancelled() }}'); + expect(uploadBlock).not.toContain('if: always()'); + }); });