From f17ac0a2ac2e89532611af30ab9222192ab7efde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:38:16 +0200 Subject: [PATCH] ci: parallelize the quality gate into cached jobs Split the single serial macos-15 quality job into fully independent parallel jobs to cut wall-clock latency while keeping the same commands and evidence: - checks (ubuntu-latest): bun run check, the static and unit gate. - e2e (macos-15): Playwright against the production build, uploading only bounded failure evidence. - desktop (macos-15): pinned Rust toolchain with Swatinem/rust-cache, sidecar build, cargo fmt/test, and a release-mode Tauri app bundle whose stock beforeBuildCommand produces its own clean, flag-free production frontend. - changes: dorny/paths-filter classifies pull requests so documentation-only changes (Markdown, docs/, .agents/) skip the browser and desktop jobs; anything ambiguous counts as code. - gate: the required "Flect quality gate" summary job always reports, carries the workflow_dispatch failure probe, and fails unless every needed job succeeded or was intentionally skipped as docs-only. The three quality jobs start immediately in parallel, so the warm wall clock is the slowest single job instead of the serial sum. New actions are pinned by full commit: dorny/paths-filter v4.0.3 and Swatinem/rust-cache v2.9.2. Bun dependency caching is enabled by dropping setup-bun's no-cache flag. Local check:all behavior is unchanged. The workflow contract test now asserts the parallel shape, and CONTRIBUTING/README describe it. Claude-Session: https://claude.ai/code/session_01VNmFXmRMNV98Hyd3xogUuK --- .github/workflows/quality.yml | 166 ++++++++++++-- CONTRIBUTING.md | 15 +- README.md | 9 +- scripts/github-quality-workflow.test.ts | 291 +++++++++++++++++++----- 4 files changed, 404 insertions(+), 77 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 35df499..8d14ded 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,10 +23,36 @@ concurrency: cancel-in-progress: true jobs: - quality: - name: Flect quality gate - runs-on: macos-15 - timeout-minutes: 45 + changes: + name: Classify changed paths + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + # Pull requests are classified through the GitHub API, so no checkout is + # needed. Non-pull-request events skip the filter and always run the full + # gate. A changed file counts as code unless it matches every + # documentation pattern below; when in doubt a path counts as code. + - name: Detect non-documentation changes + id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d + with: + predicate-quantifier: every + filters: | + code: + - "!**/*.md" + - "!docs/**" + - "!.agents/**" + + checks: + name: Static checks and unit tests + runs-on: ubuntu-latest + timeout-minutes: 30 env: CI: true steps: @@ -39,13 +65,31 @@ jobs: uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 with: bun-version: "1.3.14" - no-cache: true - - name: Install the pinned Rust toolchain - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c + - name: Install locked dependencies + run: bun install --frozen-lockfile + + - name: Run the static and unit quality gate + run: bun run check + + e2e: + name: Browser end-to-end tests + runs-on: macos-15 + timeout-minutes: 45 + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' + env: + CI: true + steps: + - name: Check out the exact revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: - toolchain: "1.93.0" - components: rustfmt + persist-credentials: false + + - name: Install the pinned Bun runtime + uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 + with: + bun-version: "1.3.14" - name: Install locked dependencies run: bun install --frozen-lockfile @@ -53,12 +97,8 @@ jobs: - name: Install the pinned Chromium runtime run: bunx playwright install chromium - - name: Run the canonical quality gate - run: bun run check:all - - - name: Deliberate failure probe - if: github.event_name == 'workflow_dispatch' && inputs.failure_probe - run: bun -e 'throw new Error("Deliberate Flect CI failure probe.")' + - name: Run production Chromium workflows + run: bun run test:e2e - name: Upload bounded browser failure evidence if: failure() && !cancelled() @@ -72,3 +112,99 @@ jobs: retention-days: 7 compression-level: 9 include-hidden-files: false + + desktop: + name: Rust checks and desktop bundle + runs-on: macos-15 + timeout-minutes: 45 + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' + env: + CI: true + steps: + - name: Check out the exact revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + + - name: Install the pinned Bun runtime + uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 + with: + bun-version: "1.3.14" + + - name: Install the pinned Rust toolchain + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c + with: + toolchain: "1.93.0" + components: rustfmt + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + with: + workspaces: src-tauri + + - name: Install locked dependencies + run: bun install --frozen-lockfile + + - name: Build the Rust sidecar + run: bun run build:sidecar + + - name: Check Rust formatting + run: cargo fmt --manifest-path src-tauri/Cargo.toml --check + + - name: Run Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml + + # The stock beforeBuildCommand builds the flag-free production + # frontend, so the bundle proves a clean web build end to end. + - name: Build the release-mode application bundle + run: >- + bunx tauri build --bundles app + --config '{"bundle":{"macOS":{"signingIdentity":"-"}}}' + + gate: + name: Flect quality gate + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: + - changes + - checks + - e2e + - desktop + if: always() + steps: + - name: Deliberate failure probe + if: github.event_name == 'workflow_dispatch' && inputs.failure_probe + run: | + echo "Deliberate Flect CI failure probe." + exit 1 + + - name: Require every quality job to succeed + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CHECKS_RESULT: ${{ needs.checks.result }} + E2E_RESULT: ${{ needs.e2e.result }} + DESKTOP_RESULT: ${{ needs.desktop.result }} + DOCS_ONLY: ${{ github.event_name == 'pull_request' && needs.changes.outputs.code != 'true' }} + run: | + status=0 + require() { + job="$1" + result="$2" + docs_skippable="$3" + if [ "$result" = "success" ]; then + echo "$job succeeded." + return + fi + if [ "$docs_skippable" = "true" ] && [ "$result" = "skipped" ] && [ "$DOCS_ONLY" = "true" ]; then + echo "$job was intentionally skipped for a documentation-only change." + return + fi + echo "$job did not succeed: $result" + status=1 + } + require changes "$CHANGES_RESULT" false + require checks "$CHECKS_RESULT" false + require e2e "$E2E_RESULT" true + require desktop "$DESKTOP_RESULT" true + exit "$status" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8cddb3..73bd997 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,10 +69,17 @@ This runs: 5. Rust formatting and host tests; and 6. a release-mode macOS application bundle build. -The pinned, least-privilege GitHub workflow runs this canonical command for -every pull request and every change to `main`. Live Pi, Apple signing, -notarization, and other credentialed release proof remain separate authorized -gates; the public workflow must never silently represent them as completed. +The pinned, least-privilege GitHub workflow runs these same commands for +every pull request and every change to `main`, split into independent +parallel jobs for faster feedback: `bun run check` on Linux, the Playwright +suite on macOS, and the Rust checks plus the release-mode bundle build with +its own clean production web build on macOS. A required `Flect quality gate` +summary check always reports and fails unless every job succeeded; +documentation-only pull requests (Markdown, `docs/`, `.agents/`) may skip the +browser and desktop jobs while `bun run check` still runs. Live Pi, Apple +signing, notarization, and other credentialed release proof remain separate +authorized gates; the public workflow must never silently represent them as +completed. Playwright uses `FLECT_TEST_MODE=1`, a deterministic in-memory runtime, and no provider credentials. It covers streamed turns, schema-driven Shaper tool diff --git a/README.md b/README.md index 684cb3d..a9897d2 100644 --- a/README.md +++ b/README.md @@ -315,10 +315,11 @@ Rust formatting and tests, and the native application build. The architecture gate rejects native promise fan-out everywhere and ad hoc Promise constructors or native Promise serialization tails outside tests. Concurrency, callback lifetimes, cancellation, and failure composition therefore stay explicit in -Effect. The credential-free GitHub quality workflow runs this same command on -every pull request and every change to `main`; branch protection requires its -exact `Flect quality gate` context on a current revision, including for -administrators. The workflow uploads only bounded Playwright failure evidence. +Effect. The credential-free GitHub quality workflow runs these same checks in +parallel jobs on every pull request and every change to `main`; branch +protection requires its exact `Flect quality gate` summary context on a +current revision, including for administrators. The workflow uploads only +bounded Playwright failure evidence. `bun run test:pi-smoke` is separate because it makes one real private turn with the developer's existing Pi diff --git a/scripts/github-quality-workflow.test.ts b/scripts/github-quality-workflow.test.ts index ef54e6f..d98b3bf 100644 --- a/scripts/github-quality-workflow.test.ts +++ b/scripts/github-quality-workflow.test.ts @@ -9,16 +9,51 @@ const record = (value: unknown, label: string): JsonRecord => { return value as JsonRecord; }; +const loadWorkflow = async () => { + const source = await readFile(".github/workflows/quality.yml", "utf8"); + const workflow = record( + parse(source, { uniqueKeys: true }), + "quality workflow", + ); + return { source, workflow }; +}; + +const jobRecord = (workflow: JsonRecord, id: string): JsonRecord => + record(record(workflow.jobs, "workflow jobs")[id], `${id} job`); + +const jobSteps = (job: JsonRecord, label: string) => { + const steps = job.steps as ReadonlyArray; + assert.isArray(steps, `${label} steps must be a list`); + return steps; +}; + +const actionSteps = (steps: ReadonlyArray) => + steps.filter( + (step): step is JsonRecord & { readonly uses: string } => + typeof step.uses === "string", + ); + +const findAction = ( + steps: ReadonlyArray, + prefix: string, + label: string, +) => { + const step = actionSteps(steps).find((candidate) => + candidate.uses.startsWith(prefix), + ); + assert.isDefined(step, `${label} requires a ${prefix} step`); + return step as JsonRecord & { readonly uses: string }; +}; + +const runCommands = (steps: ReadonlyArray) => + steps.flatMap((step) => (typeof step.run === "string" ? [step.run] : [])); + describe("GitHub quality workflow", () => { - it("runs the canonical credential-free gate with immutable dependencies", async () => { - const source = await readFile(".github/workflows/quality.yml", "utf8"); + it("keeps the credential-free triggers, permissions, and concurrency", async () => { + const { source, workflow } = await loadWorkflow(); assert.notInclude(source, "secrets."); assert.notInclude(source, "pull_request_target"); - const workflow = record( - parse(source, { uniqueKeys: true }), - "quality workflow", - ); assert.strictEqual(workflow.name, "Flect quality"); assert.deepStrictEqual(workflow.permissions, { contents: "read" }); @@ -52,79 +87,227 @@ describe("GitHub quality workflow", () => { assert.strictEqual(concurrency["cancel-in-progress"], true); assert.match(String(concurrency.group), /github\.workflow/); assert.match(String(concurrency.group), /github\.ref/); + }); - const quality = record( - record(workflow.jobs, "workflow jobs").quality, - "quality job", - ); - assert.strictEqual(quality.name, "Flect quality gate"); - assert.strictEqual(quality["runs-on"], "macos-15"); - assert.strictEqual(quality["timeout-minutes"], 45); + it("pins every action by commit and never persists credentials", async () => { + const { workflow } = await loadWorkflow(); + const jobs = record(workflow.jobs, "workflow jobs"); + assert.deepStrictEqual(Object.keys(jobs).sort(), [ + "changes", + "checks", + "desktop", + "e2e", + "gate", + ]); - const steps = quality.steps as ReadonlyArray; - assert.isArray(steps); - const actionSteps = steps.filter( - (step): step is JsonRecord & { readonly uses: string } => - typeof step.uses === "string", - ); - assert.isTrue(actionSteps.length > 0); - for (const step of actionSteps) { - assert.match(step.uses, /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/); + for (const [id, value] of Object.entries(jobs)) { + const job = record(value, `${id} job`); + const timeout = job["timeout-minutes"]; + assert.strictEqual( + typeof timeout, + "number", + `${id} must declare a timeout`, + ); + assert.isAtMost(Number(timeout), 45, `${id} timeout must stay bounded`); + + for (const step of actionSteps(jobSteps(job, id))) { + assert.match( + step.uses, + /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/, + `${id} must pin ${step.uses} to a full commit`, + ); + if (step.uses.startsWith("actions/checkout@")) { + assert.deepStrictEqual(step.with, { "persist-credentials": false }); + } + if (step.uses.startsWith("oven-sh/setup-bun@")) { + assert.deepStrictEqual(step.with, { "bun-version": "1.3.14" }); + } + } } + }); - const action = (prefix: string) => { - const step = actionSteps.find((candidate) => - candidate.uses.startsWith(prefix), - ); - assert.isDefined(step, `${prefix} step is required`); - return step; - }; + it("classifies documentation-only pull requests conservatively", async () => { + const { workflow } = await loadWorkflow(); + const changes = jobRecord(workflow, "changes"); + assert.strictEqual(changes["runs-on"], "ubuntu-latest"); + assert.deepStrictEqual(changes.permissions, { + contents: "read", + "pull-requests": "read", + }); + assert.deepStrictEqual(changes.outputs, { + code: "${{ steps.filter.outputs.code }}", + }); + + const filter = findAction( + jobSteps(changes, "changes"), + "dorny/paths-filter@", + "changes", + ); assert.strictEqual( - action("actions/checkout@").uses, - "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + filter.uses, + "dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d", ); - assert.deepStrictEqual(action("actions/checkout@").with, { - "persist-credentials": false, - }); assert.strictEqual( - action("oven-sh/setup-bun@").uses, - "oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979", + filter.if, + "github.event_name == 'pull_request'", + "only pull requests may take the documentation fast path", + ); + const inputs = record(filter.with, "filter inputs"); + assert.strictEqual(inputs["predicate-quantifier"], "every"); + const filters = record( + parse(String(inputs.filters)), + "path filter definition", + ); + assert.deepStrictEqual(filters.code, [ + "!**/*.md", + "!docs/**", + "!.agents/**", + ]); + }); + + it("runs the full canonical gate commands across the parallel jobs", async () => { + const { workflow } = await loadWorkflow(); + + const checks = jobRecord(workflow, "checks"); + assert.strictEqual(checks["runs-on"], "ubuntu-latest"); + const checksCommands = runCommands(jobSteps(checks, "checks")); + assert.include(checksCommands, "bun install --frozen-lockfile"); + assert.include(checksCommands, "bun run check"); + + const codeChangedCondition = + "github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'"; + + const e2e = jobRecord(workflow, "e2e"); + assert.strictEqual(e2e["runs-on"], "macos-15"); + assert.strictEqual(e2e.needs, "changes"); + assert.strictEqual(e2e.if, codeChangedCondition); + const e2eCommands = runCommands(jobSteps(e2e, "e2e")); + assert.include(e2eCommands, "bun install --frozen-lockfile"); + assert.include(e2eCommands, "bunx playwright install chromium"); + assert.include(e2eCommands, "bun run test:e2e"); + + const desktop = jobRecord(workflow, "desktop"); + assert.strictEqual(desktop["runs-on"], "macos-15"); + assert.strictEqual( + desktop.needs, + "changes", + "desktop must not wait on e2e; the macOS jobs run in parallel", + ); + assert.strictEqual(desktop.if, codeChangedCondition); + const desktopSteps = jobSteps(desktop, "desktop"); + + const toolchain = findAction( + desktopSteps, + "dtolnay/rust-toolchain@", + "desktop", ); - assert.deepStrictEqual(action("oven-sh/setup-bun@").with, { - "bun-version": "1.3.14", - "no-cache": true, - }); assert.strictEqual( - action("dtolnay/rust-toolchain@").uses, + toolchain.uses, "dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c", ); - assert.deepStrictEqual(action("dtolnay/rust-toolchain@").with, { + assert.deepStrictEqual(toolchain.with, { toolchain: "1.93.0", components: "rustfmt", }); - const commands = steps.flatMap((step) => - typeof step.run === "string" ? [step.run] : [], + const rustCache = findAction( + desktopSteps, + "Swatinem/rust-cache@", + "desktop", + ); + assert.strictEqual( + rustCache.uses, + "Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6", + ); + assert.deepStrictEqual(rustCache.with, { workspaces: "src-tauri" }); + + const desktopCommands = runCommands(desktopSteps); + assert.include(desktopCommands, "bun install --frozen-lockfile"); + assert.include(desktopCommands, "bun run build:sidecar"); + assert.include( + desktopCommands, + "cargo fmt --manifest-path src-tauri/Cargo.toml --check", + ); + assert.include( + desktopCommands, + "cargo test --manifest-path src-tauri/Cargo.toml", + ); + const bundleCommand = desktopCommands.find((command) => + command.includes("tauri build"), ); - assert.include(commands, "bun install --frozen-lockfile"); - assert.include(commands, "bunx playwright install chromium"); - assert.include(commands, "bun run check:all"); + assert.isDefined(bundleCommand, "desktop must build the bundle"); + assert.include(String(bundleCommand), "--bundles app"); + assert.notInclude( + String(bundleCommand), + "beforeBuildCommand", + "the stock beforeBuildCommand must build the clean production frontend", + ); + assert.include( + String(bundleCommand), + '"signingIdentity":"-"', + "the bundle build must request an explicit ad-hoc signature", + ); + }); + + it("uploads only bounded browser failure evidence", async () => { + const { workflow } = await loadWorkflow(); - const artifact = action("actions/upload-artifact@"); + const e2eSteps = jobSteps(jobRecord(workflow, "e2e"), "e2e"); + const uploads = actionSteps(e2eSteps).filter((step) => + step.uses.startsWith("actions/upload-artifact@"), + ); + assert.lengthOf(uploads, 1, "e2e uploads only failure evidence"); + const evidence = uploads[0]; assert.strictEqual( - artifact.uses, + evidence.uses, "actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4", ); - assert.strictEqual(artifact.if, "failure() && !cancelled()"); - const artifactInputs = record(artifact.with, "artifact inputs"); - assert.strictEqual(artifactInputs["retention-days"], 7); - assert.strictEqual(artifactInputs["include-hidden-files"], false); + assert.strictEqual(evidence.if, "failure() && !cancelled()"); + const evidenceInputs = record(evidence.with, "evidence inputs"); + assert.strictEqual(evidenceInputs["retention-days"], 7); + assert.strictEqual(evidenceInputs["include-hidden-files"], false); assert.strictEqual( - artifactInputs.path, + evidenceInputs.path, "test-results/**\n!test-results/control-state/**\n", ); }); + it("keeps the required summary check always reporting and failing closed", async () => { + const { workflow } = await loadWorkflow(); + const gate = jobRecord(workflow, "gate"); + assert.strictEqual(gate.name, "Flect quality gate"); + assert.strictEqual(gate["runs-on"], "ubuntu-latest"); + assert.strictEqual(gate.if, "always()"); + assert.deepStrictEqual(gate.needs, ["changes", "checks", "e2e", "desktop"]); + + const steps = jobSteps(gate, "gate"); + const probe = steps.find((step) => String(step.run).includes("exit 1")); + assert.isDefined(probe, "the deliberate failure probe must remain"); + assert.strictEqual( + probe?.if, + "github.event_name == 'workflow_dispatch' && inputs.failure_probe", + ); + + const aggregate = steps.find((step) => + String(step.run).includes("did not succeed"), + ); + assert.isDefined(aggregate, "the gate must aggregate every needed job"); + const env = record(aggregate?.env, "gate aggregation env"); + assert.strictEqual(env.CHANGES_RESULT, "${{ needs.changes.result }}"); + assert.strictEqual(env.CHECKS_RESULT, "${{ needs.checks.result }}"); + assert.strictEqual(env.E2E_RESULT, "${{ needs.e2e.result }}"); + assert.strictEqual(env.DESKTOP_RESULT, "${{ needs.desktop.result }}"); + assert.strictEqual( + env.DOCS_ONLY, + "${{ github.event_name == 'pull_request' && needs.changes.outputs.code != 'true' }}", + ); + const script = String(aggregate?.run); + assert.include(script, 'require changes "$CHANGES_RESULT" false'); + assert.include(script, 'require checks "$CHECKS_RESULT" false'); + assert.include(script, 'require e2e "$E2E_RESULT" true'); + assert.include(script, 'require desktop "$DESKTOP_RESULT" true'); + }); + it("pins the local toolchains and builds the Rust sidecar in check:all", async () => { const packageJson = record( JSON.parse(await readFile("package.json", "utf8")),