Skip to content

fix(core): apply one URL scheme rule wherever a link leaves React's hands #15227

fix(core): apply one URL scheme rule wherever a link leaves React's hands

fix(core): apply one URL scheme rule wherever a link leaves React's hands #15227

Workflow file for this run

# 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