Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .github/workflows/icon-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
name: '[CI] Icon Parity'

# Renders every icon component in Chromium and compares it to Figma's own PNG.
# Gate values (64px canvas, pixelmatch threshold 0.3 / includeAA, fail above 2 diff px)
# are measured, not guessed — each value carries its measurement in the comments of
# scripts/icon-extractor/src/parity/lib.ts.
#
# Two entry points share this one job so the gate and the report live in one place:
# - pull_request: any change that can alter how an icon rasterizes
# - workflow_call: the weekly Figma sync, on the PR it just opened

on:
workflow_dispatch:
workflow_call:
inputs:
pr-number:
description: 'PR to comment the report on. Empty means report to the log only.'
type: string
required: false
changed-icons:
description: 'Comma-separated icons to draw on the report even when they pass.'
type: string
required: false
pull_request:
paths:
- packages/icons/src/**
- scripts/icon-extractor/src/parity/**
- scripts/icon-extractor/src/transformer/**
- scripts/icon-extractor/src/config.ts
- scripts/icon-extractor/icon-extractor.config.json
- .github/workflows/icon-parity.yml

# This block replaces the default permissions rather than adding to them, so `contents` and
# `pull-requests` have to stay listed alongside the OIDC `id-token` the S3 upload needs.
permissions:
contents: read
pull-requests: write
id-token: write

concurrency:
group: icon-parity-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
parity:
name: Icon Parity
runs-on: ubuntu-latest
# Forks get no FIGMA_TOKEN, so there is no baseline to compare against.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
CACHE: scripts/icon-extractor/src/parity/.cache
steps:
- name: Checkout branch
uses: actions/checkout@v4

- name: Install
uses: ./.github/composite/install

- name: Build icons
run: pnpm --filter @vapor-ui/icons build

- name: Install Chromium
run: pnpm --filter @repo/icon-extractor exec playwright install --with-deps chromium

# Colour icons are fetched and reported but not gated: Figma and Chromium antialias
# them differently enough (worst 164 px) to drown any real signal.
- name: Compare against Figma
id: compare
continue-on-error: true
env:
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
# Passed through env, never interpolated into the script: `${{ }}` is a text
# substitution the runner performs before bash parses the file, so a value
# holding `$(...)` would run as code even inside double quotes.
CHANGED_ICONS: ${{ inputs.changed-icons }}
run: |
pnpm --filter @repo/icon-extractor parity:fetch --type=basic
pnpm --filter @repo/icon-extractor parity:fetch --type=symbol
pnpm --filter @repo/icon-extractor parity:render
# --show adds rows to the page; it does not narrow the gate. Empty on the
# pull_request path, where the diff itself already says what changed.
pnpm --filter @repo/icon-extractor parity:compare \
--show="$CHANGED_ICONS"

# compare.ts already computed these, so read them back instead of re-deriving them
# from the rendered report.
- name: Read counts
id: counts
if: always()
run: |
if [[ ! -f "$CACHE/report.json" ]]; then
# rendered must be set here too: an unset output is '', and '' != '0'
# would send the upload after a page that was never written.
printf 'ran=false\nrendered=0\n' >> "$GITHUB_OUTPUT"
echo 'No parity report — the comparison never ran.' >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{
echo 'ran=true'
jq -r '"total=\(.total)",
"failed=\(.failed)",
"mono=\(.mono.count)",
"mono_worst=\(.mono.worst)",
"rendered=\(.rendered)"' "$CACHE/report.json"
} >> "$GITHUB_OUTPUT"

# A green run draws no rows, so there is no page worth an assume-role and an upload.
- name: Configure AWS credentials
if: steps.counts.outputs.rendered != '0'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_IAM_ROLE_NAME }}
role-session-name: ${{ secrets.AWS_IAM_ROLE_SESSION_NAME }}
aws-region: ${{ secrets.AWS_REGION }}

# report.html inlines the Figma / code / diff PNG of every failing icon, so one object
# is the whole report. `index.html` is the bucket website's default document.
- name: Upload report to S3
id: s3
if: steps.counts.outputs.rendered != '0'
env:
# A branch name may contain `$`, `(` and backticks, so interpolating it into
# the script would hand this step — which holds the AWS session — arbitrary code.
BRANCH_REF: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH=$(echo "$BRANCH_REF" | tr '/' '-')
# Keyed on the run, not the branch alone: a branch-only key means a second run
# today overwrites the page that an earlier PR comment still links to, so that
# comment quietly starts showing a different run's result. `run_id` is unique
# per run and already monotonic, so it sorts chronologically for free.
KEY="icon-parity/$BRANCH/${{ github.run_id }}/index.html"
aws s3 cp "$CACHE/report.html" "s3://${{ secrets.BUCKET_NAME }}/$KEY"
URL="http://${{ secrets.BUCKET_NAME }}/$KEY"
echo "url=$URL" >> "$GITHUB_OUTPUT"
# A dispatch run has no PR to comment on, so the link has to land somewhere.
echo "[Icon parity report]($URL)" >> "$GITHUB_STEP_SUMMARY"

- name: Comment on PR
if: steps.counts.outputs.ran == 'true' && (inputs.pr-number || github.event.pull_request.number)
uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ inputs.pr-number || github.event.pull_request.number }}
header: icon-parity
message: |
${{ steps.compare.outcome == 'failure' && '🚫 **아이콘 시각 검증 실패**' || '✅ **아이콘 시각 검증 통과**' }}

| 전체 | 게이트 대상 (mono) | 실패 | mono 최대 diff | 리포트 |
| :--- | :--- | :--- | :--- | :--- |
| ${{ steps.counts.outputs.total }} | ${{ steps.counts.outputs.mono }} | ${{ steps.counts.outputs.failed }} | ${{ steps.counts.outputs.mono_worst }} px | ${{ steps.counts.outputs.rendered != '0' && format('[열기 ↗︎]({0})', steps.s3.outputs.url) || '—' }} |

[워크플로 실행 보기](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
# Same author as the visual-regression comment — both land on the same PR, and
# the repo already reserves the default token for machine work rather than
# anything a reviewer reads.
GITHUB_TOKEN: ${{ secrets.VAPOR_BOT_TOKEN }}

- name: Fail if parity failed
if: steps.compare.outcome == 'failure'
run: exit 1
73 changes: 68 additions & 5 deletions .github/workflows/sync-figma-icons.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ on:
# Run every Thursday at 4 PM KST (07:00 UTC)
- cron: '0 7 * * 4'

# The called parity workflow uploads to S3 over OIDC, and a called workflow cannot be granted
# more than its caller holds — `id-token` is never in the default token, so it has to be asked
# for here. Opening this block replaces the defaults, so the write scopes this job's own
# commit / PR steps rely on have to be listed too.
permissions:
contents: write
pull-requests: write
id-token: write

concurrency:
# Fixed group, not keyed on github.ref: every run targets the same BRANCH_NAME
# regardless of the ref it was dispatched from, so runs must serialize.
Expand All @@ -23,6 +32,10 @@ jobs:
# The unspecified default is `bash -e`, which has no `pipefail`: a failing command
# on the left of a pipe would be reported as success.
shell: bash
outputs:
has_changes: ${{ steps.pr.outputs.has_changes }}
pr_number: ${{ steps.pr.outputs.number }}
changed_icons: ${{ steps.changed.outputs.icons }}
steps:
- name: Checkout branch
uses: actions/checkout@v4
Expand All @@ -45,12 +58,25 @@ jobs:
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
run: pnpm --filter @repo/icon-extractor sync-icons:symbol

# New and updated icons get drawn on the parity report so the sync PR shows them next
# to Figma. Deleted ones are left out — there is no component left to render.
- name: List changed icons
id: changed
run: |
ICONS=$(jq -rs '[.[] | .created[], .updated[]] | join(",")' \
.sync-summary/basic.json .sync-summary/symbol.json)
echo "icons=$ICONS" >> "$GITHUB_OUTPUT"

# Reads what both syncs recorded and writes `.changeset/sync-icons-*.md` plus
# `pr_body.md`. Untracked until the next step decides there is something to commit.
- name: Write changeset and PR body
run: pnpm --filter @repo/icon-extractor write-release-notes

- name: Commit and open pull request
# The `pr_number` job output reads this id. A missing step id is not an error in
# Actions — the expression just evaluates to empty — so losing it silently stops
# the parity report from ever reaching the sync PR.
id: pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand All @@ -64,6 +90,9 @@ jobs:
exit 0
fi

# The parity job gates on this.
echo "has_changes=true" >> "$GITHUB_OUTPUT"

git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git switch -C "$BRANCH_NAME"
Expand All @@ -85,18 +114,52 @@ jobs:
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty')
if [[ -n "$EXISTING_PR" ]]; then
gh pr edit "$EXISTING_PR" --body-file pr_body.md
PR_NUMBER="$EXISTING_PR"
else
gh pr create \
# `gh pr create` prints the new PR's URL, whose last segment is its number.
PR_URL=$(gh pr create \
--title "feat: sync icons from Figma" \
--body-file pr_body.md \
--base main \
--head "$BRANCH_NAME"
--head "$BRANCH_NAME")
PR_NUMBER="${PR_URL##*/}"
fi
# The parity job needs this to know where to comment.
echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"

# The gate, the report and the PR comment all live in icon-parity.yml so this entry point
# and the pull_request one can never drift apart.
#
# This call cannot be dropped in favour of that pull_request trigger: the sync PR is opened
# and pushed with GITHUB_TOKEN, and GitHub raises no workflow events for those, so the sync
# PR would otherwise never be checked. For the same reason there is no double run here.
#
# Manual dispatch always runs it, so the check can be exercised without a Figma diff.
parity:
name: Parity
needs: sync-figma-icons
if: needs.sync-figma-icons.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch'
uses: ./.github/workflows/icon-parity.yml
secrets: inherit
with:
pr-number: ${{ needs.sync-figma-icons.outputs.pr_number }}
changed-icons: ${{ needs.sync-figma-icons.outputs.changed_icons }}

notify:
name: Notify Slack
needs: [sync-figma-icons, parity]
# A skipped parity job is not a failure, so this only fires on a real one.
if: failure()
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v4

- name: Install
uses: ./.github/composite/install

- name: Notify Slack on Failure
if: failure()
run: |
WORKFLOW_STATUS=failure pnpm --filter @repo/icon-extractor notify:slack
run: WORKFLOW_STATUS=failure pnpm --filter @repo/icon-extractor notify:slack
env:
SLACK_GDS_ALARM_WEBHOOK_URL: ${{ secrets.SLACK_GDS_ALARM_WEBHOOK_URL }}
GITHUB_REPOSITORY: ${{ github.repository }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ docs/plans/
# Worktrees
.worktrees

# Icon parity check cache (baselines, renders, diffs)
scripts/icon-extractor/src/parity/.cache/

# Icon sync intermediates (see scripts/icon-extractor)
.sync-summary/
pr_body.md
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"source.fixAll.eslint": "explicit"
},
"js/ts.tsdk.path": "node_modules/typescript/lib",
"files.watcherExclude": {
"**/scripts/icon-extractor/src/parity/.cache/**": true
},
"json.schemas": [
{
"url": "https://cdn.jsdelivr.net/npm/tsup/schema.json",
Expand Down
45 changes: 45 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading