fix(release): enforce v1.28.0 development truth #2133
Workflow file for this run
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: | |
| # ---------------------------------------------------------- | |
| # 0. SECURITY: pnpm audit + gitleaks + dependency review | |
| # ---------------------------------------------------------- | |
| security: | |
| name: π Security Audit | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # 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@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 | |
| 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 | |
| # ---------------------------------------------------------- | |
| # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) | |
| # ---------------------------------------------------------- | |
| quality: | |
| name: π Quality Gate (Node ${{ matrix.node-version }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| 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: 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: Vitest mit --retry=2 fΓΌr stabile Runs + NODE_OPTIONS fΓΌr Node 24 KompatibilitΓ€t. | |
| # --no-experimental-webstorage deaktiviert native Node 24+ Web Storage API (verhindert localStorage.clear() Fehler). | |
| # Best Practice 2026: Retry-Logik fΓ€ngt transiente Fehler ab, die bei CI-Runner-Γberlastung auftreten kΓΆnnen. | |
| - name: Unit tests (Vitest) | |
| run: pnpm exec vitest run --coverage --reporter=json --outputFile=test-results.json --retry=2 | |
| 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 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 | |
| # ---------------------------------------------------------- | |
| # 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 | |
| - name: Bundle analysis (rollup visualizer) | |
| run: pnpm run analyze | |
| env: | |
| NODE_ENV: production | |
| - 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 + quality + changes + rust-tauri + core-rust + build + e2e + vrt) | |
| # ---------------------------------------------------------- | |
| ci-success: | |
| name: β CI Success | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| needs: [security, quality, changes, rust-tauri, core-rust, build, e2e, vrt] | |
| if: always() | |
| steps: | |
| - name: Verify all required jobs succeeded | |
| run: | | |
| FAIL=0 | |
| [ "${{ needs.security.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.vrt.result }}" = "success" ] || FAIL=1 | |
| if [ "$FAIL" = "1" ]; then | |
| echo "One or more required jobs did not succeed:" | |
| echo " security: ${{ needs.security.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 " vrt: ${{ needs.vrt.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] | |
| if: 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 | |
| # ---------------------------------------------------------- | |
| # 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 | |
| # ---------------------------------------------------------- | |
| # 6. LIGHTHOUSE: Performance budgets (after build) | |
| # ---------------------------------------------------------- | |
| lighthouse: | |
| name: π¦ Lighthouse CI | |
| runs-on: ubuntu-latest | |
| # QNBS-v3: 25 min β two lhci runs (mobile 3Γ + desktop 2Γ); was 15 min for mobile only | |
| timeout-minutes: 25 | |
| needs: [build] | |
| continue-on-error: false | |
| 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 | |
| # 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) | |
| run: pnpm exec lhci autorun | |
| env: | |
| LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | |
| # QNBS-v3: desktop run is continue-on-error until baselines stabilise; accessibility | |
| # and CLS are still error gates so critical issues surface immediately. | |
| - name: Run Lighthouse CI (desktop) | |
| 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 | |
| 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. Moved the | |
| # non-blocking behavior to step-level `continue-on-error: true`, which still shows the step | |
| # as failed (visible signal for the F-13 "~10 real runs" re-evaluation) without failing the | |
| # job. Re-evaluate after ~10 real (non-help-text) runs on main β if none fail on genuine | |
| # a11y/story assertions, drop `continue-on-error` and make this blocking. | |
| continue-on-error: true | |
| env: | |
| CI: true | |
| DEBUG: pw:browser* | |
| 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 | |
| # ---------------------------------------------------------- | |
| # 8. VRT: Visual regression (Playwright screenshots, chromium only) | |
| # ---------------------------------------------------------- | |
| vrt: | |
| name: πΌ Visual Regression | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [build] | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup | |
| - name: Download dist artifact | |
| # QNBS-v3: SHA-pinned to match all other download-artifact usages in this workflow | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: dist | |
| path: dist/ | |
| # 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. | |
| path: | | |
| tests/e2e/visual-regression.spec.ts-snapshots/ | |
| tests/e2e/test-results/ | |
| retention-days: 7 |