fix(core): apply one URL scheme rule wherever a link leaves React's hands #15227
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
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # PR CI: test, build, analyze, a11y audit, and comment | |
| # Everything runs in parallel where possible | |
| # | |
| # Merge queue: test + build also run on merge_group events so the | |
| # merge queue can gate on them before landing a PR. | |
| name: CI | |
| on: | |
| pull_request: | |
| branches: ['main'] | |
| merge_group: | |
| permissions: {} | |
| concurrency: | |
| group: 'ci-${{ github.event.merge_group.head_sha || github.head_ref }}' | |
| cancel-in-progress: true | |
| jobs: | |
| # Detect lightweight changes. Missing merge-base data fails closed. | |
| check-scope: | |
| runs-on: ubuntu-slim | |
| permissions: | |
| contents: read | |
| outputs: | |
| docsite_only: ${{ steps.scope.outputs.docsite_only }} | |
| spec_only: ${{ steps.scope.outputs.spec_only }} | |
| tooling_only: ${{ steps.scope.outputs.tooling_only }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 50 | |
| - name: Fetch base branch | |
| if: github.event_name == 'pull_request' | |
| run: git fetch origin ${{ github.base_ref }} --depth=50 | |
| - name: Determine change scope | |
| id: scope | |
| run: | | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| printf 'docsite_only=false\nspec_only=false\ntooling_only=false\n' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| MERGE_BASE=$(git merge-base HEAD origin/${{ github.base_ref }} 2>/dev/null || echo "") | |
| if [ -z "$MERGE_BASE" ]; then | |
| printf 'docsite_only=false\nspec_only=false\ntooling_only=false\n' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| CLASSIFIER_ROOT="$RUNNER_TEMP/change-scope" | |
| CLASSIFIER_DIR="$CLASSIFIER_ROOT/.github/scripts" | |
| REGISTRY_DIR="$CLASSIFIER_ROOT/scripts" | |
| mkdir -p "$CLASSIFIER_DIR" "$REGISTRY_DIR" | |
| CLASSIFIER="$CLASSIFIER_DIR/change-scope.cjs" | |
| MATCHER="$CLASSIFIER_DIR/knowledge-paths.cjs" | |
| REGISTRY="$REGISTRY_DIR/component-packages.cjs" | |
| if git show "origin/${{ github.base_ref }}:.github/scripts/change-scope.cjs" > "$CLASSIFIER" 2>/dev/null \ | |
| && git show "origin/${{ github.base_ref }}:.github/scripts/knowledge-paths.cjs" > "$MATCHER" 2>/dev/null \ | |
| && git show "origin/${{ github.base_ref }}:scripts/component-packages.cjs" > "$REGISTRY" 2>/dev/null; then | |
| git diff --name-status "$MERGE_BASE"...HEAD \ | |
| | node "$CLASSIFIER" --github-output | |
| else | |
| # Missing trusted policy or dependencies cannot grant a specialized | |
| # lane. Run broad CI until the base branch supplies the full classifier. | |
| printf 'docsite_only=false\nspec_only=false\ntooling_only=false\n' \ | |
| >> "$GITHUB_OUTPUT" | |
| fi | |
| # Docsite data extraction tests — always runs, fast (~2s) | |
| docsite-test: | |
| needs: [check-scope] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Require successful scope classification | |
| if: needs.check-scope.result != 'success' | |
| run: | | |
| echo "Change scope could not be classified; refusing to skip required work." | |
| exit 1 | |
| - name: Skip docsite work for Node tooling | |
| if: needs.check-scope.outputs.tooling_only == 'true' | |
| run: echo "Node-tooling PR — docsite generation is not an owned check" | |
| - uses: actions/checkout@v7 | |
| # Spec validation imports the canonical CLI discovery path, so the | |
| # lightweight docs path needs workspace dependencies too. | |
| - uses: ./.github/actions/setup | |
| if: needs.check-scope.outputs.tooling_only != 'true' | |
| - name: Validate spec records | |
| if: needs.check-scope.outputs.spec_only == 'true' | |
| run: node scripts/check-knowledge.mjs | |
| # Docsite `generate` runs `astryx theme build`, which imports the compiled | |
| # @astryxdesign/core/theme entry (dist/theme/index.js). Build core first so that | |
| # entry exists — otherwise generate fails with "Cannot find module". | |
| - name: Build core package | |
| if: needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/core build | |
| # The generated preview registry imports every configured canary component | |
| # package. Build those exports so Vitest exercises the same graph as the | |
| # canary deployment. | |
| - name: Build canary component packages | |
| if: needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: | | |
| pnpm -F @astryxdesign/lab build | |
| pnpm -F @astryxdesign/charts build | |
| pnpm -F @astryxdesign/richtext build | |
| pnpm -F @astryxdesign/vega build | |
| - name: Generate and test docsite data | |
| if: needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/docsite generate && pnpm -F @astryxdesign/docsite test | |
| # Lightweight check — does the PR touch any published components? | |
| # Gates pr-a11y and pr-rtl to avoid unnecessary work. | |
| # | |
| # `scripts/component-packages.cjs` owns the component source roots used here | |
| # and by the RTL roster. `analyze-pr.js` still decides which changed components | |
| # each downstream audit receives. When those projections disagree the audits | |
| # silently no-op, so harness/registry changes also exercise the RTL smoke scope. | |
| check-components: | |
| needs: [check-scope] | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-slim | |
| permissions: | |
| contents: read | |
| outputs: | |
| has_components: ${{ steps.check.outputs.has_components }} | |
| has_rtl_components: ${{ steps.check.outputs.has_rtl_components }} | |
| has_rtl_harness: ${{ steps.check.outputs.has_rtl_harness }} | |
| force_full_component_audits: ${{ steps.check.outputs.force_full_component_audits }} | |
| # Stable visual scope is separate from component scope: Core and the | |
| # non-private shipped themes participate; canaryOnly packages do not. | |
| has_stable_visual: ${{ steps.check.outputs.has_stable_visual }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| # Depth 50 (same as check-scope) so the three-dot diff below can find | |
| # a merge base. With the default depth of 1 the diff fails with | |
| # "no merge base", the error is swallowed by the pipeline, and | |
| # has_components silently becomes false — skipping pr-a11y entirely. | |
| fetch-depth: 50 | |
| - name: Fetch base branch | |
| run: git fetch origin ${{ github.base_ref }} --depth=50 | |
| - name: Check for component changes | |
| id: check | |
| run: | | |
| if [ "${{ needs.check-scope.outputs.docsite_only }}" = "true" ] \ | |
| || [ "${{ needs.check-scope.outputs.spec_only }}" = "true" ] \ | |
| || [ "${{ needs.check-scope.outputs.tooling_only }}" = "true" ]; then | |
| printf 'has_components=false\nhas_rtl_components=false\nhas_rtl_harness=false\nforce_full_component_audits=false\nhas_stable_visual=false\n' >> "$GITHUB_OUTPUT" | |
| echo "Trusted specialized lane — component audits explicitly not applicable." | |
| exit 0 | |
| fi | |
| if ! MERGE_BASE=$(git merge-base HEAD origin/${{ github.base_ref }} 2>/dev/null); then | |
| echo "::warning::No merge base between HEAD and origin/${{ github.base_ref }} — running full component audits" | |
| printf 'has_components=true\nhas_rtl_components=true\nhas_rtl_harness=true\nforce_full_component_audits=true\n' >> "$GITHUB_OUTPUT" | |
| echo "has_stable_visual=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| POLICY_ROOT="$RUNNER_TEMP/component-audit-scope" | |
| mkdir -p "$POLICY_ROOT/.github/scripts" "$POLICY_ROOT/scripts" | |
| SCOPE="$POLICY_ROOT/.github/scripts/component-audit-scope.cjs" | |
| REGISTRY="$POLICY_ROOT/scripts/component-packages.cjs" | |
| if git show "origin/${{ github.base_ref }}:.github/scripts/component-audit-scope.cjs" > "$SCOPE" 2>/dev/null \ | |
| && git show "origin/${{ github.base_ref }}:scripts/component-packages.cjs" > "$REGISTRY" 2>/dev/null; then | |
| git diff --name-status "$MERGE_BASE"...HEAD \ | |
| | node "$SCOPE" --registry "$REGISTRY" --github-output | |
| else | |
| echo "::warning::Trusted component audit policy is unavailable — running full component audits" | |
| printf 'has_components=true\nhas_rtl_components=true\nhas_rtl_harness=true\nforce_full_component_audits=true\n' >> "$GITHUB_OUTPUT" | |
| fi | |
| FILES=$(git diff --name-only "$MERGE_BASE"...HEAD) | |
| # One classifier owns the release-channel rule. It reads each | |
| # package's `astryx.canaryOnly` metadata rather than hard-coding | |
| # today's Lab/charts/richtext/vega list. | |
| printf '%s\n' "$FILES" \ | |
| | node .github/scripts/visual-scope.mjs --github-output "$GITHUB_OUTPUT" | |
| # Tests run as two parallel lanes joined by `test` below. | |
| # | |
| # They used to be one `pnpm test` on a 2-core runner, which ran both Vitest | |
| # projects back to back and was terminated around 20 minutes with no summary | |
| # — no assertion output, so nothing said which suite was at fault. `main` was | |
| # already at ~19m15, so the budget, not any one test, was the defect. | |
| # | |
| # The projects are independent by construction (see vitest.config.ts: `ui` is | |
| # jsdom + StyleX, `node` is everything else), so running them as two jobs | |
| # costs no duplicate execution. They do NOT carry half the work each: on this | |
| # CI, `ui` is ~5x the heavier lane, so it takes the larger runner. | |
| # | |
| # The earlier local figure that put `node` ahead (570s vs 244s) was not a | |
| # comparable measurement — only the node run was bounded to a small worker | |
| # pool, and node is the project whose cost is per-assertion CLI subprocesses. | |
| # | |
| # The repo-wide guardrail steps ride `test-ui` (~18s of it). | |
| # | |
| # `test` remains a job, so the required check of that name still exists and | |
| # still reports for every PR. This mirrors the `build` join over | |
| # build-storybook + build-sandbox below. | |
| test-ui: | |
| needs: [check-scope] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: 4-core-ubuntu | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Require successful scope classification | |
| if: needs.check-scope.result != 'success' | |
| run: | | |
| echo "Change scope could not be classified; refusing to skip required work." | |
| exit 1 | |
| - name: Skip heavy work for a specialized lane | |
| if: needs.check-scope.outputs.docsite_only == 'true' || needs.check-scope.outputs.spec_only == 'true' || needs.check-scope.outputs.tooling_only == 'true' | |
| run: echo "Specialized PR lane — skipping unrelated test suite" | |
| - uses: actions/checkout@v7 | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| - uses: ./.github/actions/setup | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| - name: Check knowledge schema history | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| needs.check-scope.outputs.docsite_only != 'true' && | |
| needs.check-scope.outputs.spec_only != 'true' && | |
| needs.check-scope.outputs.tooling_only != 'true' | |
| run: | | |
| git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1 | |
| node scripts/check-knowledge.mjs --base "${{ github.event.pull_request.base.sha }}" | |
| - name: Check copyright headers | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: ./scripts/add-copyright.sh --check | |
| - name: Check package.json exports are in sync | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: node scripts/sync-exports.js --check | |
| - name: Verify the published CLI ./api type surface | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: node .github/scripts/cli-api-types-verify.mjs | |
| - name: Check token docs are in sync | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: node scripts/generate-token-docs.mjs --check | |
| # Fails when the lab readiness manifest claims a check the source tree | |
| # contradicts — the report is derived, never hand-maintained. | |
| - name: Check lab readiness manifest is current | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm lab:readiness:check | |
| - run: pnpm vitest run --project ui | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| test-node: | |
| needs: [check-scope] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Require successful scope classification | |
| if: needs.check-scope.result != 'success' | |
| run: | | |
| echo "Change scope could not be classified; refusing to skip required work." | |
| exit 1 | |
| - name: Skip heavy work for a specialized lane | |
| if: needs.check-scope.outputs.docsite_only == 'true' || needs.check-scope.outputs.spec_only == 'true' | |
| run: echo "Specialized PR lane — skipping unrelated test suite" | |
| - uses: actions/checkout@v7 | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' | |
| - uses: ./.github/actions/setup | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' | |
| - run: pnpm vitest run --project node | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' | |
| # Theme-family compilation is owned by `theme-build`, not Build. Until | |
| # that surface has an admitted dedicated lane, broad CI keeps its browser | |
| # contract inside the existing Node test owner rather than `test-build`. | |
| - name: Install Playwright for theme-family contract | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: npx playwright install chromium | |
| - name: Build theme-family compiler dependencies | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/core build | |
| - name: Generate the maintained theme-family fixture | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/sandbox generate:theme-family | |
| - name: Run theme family cascade guard | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: node .github/scripts/theme-family-cascade.js | |
| # Public registry contract. Generation proves schema, dependency, route-lock, | |
| # alias, and release-gating invariants; the final step drives every canonical | |
| # item through the exact pinned ShadCN client and compiles every written source. | |
| registry-contract: | |
| needs: [check-scope] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: 4-core-ubuntu | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Require successful scope classification | |
| if: needs.check-scope.result != 'success' | |
| run: | | |
| echo "Change scope could not be classified; refusing to skip required work." | |
| exit 1 | |
| - name: Skip registry work for spec-only changes | |
| if: needs.check-scope.outputs.spec_only == 'true' | |
| run: echo "Spec-only PR — public registry output cannot change" | |
| - uses: actions/checkout@v7 | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| - uses: ./.github/actions/setup | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| - name: Build registry package exports | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| run: pnpm build | |
| - name: Verify hidden production registry contract | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| run: node internal/shadcn-registry/verify-production-gate.mjs | |
| - name: Generate and validate the complete preview registry | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| env: | |
| DOCSITE_TARGET: canary | |
| run: node apps/docsite/scripts/generate-data.mjs | |
| - name: Install and build every preview registry item | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| run: node internal/shadcn-registry/verify-full-catalog.mjs | |
| - name: Generate the hidden production registry | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| env: | |
| DOCSITE_TARGET: latest | |
| run: node apps/docsite/scripts/generate-data.mjs | |
| - name: Verify hidden production discovery surfaces | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| env: | |
| ASTRYX_VERIFY_HIDDEN_PRODUCTION: '1' | |
| NEXT_PUBLIC_DOCS_TARGET: latest | |
| run: pnpm -F @astryxdesign/docsite exec vitest run src/__tests__/hidden-shadcn-production.test.ts | |
| - name: Install and build every hidden production registry item | |
| if: needs.check-scope.outputs.spec_only != 'true' | |
| env: | |
| ASTRYX_SHADCN_USE_PUBLISHED_PACKAGES: '1' | |
| run: node internal/shadcn-registry/verify-full-catalog.mjs | |
| # Historical required check name. The Vitest lanes, Build test owner, and | |
| # public registry contract always report or explicitly skip, so this join | |
| # projects their combined result without creating another surface lane. | |
| test: | |
| needs: [check-scope, test-ui, test-node, test-build, registry-contract] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: ubuntu-slim | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Require expected test-owner outcomes | |
| env: | |
| CHECK_SCOPE_RESULT: ${{ needs.check-scope.result }} | |
| DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }} | |
| SPEC_ONLY: ${{ needs.check-scope.outputs.spec_only }} | |
| TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }} | |
| TEST_UI_RESULT: ${{ needs.test-ui.result }} | |
| TEST_NODE_RESULT: ${{ needs.test-node.result }} | |
| TEST_BUILD_RESULT: ${{ needs.test-build.result }} | |
| REGISTRY_CONTRACT_RESULT: ${{ needs.registry-contract.result }} | |
| run: node .github/scripts/ci-test-join.mjs | |
| # Build storybook + typecheck + analyze components (parallel with test and | |
| # build-sandbox). Uploads the storybook preview and analysis artifacts for | |
| # downstream jobs. | |
| # The sandbox preview build lives in build-sandbox: it alone takes ~4min | |
| # while everything here takes ~2min, so the two run in parallel and the | |
| # `build` join below folds their results into the historical check name. | |
| build-storybook: | |
| needs: [check-scope] | |
| if: ${{ always() && !cancelled() }} | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| outputs: | |
| storybook_url: ${{ steps.urls.outputs.storybook_url }} | |
| short_hash: ${{ steps.urls.outputs.short_hash }} | |
| sandbox_url: ${{ steps.urls.outputs.sandbox_url }} | |
| pr_number: ${{ steps.urls.outputs.pr_number }} | |
| steps: | |
| - name: Require successful scope classification | |
| if: needs.check-scope.result != 'success' | |
| run: | | |
| echo "Change scope could not be classified; refusing to skip required work." | |
| exit 1 | |
| - name: Skip heavy work for a specialized lane | |
| if: needs.check-scope.outputs.docsite_only == 'true' || needs.check-scope.outputs.spec_only == 'true' || needs.check-scope.outputs.tooling_only == 'true' | |
| run: echo "Specialized PR lane — skipping unrelated build" | |
| - name: Checkout | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| uses: actions/checkout@v7 | |
| with: | |
| # Depth 50 (same as check-scope) so analyze-pr.js's three-dot diff | |
| # can find a merge base — depth 1 breaks it ("no merge base") and | |
| # the analysis silently reported zero changed components. | |
| fetch-depth: 50 | |
| - name: Fetch base branch for PR analysis | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| run: git fetch origin ${{ github.base_ref }} --depth=50 | |
| - name: Setup Node and pnpm | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| uses: ./.github/actions/setup | |
| - name: Build core package | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm build | |
| - name: Verify package exports | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: node scripts/verify-exports.mjs | |
| - name: Typecheck component docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/core typecheck:docs | |
| - name: Typecheck lab docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/lab typecheck:docs | |
| - name: Typecheck charts docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/charts typecheck:docs | |
| - name: Typecheck rich text docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/richtext typecheck:docs | |
| - name: Typecheck Vega docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/vega typecheck:docs | |
| - name: Typecheck template docs | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/cli typecheck:template-docs | |
| # Full-package strict checkJs over the CLI (src, bin, scripts, docs, and | |
| # the emitted templates). Requires the `pnpm build` above so the template | |
| # .tsx sources can resolve the built @astryxdesign/{core,charts,lab} types. | |
| - name: Typecheck CLI (strict) | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/cli typecheck:strict | |
| - name: Typecheck storybook | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/storybook typecheck | |
| - name: Typecheck core (including tests) | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/core typecheck | |
| # The accessibility spec-test contracts are consumed as TypeScript source | |
| # by Vitest and Playwright, and neither runner typechecks. This is the | |
| # only gate over them. | |
| - name: Typecheck accessibility spec-test contracts | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/a11y-spec typecheck | |
| - name: Build Storybook | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| run: pnpm -F @astryxdesign/storybook build | |
| - name: Compute PR preview path | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| id: urls | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number }}" | |
| COMMIT_HASH="${{ github.event.pull_request.head.sha }}" | |
| SHORT_HASH="${COMMIT_HASH:0:7}" | |
| REPO_NAME="${{ github.event.repository.name }}" | |
| REPO_OWNER="${{ github.repository_owner }}" | |
| echo "short_hash=${SHORT_HASH}" >> $GITHUB_OUTPUT | |
| echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT | |
| echo "storybook_url=https://${REPO_OWNER}.github.io/${REPO_NAME}/pr/${PR_NUMBER}/" >> $GITHUB_OUTPUT | |
| echo "sandbox_url=https://${REPO_OWNER}.github.io/${REPO_NAME}/pr/${PR_NUMBER}/sandbox/" >> $GITHUB_OUTPUT | |
| - name: Upload Storybook artifact | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: storybook-${{ steps.urls.outputs.short_hash }} | |
| path: apps/storybook/dist/ | |
| retention-days: 30 | |
| # pr-visual reads each theme's built source to learn which targets that | |
| # theme styles — a theme's component map is what defineTheme returns, not | |
| # a literal in its source. This job has already built them; shipping the | |
| # artifact keeps that job on a download instead of a second full build. | |
| # | |
| # core's dist ships with them because importing a built theme executes | |
| # `import {defineTheme} from '@astryxdesign/core/theme'`, which pnpm | |
| # resolves through the workspace link into packages/core/dist. | |
| # | |
| # Two paths, so the artifact is rooted at their common ancestor | |
| # (packages/) — the download below unpacks it there. | |
| - name: Upload built themes and core | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: dists-${{ steps.urls.outputs.short_hash }} | |
| path: | | |
| packages/themes/*/dist/ | |
| packages/core/dist/ | |
| retention-days: 1 | |
| - name: Analyze components | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| run: | | |
| node .github/scripts/analyze-pr.js \ | |
| --base origin/${{ github.base_ref }} \ | |
| --head HEAD \ | |
| --output analysis.json | |
| - name: Write PR comment metadata | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| run: | | |
| # The pr-comment and deploy-preview workflows run on workflow_run | |
| # (privileged token), so they can comment on and deploy previews for | |
| # fork PRs too. Neither context knows the PR number, preview URLs, or | |
| # the artifact hash, so persist them here for the trusted jobs to read. | |
| # | |
| # Preview URLs are now populated for ALL PRs (fork and same-repo alike) | |
| # because deploy-preview.yml deploys every PR's preview from the trusted | |
| # context — the old fork/same-repo split (empty URLs on forks) is gone. | |
| cat > pr-meta.json <<EOF | |
| { | |
| "prNumber": "${{ steps.urls.outputs.pr_number }}", | |
| "shortHash": "${{ steps.urls.outputs.short_hash }}", | |
| "headSha": "${{ github.event.pull_request.head.sha }}", | |
| "headRepository": "${{ github.event.pull_request.head.repo.full_name }}", | |
| "baseRepository": "${{ github.event.pull_request.base.repo.full_name }}", | |
| "runId": "${{ github.run_id }}", | |
| "runAttempt": "${{ github.run_attempt }}", | |
| "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| } | |
| EOF | |
| - name: Upload analysis artifact | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' && github.event_name == 'pull_request' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: pr-analysis | |
| path: | | |
| analysis.json | |
| pr-meta.json | |
| retention-days: 1 | |
| # Sandbox preview build — the single slowest piece of PR CI (~4min), run as | |
| # its own job so it overlaps build-storybook instead of serializing after it. | |
| # PR-only: merge_group runs never deploy previews, and docsite-only PRs skip | |
| # preview builds entirely. | |
| build-sandbox: | |
| needs: [check-scope] | |
| if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| outputs: | |
| short_hash: ${{ steps.urls.outputs.short_hash }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node and pnpm | |
| uses: ./.github/actions/setup | |
| - name: Build core package | |
| run: pnpm build | |
| - name: Compute sandbox preview paths | |
| id: urls | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number }}" | |
| COMMIT_HASH="${{ github.event.pull_request.head.sha }}" | |
| SHORT_HASH="${COMMIT_HASH:0:7}" | |
| REPO_NAME="${{ github.event.repository.name }}" | |
| { | |
| echo "short_hash=${SHORT_HASH}" | |
| echo "sandbox_base_path=/${REPO_NAME}/pr/${PR_NUMBER}/sandbox" | |
| echo "template_assets_base_path=/${REPO_NAME}/sandbox/template-assets" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Cache Next.js build | |
| uses: actions/cache@v6 | |
| with: | |
| path: apps/sandbox/.next/cache | |
| # No restore-keys fallback — see deploy.yml for the full rationale. | |
| # The Next.js module cache bakes in @astryxdesign/core's resolved export graph, | |
| # so a loose fallback can restore a cache built against a different | |
| # export shape and silently produce wrong builds (broke #2941's | |
| # post-merge deploy). A changed key yields a cold (safe) rebuild. | |
| # | |
| # Keyed by PR number so PRs never share a cache with main (deploy.yml) | |
| # or with other PRs: each PR builds with its own basePath | |
| # (SANDBOX_BASE_PATH=/<repo>/pr/<n>/sandbox vs /<repo>/sandbox on main), | |
| # and Next.js invalidates the webpack cache whenever the resolved config | |
| # changes — so a cache built under a different basePath always restores | |
| # as dead weight (download cost, zero reuse) and, because the shared key | |
| # "hit", the PR could never save a cache for its own basePath either. | |
| # Re-pushes to the same PR keep the same key and get real warm builds. | |
| # (This job only runs on pull_request events — see the job-level `if:` | |
| # above — so github.event.pull_request.number is always set here.) | |
| key: nextjs-sandbox-pr${{ github.event.pull_request.number }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('packages/core/dist/**') }} | |
| - name: Build Sandbox | |
| run: pnpm -F @astryxdesign/sandbox build | |
| env: | |
| # Must match the deployed Pages path (<owner>.github.io/<repo>/pr/<n>/sandbox). | |
| # Built without the /<repo> prefix, the sandbox's root-absolute asset URLs | |
| # drop that segment and 404 — breaking all styles/JS on the preview. | |
| SANDBOX_BASE_PATH: ${{ steps.urls.outputs.sandbox_base_path }} | |
| SANDBOX_TEMPLATE_ASSETS_BASE_PATH: ${{ steps.urls.outputs.template_assets_base_path }} | |
| NODE_OPTIONS: '--max-old-space-size=8192' | |
| - name: Remove shared assets from preview artifact | |
| run: rm -rf apps/sandbox/out/template-assets | |
| - name: Upload Sandbox artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: sandbox-${{ steps.urls.outputs.short_hash }} | |
| path: apps/sandbox/out/ | |
| retention-days: 30 | |
| # Join gate that keeps the historical `build` check name: anything keyed on | |
| # "build" being green (branch protection, tooling, habit) keeps working, and | |
| # it fails when either parallel build fails. build-sandbox is legitimately | |
| # skipped on docsite-only PRs; a skipped build-storybook (merge_group, or a | |
| # failed check-scope) skips this gate too — both match the old single-job | |
| # behavior. | |
| build: | |
| needs: [build-storybook, build-sandbox] | |
| if: ${{ !cancelled() && needs.build-storybook.result != 'skipped' }} | |
| runs-on: ubuntu-slim | |
| permissions: {} | |
| steps: | |
| - name: Assert parallel builds succeeded | |
| run: | | |
| echo "build-storybook: ${{ needs.build-storybook.result }}" | |
| echo "build-sandbox: ${{ needs.build-sandbox.result }}" | |
| [ "${{ needs.build-storybook.result }}" = "success" ] || exit 1 | |
| case "${{ needs.build-sandbox.result }}" in | |
| success|skipped) ;; | |
| *) exit 1 ;; | |
| esac | |
| # NOTE: PR preview deploy has moved to deploy-preview.yml (workflow_run). | |
| # It used to live here as a `deploy-preview` job, but a pull_request job on a | |
| # fork gets a read-only GITHUB_TOKEN and can't push to gh-pages — so previews | |
| # only ever appeared on same-repo PRs. The trusted workflow_run companion runs | |
| # in the base-repo context with a write token, downloads the static artifacts | |
| # this workflow already uploads (storybook-<hash>, sandbox-<hash>), and deploys | |
| # for EVERY PR (fork and same-repo alike) without executing any PR code. | |
| # Accessibility audit | |
| # Skipped when no components changed. Depends only on build-storybook, so it | |
| # starts as soon as the storybook artifact is up (~2min) instead of waiting | |
| # for the slower sandbox build. | |
| pr-a11y: | |
| needs: [build-storybook, check-components] | |
| if: github.event_name == 'pull_request' && needs.check-components.outputs.has_components == 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| # Dependencies are installed later, together with the Playwright browser, | |
| # because the analysis artifact has to be downloaded first. | |
| - name: Setup Node and pnpm | |
| uses: ./.github/actions/setup | |
| with: | |
| install: 'false' | |
| - name: Download analysis artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pr-analysis | |
| - name: Download Storybook artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: storybook-${{ needs.build-storybook.outputs.short_hash }} | |
| path: apps/storybook/dist | |
| - name: Install Playwright | |
| run: pnpm install --frozen-lockfile && npx playwright install chromium | |
| # Fails on axe violations not present in the checked-in baseline | |
| # (.github/a11y-baseline.json). See CONTRIBUTING.md "Accessibility | |
| # audits" for the baseline workflow. | |
| - name: Run accessibility audit | |
| run: | | |
| COMPONENTS=$(jq -r '((.newComponentOwners // .newComponents // []) + (.modifiedComponentOwners // .modifiedComponents // [])) | join(",")' analysis.json) | |
| ANALYSIS_FORCE_FULL=$(jq -r '.forceFullComponentAudits // false' analysis.json) | |
| SCOPE_ARGS=() | |
| if [ "${{ needs.check-components.outputs.force_full_component_audits }}" != "true" ] && [ "$ANALYSIS_FORCE_FULL" != "true" ] && [ -n "$COMPONENTS" ]; then | |
| SCOPE_ARGS=(--components "$COMPONENTS") | |
| else | |
| echo "Component scope is policy-sensitive or unresolved — running the full accessibility audit." | |
| fi | |
| node .github/scripts/accessibility-audit.js \ | |
| --storybook-dir apps/storybook/dist \ | |
| --output a11y-report.json \ | |
| "${SCOPE_ARGS[@]}" \ | |
| --baseline .github/a11y-baseline.json \ | |
| --fail-on-new | |
| # if: always() so the report artifact (and the PR comment built from it) | |
| # still exists when the gate above fails. | |
| - name: Upload a11y artifact | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: a11y-report | |
| path: a11y-report.json | |
| retention-days: 1 | |
| # A modal <dialog> hidden before close() blocks the whole page in Safari | |
| # 26.1 and leaves no trace (#4290). The ordering is produced by a CSS | |
| # transition, which jsdom does not run — so the unit suite cannot see it | |
| # and this Chromium probe is the guard. Runs after the upload above so an | |
| # a11y failure still publishes its report. | |
| - name: Run modal close visibility guard | |
| run: node .github/scripts/modal-close-visibility.js --storybook-dir apps/storybook/dist | |
| # A public component var can be documented, read, and emitted into the | |
| # theme CSS and still be unreachable — an inline write or an unlayered | |
| # declaration outranks `@layer astryx-theme` (#4530). jsdom resolves no | |
| # cascade, so the unit suites assert on the generated CSS text, which | |
| # proves emission and not effect. Chromium is where the promise is | |
| # checkable. The source-side wiring half lives in | |
| # packages/cli/foundation/discovery/theming-targets.test.mjs. | |
| - name: Run theme var reachability guard | |
| run: node .github/scripts/theme-var-reachability.js --storybook-dir apps/storybook/dist | |
| # A story play function is where geometry assertions live (they need | |
| # getBoundingClientRect, so jsdom is out), but nothing else observes the | |
| # result: Vitest never collects stories, and pr-visual loads stories | |
| # without awaiting `play` — and is soft while it earns trust. This guard | |
| # loads each listed story in Chromium and fails when its play assertions | |
| # throw. Hosted here with the sibling Chromium guards because pr-a11y is | |
| # the blocking job that runs whenever components change. | |
| - name: Run story play guard | |
| run: node .github/scripts/story-play-guard.js --storybook-dir apps/storybook/dist | |
| # The reusable accessibility spec-test contracts (internal/a11y-spec) and | |
| # the component bindings that adopt them. jsdom cannot compute a role or | |
| # an accessible name, resolve a real tab sequence, or turn a key press | |
| # into an activation, so those expectations are reported `unrun` in the | |
| # unit lane and proven here or not at all. Hosted with the sibling | |
| # Chromium guards for the same reason they are: pr-a11y is the blocking | |
| # job that runs whenever components change. | |
| - name: Run accessibility spec-test contracts | |
| run: pnpm test:a11y-contract | |
| # Build package test owner. The plugin's production CSS partition is a Build | |
| # integration contract: source inspection cannot prove which declaration | |
| # paints after Vite emits the final bundle. The `test-build` name makes that | |
| # package ownership explicit; the historical `test` join above projects its | |
| # result. Until runtime:build has a specialized route, broad CI dispatches it. | |
| test-build: | |
| needs: [check-scope] | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup | |
| - name: Install Playwright | |
| run: npx playwright install chromium | |
| # The guard imports the plugin's build artifact, the same one a consumer | |
| # installs. | |
| - name: Build the Vite plugin | |
| run: pnpm -F @astryxdesign/build build | |
| - name: Run Build CSS-layer cascade guard | |
| run: node .github/scripts/theme-layer-cascade.js | |
| # Guest token boundaries cross portals and the host light/dark cascade. This | |
| # requires a production browser render; source and jsdom checks cannot prove | |
| # which custom properties paint the portaled surfaces. | |
| fixture-contrast: | |
| needs: [check-scope] | |
| if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup | |
| - name: Install Playwright | |
| run: npx playwright install chromium | |
| - name: Verify canonical setup compositions | |
| env: | |
| ASTRYX_CANONICAL_SETUP_BROWSER: '1' | |
| run: pnpm vitest run internal/vibe-tests/setup-test/setup-canonical.test.ts --project node --maxWorkers=1 | |
| - name: Verify guest fixture contrast | |
| run: pnpm -F @astryxdesign/vibe-tests fixtures:contrast | |
| # Stable-package visual regression. Core component changes compare only | |
| # representative or explicitly tagged story keys already present in the | |
| # accepted baseline. A changed shipped theme gets its relevant matrix, but PR | |
| # acceptance cannot add or remove baseline keys. Packages marked | |
| # `astryx.canaryOnly` (Lab, richtext, vega) do not compare pixels and do not | |
| # own a baseline. | |
| # | |
| # Deeper than the daily gate only where the component declares it: audit and | |
| # interaction fixtures stay available to their dedicated checks without | |
| # multiplying the pixel baseline. | |
| # | |
| # SKIPS ITSELF WHEN COMPONENT OR SHARED-INFRASTRUCTURE SCOPE IS TOO BROAD. | |
| # Theme-only matrices are release evidence and are not charged against the | |
| # focused component review ceiling. A token or shared-hook change puts | |
| # hundreds of diffs in front of a reviewer who cannot judge them | |
| # one by one, and a per-PR check is the wrong instrument for that change — | |
| # the daily gate reviews it against the whole baseline instead. The skip | |
| # states its reason in the PR comment rather than vanishing. | |
| # | |
| # SOFT / NON-BLOCKING (continue-on-error) while the suite earns trust: the | |
| # verdict surfaces in the job summary and the PR comment, and the release | |
| # gate is the blocking one. To promote: drop continue-on-error. | |
| pr-visual: | |
| name: Stable visual regression | |
| needs: [build-storybook, check-components] | |
| if: github.event_name == 'pull_request' && needs.check-components.outputs.has_stable_visual == 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| continue-on-error: true | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node and pnpm | |
| uses: ./.github/actions/setup | |
| with: | |
| install: 'false' | |
| - name: Download analysis artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pr-analysis | |
| - name: Download Storybook artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: storybook-${{ needs.build-storybook.outputs.short_hash }} | |
| path: apps/storybook/dist | |
| # Non-fatal: if this artifact is ever missing, renamed, or produced under | |
| # a different key, the gate builds the themes itself. A crash here would | |
| # be before the gate ever runs, which is how an artifact-wiring change | |
| # reddened every open component PR twice in one day. | |
| - name: Download built themes and core | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: dists-${{ needs.build-storybook.outputs.short_hash }} | |
| path: packages | |
| - name: Install Playwright | |
| run: pnpm install --frozen-lockfile && npx playwright install chromium | |
| # The downloaded theme builds import Core's consumer-facing dist files. | |
| # Rebuild only Core here so those imports resolve without repeating the | |
| # full workspace build that produced the theme artifact. | |
| - name: Build Core dependency for theme inspection | |
| run: pnpm -F @astryxdesign/core build | |
| - name: Fetch visual baseline from gh-pages | |
| run: | | |
| set -eu | |
| rm -rf /tmp/gh-pages | |
| git clone --depth=1 --filter=blob:none --sparse --single-branch --branch gh-pages \ | |
| "https://github.com/${GITHUB_REPOSITORY}.git" /tmp/gh-pages | |
| git -C /tmp/gh-pages sparse-checkout set visual-gate/baseline | |
| mkdir -p .visual-baseline | |
| if [ -d /tmp/gh-pages/visual-gate/baseline ]; then | |
| cp -r /tmp/gh-pages/visual-gate/baseline/. .visual-baseline/ | |
| fi | |
| - name: Run the visual gate for the touched components | |
| id: visual | |
| env: | |
| ASTRYX_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| ASTRYX_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| set -eu | |
| # Component names are not globally unique, so filter by the package | |
| # recorded in componentStats — never infer stability from a name. | |
| COMPONENTS=$(jq -r '. as $root | [(.newComponents + .modifiedComponents)[] | select($root.componentStats[.].package == "@astryxdesign/core")] | unique | join(",")' analysis.json) | |
| THEMES=$(jq -r '(.changedStableThemes // []) | join(",")' analysis.json) | |
| if [ -n "$COMPONENTS" ] && [ -n "$THEMES" ]; then | |
| TIERS="component,theme-matrix" | |
| SCOPE_ARGS=(--components "$COMPONENTS" --themes "$THEMES") | |
| elif [ -n "$COMPONENTS" ]; then | |
| TIERS="component" | |
| SCOPE_ARGS=(--components "$COMPONENTS" --no-scout) | |
| elif [ -n "$THEMES" ]; then | |
| TIERS="theme-matrix" | |
| SCOPE_ARGS=(--themes "$THEMES") | |
| else | |
| # Shared Core theming/tokens/hooks are stable but broad. Let the | |
| # gate plan the full stable surface and decline visibly at the shot | |
| # budget; the daily stable gate owns the exhaustive comparison. | |
| TIERS="surface,theme-matrix,probe" | |
| SCOPE_ARGS=() | |
| fi | |
| echo "Stable Core components: ${COMPONENTS:-none}" | |
| echo "Stable themes: ${THEMES:-none}" | |
| set +e | |
| node .github/scripts/visual-gate/gate.mjs check \ | |
| --storybook-dir apps/storybook/dist \ | |
| --baseline .visual-baseline \ | |
| --out .visual-run \ | |
| --tiers "$TIERS" \ | |
| "${SCOPE_ARGS[@]}" \ | |
| --summary-output visual-summary.md | |
| code=$? | |
| [ -f visual-summary.md ] && cat visual-summary.md >> "$GITHUB_STEP_SUMMARY" | |
| # Exit 2 is "changed", which is a question for the reviewer, not a | |
| # broken job. Only a crash (1) is a failure. | |
| [ "$code" -eq 1 ] && exit 1 | |
| exit 0 | |
| - name: Upload visual verdict and report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: visual-pr-report | |
| path: | | |
| .visual-run/verdict.json | |
| .visual-run/manifest.json | |
| .visual-run/shots/ | |
| .visual-run/report/ | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| # RTL semantic audit. Sibling to pr-a11y: same build-storybook artifact, same | |
| # analysis.json scoping, so ordinary PRs only pay for the components they | |
| # touched. A harness-only PR runs the fixed Chart/ChartLegend smoke scope so | |
| # routing and target changes prove themselves instead of skipping their own | |
| # check. A policy-sensitive full run spans ~2,000 stories and exceeded the | |
| # single-job budget, so CI partitions it by canonical package; each shard runs | |
| # four browser workers, and the stable pr-rtl join requires all five scope | |
| # manifests plus a report from every applicable package. | |
| # The scheduled full sweep also runs in rtl-weekly.yml. | |
| # | |
| # Three auto dimensions: D1 icon-mirror, D5 positional-mirror, and D6 | |
| # contextual directional decorations. Curated D2/D3/D4 adds behavior that | |
| # needs selectors. The applicability rollup treats unexplained all-N/A as a | |
| # coverage gap for both new and existing components. | |
| # | |
| # Audit findings remain SOFT while the stability window is observed: the audit | |
| # step may fail after writing a report. Missing/canceled/incomplete shards are | |
| # HARD failures, and the pr-rtl join cannot pass without every package scope | |
| # manifest and every applicable report. | |
| # To promote findings too, remove the audit step's continue-on-error. | |
| pr-rtl-shard: | |
| name: pr-rtl (${{ matrix.package }}) | |
| needs: [build-storybook, check-components] | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| (needs.check-components.outputs.has_rtl_components == 'true' || | |
| needs.check-components.outputs.has_rtl_harness == 'true' || | |
| needs.check-components.outputs.force_full_component_audits == 'true') | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| package: [core, lab, charts, richtext, vega] | |
| runs-on: 4-core-ubuntu | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node and pnpm | |
| uses: ./.github/actions/setup | |
| with: | |
| install: 'false' | |
| - name: Download analysis artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pr-analysis | |
| - name: Download Storybook artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: storybook-${{ needs.build-storybook.outputs.short_hash }} | |
| path: apps/storybook/dist | |
| - name: Install Playwright | |
| run: pnpm install --frozen-lockfile && npx playwright install chromium | |
| # A registry/classifier mutation runs the full audit from the PR checkout, | |
| # while eligibility itself came from trusted base policy. Scope resolution | |
| # is strict: malformed or missing analysis fails before the soft findings | |
| # step can run. An ordinary harness-only change uses the bounded routing | |
| # smoke scope. | |
| - name: Resolve RTL shard scope | |
| id: rtl-scope | |
| run: | | |
| node .github/scripts/rtl-shard-scope.mjs \ | |
| --analysis analysis.json \ | |
| --package "${{ matrix.package }}" \ | |
| --force-full "${{ needs.check-components.outputs.force_full_component_audits }}" \ | |
| --has-components "${{ needs.check-components.outputs.has_rtl_components }}" \ | |
| --has-harness "${{ needs.check-components.outputs.has_rtl_harness }}" \ | |
| --manifest rtl-shard-scope.json \ | |
| --github-output "$GITHUB_OUTPUT" | |
| - name: Run RTL audit | |
| id: rtl | |
| if: steps.rtl-scope.outputs.should_run == 'true' | |
| continue-on-error: true | |
| run: | | |
| PACKAGE=${{ matrix.package }} | |
| COMPONENTS="${{ steps.rtl-scope.outputs.components }}" | |
| FILTER="${{ steps.rtl-scope.outputs.filter }}" | |
| FILTER_ARGS=() | |
| if [ -n "$FILTER" ]; then | |
| FILTER_ARGS=(--filter "$FILTER") | |
| fi | |
| echo "Auditing $PACKAGE components: $COMPONENTS" | |
| node apps/storybook/rtl-audit/rtl-audit.mjs \ | |
| --storybook-dir apps/storybook/dist \ | |
| --output rtl-audit-report.json \ | |
| --packages "$PACKAGE" \ | |
| --concurrency 4 \ | |
| "${FILTER_ARGS[@]}" | |
| - name: Require completed RTL shard report | |
| if: always() | |
| run: | | |
| SCOPE_OUTCOME="${{ steps.rtl-scope.outcome }}" | |
| SHOULD_RUN="${{ steps.rtl-scope.outputs.should_run }}" | |
| AUDIT_OUTCOME="${{ steps.rtl.outcome }}" | |
| if [ "$SCOPE_OUTCOME" != "success" ]; then | |
| echo "RTL shard scope did not resolve successfully: $SCOPE_OUTCOME" | |
| exit 1 | |
| fi | |
| if [ "$SHOULD_RUN" = "true" ]; then | |
| if [ "$AUDIT_OUTCOME" != "success" ] && [ "$AUDIT_OUTCOME" != "failure" ]; then | |
| echo "Applicable RTL audit did not run: $AUDIT_OUTCOME" | |
| exit 1 | |
| fi | |
| node .github/scripts/rtl-report-completion.mjs \ | |
| --report rtl-audit-report.json \ | |
| --package "${{ matrix.package }}" \ | |
| --filter "${{ steps.rtl-scope.outputs.filter }}" | |
| elif [ "$SHOULD_RUN" = "false" ]; then | |
| if [ "$AUDIT_OUTCOME" != "skipped" ]; then | |
| echo "Out-of-scope RTL audit unexpectedly ran: $AUDIT_OUTCOME" | |
| exit 1 | |
| fi | |
| else | |
| echo "RTL shard scope produced no explicit should_run decision." | |
| exit 1 | |
| fi | |
| - name: Write scorecard summary | |
| if: always() && steps.rtl-scope.outputs.should_run == 'true' | |
| run: | | |
| { | |
| echo "## RTL semantic audit" | |
| echo "" | |
| echo "Audit scope: **${{ steps.rtl-scope.outputs.components }}**. The scheduled unfiltered sweep also runs weekly — see \`rtl-weekly.yml\`." | |
| echo "" | |
| echo "Relationship-based (LTR vs RTL in the same run) — no golden screenshots. **Soft-gated** pending a stability window." | |
| echo "" | |
| if [ -f rtl-audit-report.json ]; then | |
| node -e ' | |
| const r = JSON.parse(require("fs").readFileSync("rtl-audit-report.json")); | |
| const a = r.autoDiscovery; | |
| const known = new Set(r.knownNotRtl || []); | |
| const out = []; | |
| out.push("### Auto-discovery (D1 icon-mirror, one representative story per scoped component)"); | |
| out.push(""); | |
| out.push(`**${a.pass} pass · ${a.fail} not-RTL · ${a.na} N-A** (of ${a.total} scoped components). Surprises (not pre-labelled): **${a.surprises.length}**${a.surprises.length ? ": " + a.surprises.join(", ") : ""}.`); | |
| out.push(""); | |
| const fails = a.results.filter(x => x.verdict === "fail" || x.verdict === "ERROR"); | |
| if (fails.length) { | |
| out.push("| Component | D1 | Label | Detail |"); | |
| out.push("|-----------|----|-------|--------|"); | |
| for (const c of fails) { | |
| const label = known.has(c.component) ? "known-not-rtl" : "⚠️ surprise"; | |
| out.push(`| ${c.component} | ${c.verdict} | ${label} | ${(c.notes||[]).join("; ")} |`); | |
| } | |
| } else { | |
| out.push("_No not-RTL findings._"); | |
| } | |
| out.push(""); | |
| const passes = a.results.filter(x => x.verdict === "pass").map(x => x.component); | |
| out.push(`RTL-ready (D1 pass): ${passes.join(", ") || "—"}.`); | |
| out.push(""); | |
| const pm = r.positionalMirror; | |
| if (pm) { | |
| out.push("### Positional-mirror (D5, all scoped stories)"); | |
| out.push(""); | |
| out.push("Catches a logical anchor (insetInlineStart/End) paired with an UNFLIPPED physical transform (translate/translateX) → element lands on the wrong side in RTL. Lint can\u2019t see this (each prop is individually fine). Tolerance " + pm.tolerancePx + "px, degenerate-parent guard (<8px) applied."); | |
| out.push(""); | |
| out.push(`**${pm.pass} pass · ${pm.fail} FAIL · ${pm.na} N-A** (of ${pm.total} scoped stories scanned).`); | |
| out.push(""); | |
| const pmFails = pm.results.filter(x => x.verdict === "fail" || x.verdict === "ERROR"); | |
| if (pmFails.length) { | |
| out.push("| Story | Element | LTR relCenterX | RTL relCenterX | \u0394 (px) |"); | |
| out.push("|-------|---------|----------------|----------------|--------|"); | |
| for (const c of pmFails) { | |
| for (const f of (c.fails || [])) { | |
| out.push(`| ${c.storyId} | \`${f.cls || f.tag}\` | ${f.ltrRelCenterX} | ${f.rtlRelCenterX} (exp ~${f.expectedRtlCenterX}) | ${f.delta} |`); | |
| } | |
| if (!(c.fails || []).length) out.push(`| ${c.storyId} | (error) | — | — | ${(c.notes||[]).join("; ")} |`); | |
| } | |
| out.push(""); | |
| } else { | |
| out.push("_No positional-mirror FAILs._"); | |
| out.push(""); | |
| } | |
| } | |
| const decorations = r.directionalDecorations; | |
| if (decorations) { | |
| out.push("### Contextual directional decorations (D6, all stories)"); | |
| out.push(""); | |
| out.push(`**${decorations.pass} pass · ${decorations.fail} FAIL · ${decorations.na} N-A** (of ${decorations.total} stories scanned).`); | |
| out.push(""); | |
| const decorationFails = decorations.results.filter(x => x.verdict === "fail" || x.verdict === "ERROR"); | |
| if (decorationFails.length) { | |
| out.push("| Story | D6 | Detail |"); | |
| out.push("|-------|----|--------|"); | |
| for (const c of decorationFails) out.push(`| ${c.storyId} | ${c.verdict} | ${(c.notes||[]).join("; ")} |`); | |
| } else { | |
| out.push("_No directional-decoration FAILs._"); | |
| } | |
| out.push(""); | |
| } | |
| const coverage = r.coverage; | |
| if (coverage) { | |
| out.push("### Applicability coverage"); | |
| out.push(""); | |
| out.push(`**${coverage.measured} measured · ${coverage.verifiedNa} verified N-A · ${coverage.gaps} coverage gap · ${coverage.staleVerifiedNa} stale verified N-A.**`); | |
| out.push(""); | |
| const gaps = coverage.results.filter(x => x.status === "coverage-gap" || x.status === "stale-verified-na"); | |
| if (gaps.length) { | |
| out.push("| Component | Status | Detail |"); | |
| out.push("|-----------|--------|--------|"); | |
| for (const c of gaps) out.push(`| ${c.component} | ${c.status} | ${c.note || c.reason || "unexplained all-N/A result"} |`); | |
| out.push(""); | |
| } | |
| } | |
| out.push("### Curated precision (D2 order / D3 behavior / D4 overlay)"); | |
| out.push(""); | |
| out.push("| Component | Rollup | Dimensions |"); | |
| out.push("|-----------|--------|------------|"); | |
| for (const c of r.curated.results) { | |
| const dims = Object.entries(c.dims).map(([k,v]) => k+":"+v).join(", ") || "—"; | |
| out.push(`| ${c.component} | ${c.rollup} | ${dims} |`); | |
| } | |
| console.log(out.join("\n")); | |
| ' | |
| else | |
| echo "_No report produced — audit errored before writing output._" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload RTL shard evidence | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: rtl-audit-report-${{ matrix.package }} | |
| path: | | |
| rtl-shard-scope.json | |
| rtl-audit-report.json | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # Stable required context joining the five package shards above. | |
| pr-rtl: | |
| needs: [check-components, pr-rtl-shard] | |
| if: ${{ always() && !cancelled() && github.event_name == 'pull_request' }} | |
| runs-on: ubuntu-slim | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Download applicable RTL shard reports | |
| if: ${{ needs.check-components.result == 'success' && (needs.check-components.outputs.has_rtl_components == 'true' || needs.check-components.outputs.has_rtl_harness == 'true' || needs.check-components.outputs.force_full_component_audits == 'true') }} | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: rtl-audit-report-* | |
| path: rtl-shard-reports | |
| - name: Require every applicable RTL shard | |
| if: always() | |
| run: | | |
| node .github/scripts/rtl-join.mjs \ | |
| --check-components "${{ needs.check-components.result }}" \ | |
| --shards "${{ needs.pr-rtl-shard.result }}" \ | |
| --should-run "${{ needs.check-components.outputs.has_rtl_components == 'true' || needs.check-components.outputs.has_rtl_harness == 'true' || needs.check-components.outputs.force_full_component_audits == 'true' }}" \ | |
| --force-full "${{ needs.check-components.outputs.force_full_component_audits }}" \ | |
| --reports-dir rtl-shard-reports |