Skip to content

feat(project): add explicit legacy-to-v1 admission primitive (#653) #2809

feat(project): add explicit legacy-to-v1 admission primitive (#653)

feat(project): add explicit legacy-to-v1 admission primitive (#653) #2809

Workflow file for this run

# ============================================================
# 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
# ----------------------------------------------------------
# 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