mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix: run fork visual reports from trusted code * fix: auto-approve strict web visual capture * fix: address visual report review feedback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: propagate visual report storage failures Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: validate PR screenshots before upload Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: validate visual PR identity before comment * fix: harden fork visual report validation Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: address remaining fork visual report review feedback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: handle stale fork visual report lookup Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: allow stale fork visual report fallback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
378 lines
18 KiB
YAML
378 lines
18 KiB
YAML
name: visual-pr-comment
|
|
|
|
on:
|
|
workflow_run:
|
|
workflows: [visual-pr-capture]
|
|
types: [completed]
|
|
workflow_dispatch:
|
|
inputs:
|
|
capture_run_id:
|
|
description: GitHub Actions run id for the Visual PR capture workflow to comment on.
|
|
required: true
|
|
type: string
|
|
pr_number:
|
|
description: Pull request number to upsert the visual comment on.
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
group: visual-pr-comment-${{ github.event.workflow_run.head_repository.full_name || github.repository }}-${{ github.event.workflow_run.head_branch || inputs.pr_number || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
comment:
|
|
name: Publish PR visual diff comment
|
|
# Fork PR capture artifacts are untrusted. Always validate the live PR state
|
|
# and only execute trusted base-repository code before publishing comments
|
|
# or reports.
|
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
|
|
steps:
|
|
- name: Checkout same-repository workflow head
|
|
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name == github.repository }}
|
|
uses: actions/checkout@v6.0.2
|
|
with:
|
|
fetch-depth: 0
|
|
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
|
ref: ${{ github.event.workflow_run.head_sha }}
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6.0.8
|
|
with:
|
|
version: 10.33.2
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6.4.0
|
|
with:
|
|
node-version: 24
|
|
package-manager-cache: false
|
|
|
|
- name: Download PR visual artifact
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
|
path: ${{ runner.temp }}/visual-artifact
|
|
merge-multiple: true
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Read capture manifest
|
|
id: manifest
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
|
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
|
|
WORKFLOW_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }}
|
|
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }}
|
|
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
artifact_dir="$RUNNER_TEMP/visual-artifact"
|
|
manifest="$artifact_dir/visual-report/manifest.json"
|
|
if [ ! -f "$manifest" ]; then
|
|
manifest="$artifact_dir/manifest.json"
|
|
fi
|
|
if [ ! -f "$manifest" ]; then
|
|
echo "Capture manifest not found" >&2
|
|
find "$artifact_dir" -maxdepth 4 -type f >&2
|
|
exit 1
|
|
fi
|
|
manifest_pr_number="$(jq -r '.pr_number' "$manifest")"
|
|
base_sha="$(jq -r '.base_sha' "$manifest")"
|
|
run_id="$(jq -r '.run_id' "$manifest")"
|
|
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
|
|
if ! [[ "$manifest_pr_number" =~ ^[0-9]+$ ]]; then
|
|
echo "Invalid manifest pr_number: $manifest_pr_number" >&2
|
|
exit 1
|
|
fi
|
|
if ! [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Invalid manifest base_sha: $base_sha" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$run_id" != "$WORKFLOW_RUN_ID" ]; then
|
|
echo "Artifact run_id ($run_id) does not match workflow_run id ($WORKFLOW_RUN_ID)." >&2
|
|
exit 1
|
|
fi
|
|
if ! [[ "$capture_outcome" =~ ^(success|failure|cancelled|skipped)$ ]]; then
|
|
echo "Invalid manifest capture_outcome: $capture_outcome" >&2
|
|
exit 1
|
|
fi
|
|
source_head_sha="$WORKFLOW_HEAD_SHA"
|
|
source_head_repository="$WORKFLOW_HEAD_REPOSITORY"
|
|
source_head_branch="$WORKFLOW_HEAD_BRANCH"
|
|
if [ -z "$source_head_sha" ] || [ -z "$source_head_repository" ] || [ -z "$source_head_branch" ]; then
|
|
run_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$WORKFLOW_RUN_ID")"
|
|
if [ -z "$source_head_sha" ]; then
|
|
source_head_sha="$(jq -r '.head_sha // empty' <<< "$run_json")"
|
|
fi
|
|
if [ -z "$source_head_repository" ]; then
|
|
source_head_repository="$(jq -r '.head_repository.full_name // empty' <<< "$run_json")"
|
|
fi
|
|
if [ -z "$source_head_branch" ]; then
|
|
source_head_branch="$(jq -r '.head_branch // empty' <<< "$run_json")"
|
|
fi
|
|
fi
|
|
manifest_head="$(jq -r '.head_sha' "$manifest")"
|
|
if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Invalid manifest head_sha: $manifest_head" >&2
|
|
exit 1
|
|
fi
|
|
if ! [[ "$source_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Unable to resolve trusted workflow_run head_sha for run $WORKFLOW_RUN_ID." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$manifest_head" != "$source_head_sha" ]; then
|
|
echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($source_head_sha)." >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$source_head_repository" ]; then
|
|
echo "Unable to resolve trusted workflow_run head repository for run $WORKFLOW_RUN_ID." >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$source_head_branch" ]; then
|
|
echo "Unable to resolve trusted workflow_run head branch for run $WORKFLOW_RUN_ID." >&2
|
|
exit 1
|
|
fi
|
|
pr_number="$WORKFLOW_PR_NUMBER"
|
|
if [ -z "$pr_number" ]; then
|
|
head_owner="${source_head_repository%%/*}"
|
|
if [ -z "$head_owner" ] || [ "$head_owner" = "$source_head_repository" ]; then
|
|
echo "Unable to resolve trusted workflow_run head owner from $source_head_repository." >&2
|
|
exit 1
|
|
fi
|
|
encoded_head="$(jq -rn --arg head "$head_owner:$source_head_branch" '$head|@uri')"
|
|
matching_pr_numbers="$(
|
|
gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$encoded_head&per_page=100" \
|
|
| jq -r --arg repo "$source_head_repository" \
|
|
'.[] | select((.head.repo.full_name // "") == $repo) | .number' \
|
|
| paste -sd ' ' -
|
|
)"
|
|
match_count="$(wc -w <<< "$matching_pr_numbers" | tr -d ' ')"
|
|
if [ "$match_count" -gt 1 ]; then
|
|
echo "Unable to resolve a unique open PR from trusted workflow metadata for $source_head_repository@$source_head_branch ($source_head_sha); found $match_count matches." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$match_count" -eq 1 ]; then
|
|
pr_number="$matching_pr_numbers"
|
|
else
|
|
pr_number="$manifest_pr_number"
|
|
fi
|
|
fi
|
|
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
|
echo "Unable to derive PR number from trusted workflow metadata for workflow head $source_head_sha." >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "$WORKFLOW_PR_NUMBER" ] && [ "$manifest_pr_number" != "$WORKFLOW_PR_NUMBER" ]; then
|
|
echo "Manifest pr_number ($manifest_pr_number) does not match workflow_run PR number ($WORKFLOW_PR_NUMBER)." >&2
|
|
exit 1
|
|
fi
|
|
{
|
|
echo "path=$manifest"
|
|
echo "pr_number=$pr_number"
|
|
echo "head_sha=$manifest_head"
|
|
echo "base_sha=$base_sha"
|
|
echo "run_id=$run_id"
|
|
echo "capture_outcome=$capture_outcome"
|
|
echo "source_head_repository=$source_head_repository"
|
|
echo "source_head_branch=$source_head_branch"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Validate live PR state for trusted checkout
|
|
id: trusted-pr
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
|
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
|
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
|
SOURCE_HEAD_REPOSITORY: ${{ steps.manifest.outputs.source_head_repository }}
|
|
SOURCE_HEAD_BRANCH: ${{ steps.manifest.outputs.source_head_branch }}
|
|
run: |
|
|
set -euo pipefail
|
|
skip_or_fail_stale() {
|
|
local message="$1"
|
|
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
echo "$message" >&2
|
|
exit 1
|
|
fi
|
|
echo "$message"
|
|
echo "stale=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
}
|
|
|
|
pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")"
|
|
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
|
pr_is_draft="$(jq -r '.draft' <<< "$pr_json")"
|
|
pr_is_cross_repository="$(jq -r '(.head.repo.full_name // "") != (.base.repo.full_name // "")' <<< "$pr_json")"
|
|
current_head="$(jq -r '.head.sha' <<< "$pr_json")"
|
|
current_base="$(jq -r '.base.sha' <<< "$pr_json")"
|
|
current_head_repository="$(jq -r '.head.repo.full_name // empty' <<< "$pr_json")"
|
|
current_head_branch="$(jq -r '.head.ref // empty' <<< "$pr_json")"
|
|
if [ "$pr_state" != "open" ]; then
|
|
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because state is $pr_state."
|
|
fi
|
|
if [ "$pr_is_draft" != "false" ]; then
|
|
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because it is draft."
|
|
fi
|
|
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
|
skip_or_fail_stale "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
|
fi
|
|
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
|
skip_or_fail_stale "Skipping stale visual artifact for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
|
fi
|
|
if [ "$current_head_repository" != "$SOURCE_HEAD_REPOSITORY" ]; then
|
|
echo "Artifact PR head repository ($current_head_repository) does not match trusted workflow_run head repository ($SOURCE_HEAD_REPOSITORY)." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$current_head_branch" != "$SOURCE_HEAD_BRANCH" ]; then
|
|
echo "Artifact PR head branch ($current_head_branch) does not match trusted workflow_run head branch ($SOURCE_HEAD_BRANCH)." >&2
|
|
exit 1
|
|
fi
|
|
{
|
|
echo "stale=false"
|
|
echo "base_sha=$current_base"
|
|
echo "is_cross_repository=$pr_is_cross_repository"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Checkout trusted base revision
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.head_repository.full_name != github.repository) }}
|
|
uses: actions/checkout@v6.0.2
|
|
with:
|
|
fetch-depth: 0
|
|
repository: ${{ github.repository }}
|
|
ref: ${{ steps.trusted-pr.outputs.base_sha }}
|
|
|
|
- name: Resolve pnpm store path
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
|
id: pnpm-store
|
|
shell: bash
|
|
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Restore pnpm store cache
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
|
uses: actions/cache/restore@v5.0.5
|
|
with:
|
|
path: ${{ steps.pnpm-store.outputs.path }}
|
|
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
|
|
- name: Install dependencies
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Stop stale visual runs
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
|
id: stale
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
|
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
|
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
|
|
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
|
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
|
|
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
|
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
|
if [ "$pr_state" != "OPEN" ]; then
|
|
echo "Skipping visual report for PR $PR_NUMBER because state is $pr_state."
|
|
echo "stale=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
if [ "$pr_is_draft" != "false" ]; then
|
|
echo "Skipping visual report for PR $PR_NUMBER because it is draft."
|
|
echo "stale=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
|
echo "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
|
echo "stale=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
|
echo "Skipping stale visual artifact for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
|
echo "stale=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
echo "stale=false" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Build visual diff report
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
|
env:
|
|
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
|
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
|
|
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
|
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
|
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
|
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
|
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
|
run: |
|
|
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
|
|
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
|
|
--run-id "${{ steps.manifest.outputs.run_id }}" \
|
|
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
|
|
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
|
|
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
|
|
--screenshots "$RUNNER_TEMP/visual-artifact/visual-screenshots" \
|
|
--comment-out ui/reports/visual-report/comment.md \
|
|
--manifest-out ui/reports/visual-report/report-manifest.json
|
|
|
|
- name: Upsert PR comment
|
|
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
|
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
|
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
|
|
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
|
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
|
|
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
|
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
|
if [ "$pr_state" != "OPEN" ]; then
|
|
echo "Skipping visual comment for PR $PR_NUMBER because state is $pr_state."
|
|
exit 0
|
|
fi
|
|
if [ "$pr_is_draft" != "false" ]; then
|
|
echo "Skipping visual comment for PR $PR_NUMBER because it is draft."
|
|
exit 0
|
|
fi
|
|
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
|
echo "Skipping stale visual comment for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
|
exit 0
|
|
fi
|
|
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
|
echo "Skipping stale visual comment for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
|
exit 0
|
|
fi
|
|
body_path="e2e/ui/reports/visual-report/comment.md"
|
|
comments_json="$RUNNER_TEMP/comments.json"
|
|
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
|
|
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
|
|
if [ -n "$comment_id" ]; then
|
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
|
|
else
|
|
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
|
|
fi
|
|
|
|
- name: Upload visual report artifact
|
|
if: ${{ always() && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }}
|
|
path: e2e/ui/reports/visual-report
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|