build(deps): bump @ai-sdk/openai from 4.0.20 to 4.0.53 (#665) #2881
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================ | |
| # WorldScript Studio β CI/CD Pipeline | |
| # security β quality (lint, i18n, tsgo, vitest+coverage) β build (+chunk budget, rollup analyze) | |
| # ββ e2e | storybook (parallel) ; lighthouse after build | |
| # deploy (main): needs ci-success β GitHub Pages | |
| # ============================================================ | |
| name: CI / CD | |
| on: | |
| push: | |
| branches: [main] | |
| tags: ['*'] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # QNBS-v3: cancel-in-progress only on PRs β cancelling a main-branch deploy mid-flight causes rollback confusion | |
| concurrency: | |
| group: >- | |
| ci-${{ github.workflow }}-${{ | |
| github.event_name == 'pull_request' && github.head_ref || github.ref_name | |
| }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # ---------------------------------------------------------- | |
| # -1. WORKFLOW POLICY GATE: runs before any job invokes the governed | |
| # ./.github/actions/setup composite (first used by "security" below), so a | |
| # PR that tampers with that composite β or any other workflow/action file β | |
| # is rejected before its own tampered code ever executes. Uses only | |
| # external, SHA-pinned actions directly, never the composite it gates. | |
| # ---------------------------------------------------------- | |
| workflow-policy: | |
| name: π Workflow Policy Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| # QNBS-v3: pnpm/setup instead of pnpm/action-setup, mirroring .github/actions/setup/action.yml (duplicated because this job predates the trust boundary it validates and can't use that composite). | |
| - uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 | |
| with: | |
| version: 11.22.0 | |
| install: false | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| # QNBS-v3: no lifecycle/.pnpmfile.cjs hooks β install must not run repo code before the gate. | |
| - name: Install dependencies (no lifecycle scripts) | |
| run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile | |
| # QNBS-v3: node directly β pnpm run's own pre-run check rejects an --ignore-pnpmfile install. | |
| - name: Workflow-policy structural gate (permissions, needs graph, action pins) | |
| run: node scripts/workflow-policy-check.mjs | |
| # ---------------------------------------------------------- | |
| # 0. SECURITY: pnpm audit + gitleaks + dependency review | |
| # ---------------------------------------------------------- | |
| security: | |
| name: π Security Audit | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [workflow-policy] | |
| # QNBS-v3: dependency-review-action needs readable PR metadata β otherwise the job fails unnecessarily on external PRs/scopes. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| # QNBS-v3: fetch-depth:0 gives gitleaks access to parent commits (sha^) needed for PR diff scans; shallow clone causes "ambiguous argument" errors. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| # QNBS-v3: pnpm's audit client cannot decode force-gzipped responses from some npm CDN edge | |
| # nodes (ERR_PNPM_AUDIT_BAD_RESPONSE, deterministic per edge β retries are useless). The | |
| # enforced vulnerability gate in this job is the OSV scan below (covers BOTH pnpm-lock.yaml | |
| # and Cargo.lock with a superset DB); this step is advisory-only until pnpm fixes decoding. | |
| - name: pnpm audit (high + critical, advisory) | |
| continue-on-error: true | |
| run: | | |
| if pnpm audit --audit-level=high; then | |
| echo "pnpm audit: clean" | |
| else | |
| echo "::warning::pnpm audit failed (registry gzip-decoding bug or findings) β OSV scan below is the enforced gate" | |
| exit 1 | |
| fi | |
| # QNBS-v3 (#60): vendored y-webrtc fork invariant guard (PBKDF2 600k, non-extractable keys, | |
| # DataChannel encryption, no dangling upstream dep) β fails the build on fork drift. | |
| - name: Vendor fork invariant guard | |
| run: pnpm run verify:vendor | |
| - name: OSV vulnerability scan | |
| uses: google/osv-scanner-action/osv-scanner-action@6e4298ebc4db23e847df9b2e2de2939d6f066c67 # v2.5.1 | |
| with: | |
| scan-args: |- | |
| --config=src-tauri/osv-scanner.toml | |
| --lockfile=pnpm-lock.yaml | |
| --lockfile=src-tauri/Cargo.lock | |
| --lockfile=crates/Cargo.lock | |
| # QNBS-v3: gitleaks scans git history for secrets; GITHUB_TOKEN is enough for PR annotations. | |
| - name: Scan for leaked secrets (gitleaks) | |
| uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 β Node 24 compatible | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Dependency review (PRs only) | |
| if: github.event_name == 'pull_request' | |
| uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 | |
| # ---------------------------------------------------------- | |
| # 0b. CHANGE DETECTION: path-scopes Rust gates so a docs/frontend-only PR | |
| # doesn't pay for a Rust toolchain + apt-get(libgtk/libwebkit) build it can't | |
| # affect. Fails OPEN (tauri=true) on any ambiguity β base SHA missing/unreachable | |
| # β so a detection error runs the real gate instead of silently skipping it. | |
| # ---------------------------------------------------------- | |
| changes: | |
| name: π Detect changed paths | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| tauri: ${{ steps.filter.outputs.tauri }} | |
| crates: ${{ steps.filter.outputs.crates }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Detect src-tauri / crates changes | |
| id: filter | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| else | |
| BASE="${{ github.event.before }}" | |
| fi | |
| if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE" 2>/dev/null; then | |
| echo "::notice::No usable base SHA to diff against β defaulting to tauri=true, crates=true (fail open)" | |
| echo "tauri=true" >> "$GITHUB_OUTPUT" | |
| echo "crates=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}") | |
| if grep -qE '^(src-tauri/|crates/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then | |
| echo "tauri=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "tauri=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| if grep -qE '^(crates/|tests/fixtures/project-golden-masters/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then | |
| echo "crates=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "crates=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ---------------------------------------------------------- | |
| # PR SIZE GOVERNANCE: advisory below the absolute ceiling, blocking above it. | |
| # QNBS-v3: runs the BASE ref's checker copy β a PR touching it can't raise its own limits. | |
| # ---------------------------------------------------------- | |
| pr-size: | |
| name: π PR Size Governance | |
| if: github.event_name == 'pull_request' | |
| needs: [workflow-policy] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Evaluate PR size (base-ref checker) | |
| id: size | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p /tmp/base-scripts | |
| # QNBS-v3: bootstrap fallback β the PR introducing this checker has no base-ref copy yet. | |
| if git show "$BASE_SHA:scripts/check-pr-size.mjs" > /tmp/base-scripts/check-pr-size.mjs 2>/dev/null \ | |
| && git show "$BASE_SHA:scripts/ci-prepush-classifier.mjs" > /tmp/base-scripts/ci-prepush-classifier.mjs 2>/dev/null; then | |
| CHECKER=/tmp/base-scripts/check-pr-size.mjs | |
| else | |
| echo "::notice::check-pr-size.mjs not found on base ref (bootstrap PR) β using this PR's own copy this one time." | |
| CHECKER=scripts/check-pr-size.mjs | |
| fi | |
| set +e | |
| node "$CHECKER" "$BASE_SHA" "$HEAD_SHA" | tee /tmp/pr-size-report.txt | |
| exit_code=${PIPESTATUS[0]} | |
| set -e | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| # QNBS-v3: fork PRs get a read-only token β a failed comment must never fail this advisory job. | |
| # QNBS-v3: edits by owned comment ID (not --edit-last) so an intervening bot comment can't be overwritten. | |
| - name: Post PR size comment (advisory or blocking tiers only) | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| PR="${{ github.event.pull_request.number }}" | |
| REPO="${{ github.repository }}" | |
| MARKER="<!-- pr-size-governance -->" | |
| # QNBS-v3: --paginate + --jq filters each page separately β pipe to external jq -s to combine first. | |
| OWNED_ID=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null \ | |
| | jq -s --arg marker "$MARKER" \ | |
| '[.[][] | select(.user.login == "github-actions[bot]" and (.body | contains($marker)))] | last | .id // empty' \ | |
| || echo "") | |
| if grep -qE 'within target' /tmp/pr-size-report.txt; then | |
| if [ -n "$OWNED_ID" ]; then | |
| printf '%s\nPR size is back within target β previous warning below is resolved.\n' "$MARKER" > /tmp/pr-size-resolved.txt | |
| gh api "repos/$REPO/issues/comments/$OWNED_ID" -X PATCH -F body=@/tmp/pr-size-resolved.txt || true | |
| fi | |
| exit 0 | |
| fi | |
| { printf '%s\n' "$MARKER"; cat /tmp/pr-size-report.txt; } > /tmp/pr-size-marked.txt | |
| if [ -n "$OWNED_ID" ]; then | |
| gh api "repos/$REPO/issues/comments/$OWNED_ID" -X PATCH -F body=@/tmp/pr-size-marked.txt || true | |
| else | |
| gh pr comment "$PR" --body-file /tmp/pr-size-marked.txt || true | |
| fi | |
| # QNBS-v3: any nonzero exit (crash, OOM-kill, etc.) must fail, not just the literal '1' ceiling code. | |
| - name: Fail on a checker crash or an absolute-ceiling violation | |
| if: steps.size.outputs.exit_code != '0' | |
| run: exit 1 | |
| signatures: | |
| name: π Verified Signatures | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [security] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Verify every introduced GitHub commit and release tag | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/signing/verify-github-signatures.mjs | |
| - name: Reject AI/session attribution in introduced commits | |
| run: | | |
| set -euo pipefail | |
| HEAD="${{ github.sha }}" | |
| # QNBS-v3: run the base-ref copy on PRs so this PR can't weaken the checker that grades it. | |
| CHECKER=scripts/check-commit-attribution.mjs | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| mkdir -p /tmp/base-scripts | |
| if git show "$BASE:scripts/check-commit-attribution.mjs" > /tmp/base-scripts/check-commit-attribution.mjs 2>/dev/null; then | |
| CHECKER=/tmp/base-scripts/check-commit-attribution.mjs | |
| else | |
| echo "::notice::check-commit-attribution.mjs not found on base ref (bootstrap PR) β using this PR's own copy this one time." | |
| fi | |
| node "$CHECKER" "$BASE" "$HEAD" | |
| elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| # QNBS-v3: github.sha is the dereferenced commit for a tag push; rev-parse the ref for the actual tag object so the annotation itself gets checked. | |
| TAG_SHA=$(git rev-parse "$GITHUB_REF") | |
| node "$CHECKER" --tag "$TAG_SHA" | |
| else | |
| BASE="${{ github.event.before }}" | |
| if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE" 2>/dev/null; then | |
| echo "::error::No usable base SHA to diff against β cannot verify introduced commits for AI/session attribution. This is expected only on the repository's first-ever commit; review manually before merging." | |
| exit 1 | |
| fi | |
| node "$CHECKER" "$BASE" "$HEAD" | |
| fi | |
| # QNBS-v3: PR title/body attribution is checked by the separate, cheap pr-text-attribution.yml (also runs on `edited`, which this workflow's trigger deliberately excludes). | |
| # ---------------------------------------------------------- | |
| # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) | |
| # ---------------------------------------------------------- | |
| quality: | |
| name: π Quality Gate (Node ${{ matrix.node-version }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 22 | |
| needs: [security] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # QNBS-v3: Explicit versions β `node` (= 'current') breaks CI without code changes when Node N+1 is released. | |
| node-version: ['22', '24'] | |
| steps: | |
| # QNBS-v3: fetch-tags β check-doc-metrics.mjs's stale-PLANNED-status check reads `git tag` | |
| # and silently no-ops without one; a default shallow checkout has no tags at all. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| fetch-tags: true | |
| - uses: ./.github/actions/setup | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| - name: Lint (Biome) | |
| run: pnpm run lint | |
| - name: Suppression-debt ratchet (biome-ignore count) | |
| run: node scripts/check-suppressions.mjs | |
| - name: Token-audit ratchet (raw-color/inline-svg count) | |
| run: pnpm run token:audit | |
| - name: i18n key parity (vs en) | |
| run: pnpm run i18n:check | |
| - name: i18n translation quality floor | |
| run: pnpm run i18n:quality | |
| - name: Doc metrics drift gate (locale/key counts, stale PLANNED status) | |
| run: pnpm run docs:check | |
| - name: CSP source synchronization and parity | |
| run: pnpm run csp:verify | |
| - name: Native-readiness roadmap policy gate | |
| run: pnpm run native-readiness:check | |
| - name: Feature parity audit | |
| run: pnpm run parity:check | |
| - name: DesktopPlatform import-boundary gate (Wave 1) | |
| run: pnpm run guardrail:desktop-imports | |
| - name: Typecheck (tsgo) | |
| run: npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4 | |
| # QNBS-v3: no retry β a first-attempt failure remains visible instead of being masked as a pass. | |
| # --no-experimental-webstorage disables Node 24's native Web Storage API for deterministic setup. | |
| # QNBS-v3: both outputFile paths explicit in this one invocation β never relying on vitest.config.ts's own reporter-tuple default, which could silently drift independently of this exact command. | |
| - name: Unit tests (Vitest, no retry) | |
| run: pnpm exec vitest run --coverage --reporter=json --reporter=junit --outputFile.json=test-results.json --outputFile.junit=reports/junit.xml | |
| env: | |
| NODE_OPTIONS: "--no-experimental-webstorage --max-old-space-size=4096" | |
| CI: "true" | |
| - name: Coverage ratchet check (informational, non-blocking) | |
| if: always() | |
| continue-on-error: true | |
| run: node scripts/check-coverage-ratchet.mjs | |
| - name: Upload Vitest JSON results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: vitest-results-node${{ matrix.node-version }} | |
| path: test-results.json | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Upload Vitest JUnit results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: vitest-junit-node${{ matrix.node-version }} | |
| path: reports/junit.xml | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Upload coverage report | |
| # QNBS-v3: matrix value stays literal 'lts/*' β one upload per workflow suffices (Codecov optional). | |
| if: always() && matrix.node-version == '22' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: coverage-report | |
| path: coverage/ | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| # QNBS-v3: fail_ci_if_error: false β forks without CODECOV_TOKEN; coverage remains in artifact regardless. | |
| - name: Publish coverage to Codecov | |
| if: always() && matrix.node-version == '22' | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| files: coverage/lcov.info | |
| flags: unit | |
| fail_ci_if_error: false | |
| # QNBS-v3: !cancelled() (not the default success()) β a real test failure must still upload results, only a cancelled run skips it. | |
| - name: Publish unit test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| report_type: test_results | |
| files: reports/junit.xml | |
| disable_search: true | |
| flags: unit-node${{ matrix.node-version }} | |
| name: unit-node${{ matrix.node-version }} | |
| fail_ci_if_error: false | |
| # ---------------------------------------------------------- | |
| # 1b. RUST-TAURI: Tauri Rust Gate (fmt/check/clippy/test), path-scoped via `changes` | |
| # ---------------------------------------------------------- | |
| rust-tauri: | |
| name: π¦ Tauri Rust Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [security, changes] | |
| # QNBS-v3: skips for PRs that don't touch src-tauri/** β ci-success treats 'skipped' as pass for this job only | |
| if: needs.changes.outputs.tauri == 'true' | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| components: rustfmt, clippy | |
| # QNBS-v3: no cargo caching existed before β fmt/check/clippy/test each rebuilt the full | |
| # dependency tree from scratch every run. Keyed on Cargo.lock + rustc version, so a toolchain | |
| # bump or lockfile change invalidates cleanly rather than reusing stale artifacts. | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: src-tauri -> target | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Tauri Linux build deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| # QNBS-v3: bump the -v suffix if the apt-get install package list below changes | |
| key: apt-tauri-linux-deps-v1 | |
| - name: Install Linux Tauri build dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf | |
| - name: Rust format check | |
| working-directory: src-tauri | |
| run: cargo fmt --check | |
| - name: Rust compile check | |
| working-directory: src-tauri | |
| run: cargo check --locked | |
| - name: Rust clippy | |
| working-directory: src-tauri | |
| run: cargo clippy --locked --all-targets -- -D warnings | |
| - name: Rust tests | |
| working-directory: src-tauri | |
| run: cargo test --locked | |
| # ---------------------------------------------------------- | |
| # 1c. CORE-RUST: worldscript-project crate Gate (fmt/check/clippy/test), path-scoped via `changes` | |
| # ---------------------------------------------------------- | |
| core-rust: | |
| name: π§© Core Rust Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [security, changes] | |
| if: needs.changes.outputs.crates == 'true' | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: crates -> target | |
| - name: Rust format check | |
| working-directory: crates | |
| run: cargo fmt --check | |
| - name: Rust compile check | |
| working-directory: crates | |
| run: cargo check --locked | |
| - name: Rust clippy | |
| working-directory: crates | |
| run: cargo clippy --locked --all-targets -- -D warnings | |
| - name: Rust tests | |
| working-directory: crates | |
| run: cargo test --locked | |
| # ---------------------------------------------------------- | |
| # 2. BUILD: Production build + Pages artifact upload | |
| # QNBS-v3: Stryker mutation removed from the PR/CI pipeline (2026-06-02) β it added noise as a | |
| # flaky, non-gating check. Mutation now runs ONLY via the manual `.github/workflows/ | |
| # mutation.yml` (workflow_dispatch). To be re-integrated in a later iteration. | |
| # ---------------------------------------------------------- | |
| build: | |
| name: ποΈ Build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [quality] | |
| # QNBS-v3: attestations + id-token needed for SLSA build provenance on main. | |
| permissions: | |
| contents: read | |
| attestations: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| - name: Build application | |
| run: pnpm run build | |
| env: | |
| NODE_ENV: production | |
| # QNBS-v3: caches the apt .deb archives `--with-deps` fetches (fonts, libx11, etc.) β shared | |
| # key across every `playwright install --with-deps` call site in this workflow, since they all | |
| # install the same system packages on the same runner image. actions/cache/save on a fresh | |
| # write here also warms the cache for e2e/e2e-deep/storybook/vrt below. | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Playwright system deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| key: apt-playwright-chromium-deps-v1 | |
| # QNBS-v3: The E2E suite runs `vite dev`, so prod-only rolldown bundling crashes (e.g. the | |
| # zod DCE "init_locales is not defined" blank screen) never surface there. This guard loads | |
| # the real production bundle in headless Chromium and fails if React does not mount. | |
| - name: Smoke-test production build (mounts, no runtime crash) | |
| run: | | |
| pnpm exec playwright install --with-deps chromium-headless-shell | |
| pnpm run smoke:prod | |
| - name: Bundle chunk size budget | |
| run: pnpm run bundle:budget | |
| # QNBS-v3: token scoped to this exact step only, never job/workflow-wide β Codecov Bundle Analysis needs it inside this one Vite build, nothing else does. | |
| - name: Bundle analysis (rollup visualizer + Codecov) | |
| run: pnpm run analyze | |
| env: | |
| NODE_ENV: production | |
| CODECOV_BUNDLE_ANALYSIS: 'true' | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| # QNBS-v3: PR head SHA (not GitHub's synthetic merge commit) so Bundle Analysis attributes to the same commit reviewers see; falls back to github.sha on main pushes. | |
| CODECOV_BUNDLE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| - name: Upload bundle analysis HTML | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: bundle-analysis | |
| path: dist/bundle-analysis.html | |
| retention-days: 14 | |
| - name: Upload build artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: dist | |
| path: ./dist | |
| # QNBS-v3: 3 days β dist is only needed by lighthouse + deploy jobs in the same run | |
| retention-days: 3 | |
| # QNBS-v3: SLSA provenance attestation β ties the dist artifact to this exact | |
| # workflow run and commit SHA; verifiable with `gh attestation verify`. Excludes | |
| # woff/woff2 β self-hosted CJK fonts (@fontsource noto-sans-jp/kr/sc) ship ~2.2k | |
| # unicode-range subset files, pushing dist past the action's 1024-subject cap. | |
| # Fonts are vendored static assets, not application code, so attesting the app | |
| # bundle (JS/CSS/HTML/JSON/wasm/etc.) without them still covers what matters. | |
| - name: Attest build provenance | |
| if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-path: | | |
| dist/** | |
| !dist/**/*.woff | |
| !dist/**/*.woff2 | |
| - name: Setup Pages | |
| if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' | |
| uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 | |
| - name: Upload Pages artifact | |
| if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' | |
| uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 | |
| with: | |
| path: ./dist | |
| # ---------------------------------------------------------- | |
| # 3. CI SUCCESS: single required-status aggregator | |
| # (security + signatures + quality + changes + rust-tauri + core-rust + build + e2e + vrt) | |
| # ---------------------------------------------------------- | |
| ci-success: | |
| name: β CI Success | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| # QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level. | |
| needs: | |
| [ | |
| workflow-policy, | |
| pr-size, | |
| security, | |
| signatures, | |
| quality, | |
| changes, | |
| rust-tauri, | |
| core-rust, | |
| build, | |
| e2e, | |
| browser-quality, | |
| ] | |
| if: always() | |
| steps: | |
| - name: Verify all required jobs succeeded | |
| run: | | |
| FAIL=0 | |
| [ "${{ needs.workflow-policy.result }}" = "success" ] || FAIL=1 | |
| # QNBS-v3: pr-size's own if: matches this event check β unconditional skip-tolerance would let a tampered if: pass. | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| [ "${{ needs.pr-size.result }}" = "success" ] || FAIL=1 | |
| elif [ "${{ needs.pr-size.result }}" != "success" ] && [ "${{ needs.pr-size.result }}" != "skipped" ]; then | |
| FAIL=1 | |
| fi | |
| [ "${{ needs.security.result }}" = "success" ] || FAIL=1 | |
| [ "${{ needs.signatures.result }}" = "success" ] || FAIL=1 | |
| [ "${{ needs.quality.result }}" = "success" ] || FAIL=1 | |
| [ "${{ needs.changes.result }}" = "success" ] || FAIL=1 | |
| if [ "${{ needs.rust-tauri.result }}" != "success" ] && [ "${{ needs.rust-tauri.result }}" != "skipped" ]; then | |
| FAIL=1 | |
| fi | |
| if [ "${{ needs.core-rust.result }}" != "success" ] && [ "${{ needs.core-rust.result }}" != "skipped" ]; then | |
| FAIL=1 | |
| fi | |
| [ "${{ needs.build.result }}" = "success" ] || FAIL=1 | |
| [ "${{ needs.e2e.result }}" = "success" ] || FAIL=1 | |
| [ "${{ needs.browser-quality.result }}" = "success" ] || FAIL=1 | |
| if [ "$FAIL" = "1" ]; then | |
| echo "One or more required jobs did not succeed:" | |
| echo " workflow-policy: ${{ needs.workflow-policy.result }}" | |
| echo " pr-size: ${{ needs.pr-size.result }} (skipped = OK, not a pull_request event)" | |
| echo " security: ${{ needs.security.result }}" | |
| echo " signatures: ${{ needs.signatures.result }}" | |
| echo " quality: ${{ needs.quality.result }}" | |
| echo " changes: ${{ needs.changes.result }}" | |
| echo " rust-tauri: ${{ needs.rust-tauri.result }} (skipped = OK, src-tauri untouched)" | |
| echo " core-rust: ${{ needs.core-rust.result }} (skipped = OK, crates/ untouched)" | |
| echo " build: ${{ needs.build.result }}" | |
| echo " e2e: ${{ needs.e2e.result }}" | |
| echo " browser-quality: ${{ needs.browser-quality.result }}" | |
| exit 1 | |
| fi | |
| echo "All required jobs succeeded (or were legitimately skipped)." | |
| # ---------------------------------------------------------- | |
| # 4. DEPLOY: GitHub Pages (only on main push) | |
| # ---------------------------------------------------------- | |
| deploy: | |
| name: π Deploy to GitHub Pages | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [ci-success] | |
| # QNBS-v3 (#522): always()+!cancelled() overrides GHA's default skip-propagation from pr-size/rust-tauri/core-rust being legitimately skipped on push, without publishing from a cancelled run β ci-success.result is the real gate. | |
| if: >- | |
| ${{ | |
| always() && | |
| !cancelled() && | |
| github.ref == 'refs/heads/main' && | |
| github.event_name != 'pull_request' && | |
| needs.ci-success.result == 'success' | |
| }} | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| steps: | |
| - name: Deploy to GitHub Pages | |
| id: deployment | |
| uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 | |
| # ---------------------------------------------------------- | |
| # 5. E2E: Playwright (after quality gate, parallel with build) | |
| # ---------------------------------------------------------- | |
| e2e: | |
| name: π E2E Tests (Playwright) | |
| runs-on: ubuntu-latest | |
| # QNBS-v3: 29 tests Γ 2 browser-projects Γ retries:2 sequential β can exceed 30 min; raised to 50. | |
| timeout-minutes: 50 | |
| needs: [quality] | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| # QNBS-v3: cache Playwright browsers by version so re-runs don't re-download ~400 MB each time. | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Playwright system deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| key: apt-playwright-chromium-deps-v1 | |
| - name: Install Playwright browsers | |
| run: pnpm exec playwright install --with-deps chromium | |
| - name: Run E2E tests | |
| run: pnpm run test:e2e | |
| env: | |
| CI: 'true' | |
| # QNBS-v3: Skip VRT in E2E job β visual regression is handled by the dedicated | |
| # vrt job which serves the production build and owns the baseline PNGs. | |
| PLAYWRIGHT_SKIP_VRT: 'true' | |
| - name: Upload Playwright HTML report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: playwright-report | |
| path: tests/e2e/html-report/ | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| # QNBS-v3: JUnit output from playwright.config.ts reporter β enables per-test annotation in GitHub PR checks | |
| - name: Upload E2E JUnit results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-junit | |
| path: tests/e2e/results/junit.xml | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| - name: Publish E2E test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| report_type: test_results | |
| files: tests/e2e/results/junit.xml | |
| disable_search: true | |
| flags: e2e | |
| name: playwright-e2e | |
| fail_ci_if_error: false | |
| # ---------------------------------------------------------- | |
| # 6a. E2E DEEP COVERAGE: Feature-flag matrix + error paths (non-blocking) | |
| # QNBS-v3: Separate from the required `e2e` gate. Runs tests/e2e/deep/ with | |
| # explicit flag combinations that were previously invisible (coverage illusion). | |
| # continue-on-error:true so deep failures inform but never block a merge. | |
| # ---------------------------------------------------------- | |
| e2e-deep: | |
| name: π¬ E2E Deep Coverage (feature-flag matrix) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [quality] | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Playwright system deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| key: apt-playwright-chromium-deps-v1 | |
| - name: Install Playwright browsers | |
| run: pnpm exec playwright install --with-deps chromium | |
| - name: Run deep E2E coverage | |
| run: pnpm run test:e2e:deep | |
| env: | |
| CI: 'true' | |
| - name: Upload deep E2E report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-deep-report | |
| path: tests/e2e/html-report/ | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| # QNBS-v3: same playwright.config.ts reporter as the required e2e job, produced independently on this runner β previously never uploaded. | |
| - name: Upload deep E2E JUnit results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-deep-junit | |
| path: tests/e2e/results/junit.xml | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| - name: Publish deep E2E test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| report_type: test_results | |
| files: tests/e2e/results/junit.xml | |
| disable_search: true | |
| flags: e2e-deep | |
| name: e2e-deep | |
| fail_ci_if_error: false | |
| # ---------------------------------------------------------- | |
| # 6. BROWSER QUALITY: VRT + Lighthouse (after build) | |
| # ---------------------------------------------------------- | |
| # QNBS-v3: consolidated from separate vrt/lighthouse jobs (Wave 2 CI-performance) β both already ran off the critical path; sharing checkout/setup/artifact-download once saves runner-minutes, measured to fit inside this baseline's critical-path slack, not a universal zero-wall-clock guarantee. | |
| browser-quality: | |
| name: π¦πΌ Browser Quality (VRT + Lighthouse) | |
| runs-on: ubuntu-latest | |
| # QNBS-v3: union of the former 15 min (vrt) + 25 min (lighthouse) timeouts, now sequential in one job. | |
| timeout-minutes: 35 | |
| needs: [build] | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| - name: Download build artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: dist | |
| path: dist/ | |
| # ---- VRT phase ---- | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Playwright system deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| key: apt-playwright-chromium-deps-v1 | |
| - name: Install Playwright browsers | |
| run: pnpm exec playwright install --with-deps chromium | |
| - name: Serve dist + run VRT | |
| env: | |
| CI: true | |
| run: | | |
| # QNBS-v3: The GitHub Pages build has base=/WorldScript-Studio/. Mirror that structure | |
| # so http-server serves assets at the expected paths matching playwright.config baseURL. | |
| mkdir -p serve_root/WorldScript-Studio | |
| cp -r dist/. serve_root/WorldScript-Studio/ | |
| pnpm exec http-server serve_root -p 3000 -s & | |
| pnpm exec wait-on http://127.0.0.1:3000 --timeout 30000 | |
| pnpm run test:vrt | |
| - name: Upload VRT screenshots | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: vrt-screenshots | |
| # QNBS-v3: upload both baselines and diff outputs so PR reviewers can compare visually β playwright.config.ts sets no outputDir, so Playwright's default (repo-root test-results/, not tests/e2e/test-results/) is where actual/diff failure images actually land. | |
| path: | | |
| tests/e2e/visual-regression.spec.ts-snapshots/ | |
| test-results/ | |
| retention-days: 7 | |
| # QNBS-v3: same playwright.config.ts reporter as the required e2e job, produced independently on this runner β previously never uploaded. | |
| - name: Upload VRT JUnit results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: vrt-junit | |
| path: tests/e2e/results/junit.xml | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| - name: Publish VRT test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| report_type: test_results | |
| files: tests/e2e/results/junit.xml | |
| disable_search: true | |
| flags: vrt | |
| name: vrt | |
| fail_ci_if_error: false | |
| # ---- Lighthouse phase ---- | |
| # QNBS-v3: !cancelled(), not the step default success() β VRT failing must not skip Lighthouse; each phase is an independent diagnostic gate that must produce its own evidence, matching what two separate jobs would have done. | |
| # QNBS-v3: no --assert.exitCode flag β let .lighthouserc.cjs assertions drive the | |
| # exit code; error-level assertions (CLS, accessibility) will correctly fail this job. | |
| - name: Run Lighthouse CI (mobile) | |
| if: ${{ !cancelled() }} | |
| run: pnpm exec lhci autorun | |
| env: | |
| LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | |
| # QNBS-v3: !cancelled() so desktop still runs even if VRT or mobile Lighthouse failed; pre-existing continue-on-error (unchanged by this consolidation) means its accessibility/CLS error assertions can still fail this step internally without failing the job β promotion to blocking stays gated on the 5-consecutive-main-run exit criterion in docs/CI.md. | |
| - name: Run Lighthouse CI (desktop) | |
| if: ${{ !cancelled() }} | |
| run: pnpm exec lhci autorun --config=.lighthouserc.desktop.cjs | |
| continue-on-error: true | |
| env: | |
| LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | |
| - name: Upload Lighthouse report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: lighthouse-report | |
| path: .lighthouseci/ | |
| if-no-files-found: warn | |
| # QNBS-v3: align with other report artifacts (7 days); 14 was inconsistent | |
| retention-days: 7 | |
| # ---------------------------------------------------------- | |
| # 7. STORYBOOK: Build + artifact (parallel with build) | |
| # ---------------------------------------------------------- | |
| storybook: | |
| name: π Storybook | |
| runs-on: ubuntu-latest | |
| # QNBS-v3: Cloud-first β Storybook build + test-runner runs only in CI (not local). | |
| # 25 min = build (~8 min) + server start + test-runner retries + screenshot upload. | |
| timeout-minutes: 25 | |
| # QNBS-v3: Storybook remains advisory until ten genuine first-attempt runs establish stability. | |
| continue-on-error: true | |
| needs: [quality] | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| # QNBS-v3: Cache Playwright browsers between runs β saves ~60 s install time. | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-chromium-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} | |
| restore-keys: playwright-chromium-${{ runner.os }}- | |
| - name: Build Storybook | |
| run: pnpm exec storybook build --output-dir storybook-static | |
| # QNBS-v3: /var/cache/apt/archives is root-owned by default β actions/cache's restore | |
| # (tar extraction) runs as the unprivileged runner user and fails with "Permission denied" | |
| # on every file without this, silently degrading to a cache miss every time (real CI | |
| # evidence: "Cache hit" followed immediately by "Cache not found" from the failed tar). | |
| - name: Make apt archive cache dir writable by the runner user | |
| run: sudo mkdir -p /var/cache/apt/archives/partial && sudo chown -R runner:runner /var/cache/apt/archives && sudo chmod -R 755 /var/cache/apt/archives | |
| - name: Cache apt packages (Playwright system deps) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: /var/cache/apt/archives/*.deb | |
| key: apt-playwright-chromium-deps-v1 | |
| # QNBS-v3: Chromium-only; test-runner v0.24+ is async-compatible with Storybook v10. | |
| - name: Install Playwright browsers (test-runner) | |
| run: pnpm exec playwright install --with-deps chromium | |
| - name: Serve Storybook + Run test-runner (cloud-optimised) | |
| # QNBS-v3 (F-12, 2026-07-29): the command below was invoking three flags this | |
| # @storybook/test-runner@^0.24.4 CLI does not support (`--max-workers` β wrong case, should | |
| # be `--maxWorkers`; `--retries` and `--screenshot-on-failure` don't exist in this version | |
| # at all). Every prior CI run failed on the FIRST unknown option before any story ever ran, | |
| # printed --help text, and `|| true` silently turned that into a green step β this gate has | |
| # produced zero real signal since it was added. Fixed to only pass supported flags. | |
| # QNBS-v3 (CodeRabbit, 2026-07-29): `|| true` swallowed the exit code so this step showed | |
| # green even when it truly failed β literally how the bug above stayed invisible. The | |
| # job-level advisory policy keeps the failed step visible without making it a merge blocker. | |
| env: | |
| CI: true | |
| DEBUG: pw:browser* | |
| # QNBS-v3: --junit's underlying jest-junit defaults to <cwd>/junit.xml, outside every path this job already uploads β pin it into test-results/ instead of guessing at the default. | |
| JEST_JUNIT_OUTPUT_FILE: test-results/storybook-junit.xml | |
| run: | | |
| npx http-server storybook-static -p 6006 -s & | |
| npx wait-on http://localhost:6006 --timeout 90000 | |
| pnpm exec test-storybook --url http://localhost:6006 \ | |
| --maxWorkers=2 \ | |
| --junit | |
| # QNBS-v3: always() β upload even on failure; artifacts are the primary debug tool on low-end | |
| # local hardware where storybook cannot run. Use the Storybook Debug workflow for | |
| # interactive investigation (see .github/workflows/storybook-debug.yml). | |
| - name: Upload Storybook build + test results | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: storybook-build-and-test-results | |
| path: | | |
| storybook-static/ | |
| test-results/ | |
| **/screenshots/ | |
| retention-days: 7 | |
| - name: Publish Storybook test results to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| report_type: test_results | |
| files: test-results/storybook-junit.xml | |
| disable_search: true | |
| flags: storybook | |
| name: storybook | |
| fail_ci_if_error: false |