chore(e2e): refresh screenshot baselines and drop unread snapshot files #909
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
| # ---------------------------------------------------------------------- | |
| # SECURITY INVARIANT — read before editing. | |
| # | |
| # Do NOT add any of these triggers without a strict author-association | |
| # gate at the very first job step: | |
| # issue_comment, pull_request_target, pull_request_review, | |
| # pull_request_review_comment, workflow_run | |
| # These triggers run with FULL repo secrets and a writable GITHUB_TOKEN | |
| # even when activity originates from a fork PR. Combined with checkout- | |
| # of-PR-head + execute-code-from-PR (which any build inherently does), | |
| # they enable Remote Code Execution by anyone who can comment on a PR | |
| # or open one. | |
| # | |
| # Background: the PR Preview design and a pre-rollout security review | |
| # explicitly forbid these triggers. See the design doc Security section. | |
| # ---------------------------------------------------------------------- | |
| name: PR Preview | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, closed] | |
| # Default for all jobs is no permissions; each job opts in to the | |
| # narrowest scope it needs below. | |
| permissions: {} | |
| # Single concurrency group across build and cleanup. When a PR closes | |
| # mid-build, the close-event cleanup job cancels the still-running | |
| # build (cancel-in-progress: true) so the build can't finish and | |
| # create an orphaned release after the cleanup has already deleted | |
| # whatever was there. The next build's "Delete existing preview | |
| # release" step is idempotent, so a cancelled cleanup leaves no | |
| # permanent half-state. | |
| concurrency: | |
| group: pr-preview-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| build-and-release: | |
| name: Build and Release PR Preview | |
| # Two-layer gate evaluated BEFORE any secret is injected: | |
| # 1. Skip on PR close (handled by cleanup-release job) | |
| # 2. Reject fork PRs (defense-in-depth — platform also withholds secrets/token) | |
| # NOTE: we don't gate on author_association because the field in the | |
| # webhook event payload only reflects PUBLIC org membership; SDF members | |
| # with private memberships show as CONTRIBUTOR, which would lock them | |
| # out. Non-SDF gating is handled by the org-level "Require approval | |
| # for outside collaborators" setting plus the platform-level fork-PR | |
| # secret-withholding. | |
| if: >- | |
| github.event.action != 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # release create/delete + tag operations | |
| pull-requests: write # sticky preview-link comment + add/remove preview-degraded label | |
| issues: write # create the `preview-degraded` label definition if missing (label defs are managed under the Issues API; add/remove on the PR itself only needs pull-requests: write) | |
| # Backend URLs (INDEXER_URL=v1, INDEXER_V2_URL=v2) are intentionally NOT set | |
| # at job level. They are resolved at runtime by the "Resolve backend URLs" | |
| # step below — the PR author's per-engineer sandbox (from freighter-config) | |
| # when they have an entry, otherwise the staging fallback | |
| # (secrets.INDEXER_URL / secrets.INDEXER_V2_BETA_URL) — and written to | |
| # $GITHUB_ENV before the build reads them. Setting them here as well would | |
| # create a job-`env:`-vs-`$GITHUB_ENV` precedence ambiguity, so they live | |
| # EXCLUSIVELY in the resolve step (which also fails fast if the staging | |
| # fallback is empty — replacing the old "Validate required secrets" step). | |
| # V1 staging has no public DNS; V2 beta is publicly reachable. | |
| # freighter-backend is a read-side indexer; wallet writes go direct to | |
| # Horizon/RPC, so the backend choice never affects write paths. | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| # Default leaves a GITHUB_TOKEN auth header in .git/config for the | |
| # rest of the job. With contents:write granted, any subsequent | |
| # code execution (yarn lifecycle scripts, build scripts) could | |
| # `git push` using those persisted creds. `gh` uses GH_TOKEN env | |
| # separately so disabling this doesn't affect the release flow. | |
| persist-credentials: false | |
| # ── Phase 2: fetch the PR author's sandbox URL map from freighter-config ── | |
| # Runs immediately after checkout, BEFORE any PR-controlled code (yarn | |
| # lifecycle scripts, build) executes, so the read-only deploy key is never | |
| # in process scope while PR-authored code runs. The key lives ONLY inside | |
| # this step: a mode-600 tempfile, one shallow clone, deleted on exit. | |
| # freighter-config is a separate PRIVATE repo, so the job GITHUB_TOKEN | |
| # can't read it — hence a dedicated contents-read-only deploy key | |
| # (extension-scoped; private half = secrets.FREIGHTER_CONFIG_DEPLOY_KEY). | |
| # NEVER fails the build: an unreachable/malformed config degrades to the | |
| # staging fallback in "Resolve backend URLs". | |
| - name: Fetch freighter-config (sandbox URL map) | |
| id: fetch_config | |
| env: | |
| FREIGHTER_CONFIG_DEPLOY_KEY: | |
| ${{ secrets.FREIGHTER_CONFIG_DEPLOY_KEY }} | |
| GH_META_TOKEN: ${{ github.token }} | |
| run: | | |
| set -uo pipefail | |
| KEY_FILE="$(mktemp)" | |
| KNOWN_HOSTS="$(mktemp)" | |
| META_JSON="$(mktemp)" | |
| CLONE_DIR="$(mktemp -d)" | |
| CONFIG_OUT="${RUNNER_TEMP}/freighter-config.json" | |
| cleanup() { rm -f "$KEY_FILE" "$KNOWN_HOSTS" "$META_JSON"; rm -rf "$CLONE_DIR"; } | |
| trap cleanup EXIT | |
| if [ -z "${FREIGHTER_CONFIG_DEPLOY_KEY}" ]; then | |
| echo "config_available=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::FREIGHTER_CONFIG_DEPLOY_KEY is not set; falling back to staging" | |
| exit 0 | |
| fi | |
| printf '%s\n' "${FREIGHTER_CONFIG_DEPLOY_KEY}" > "$KEY_FILE" | |
| chmod 600 "$KEY_FILE" | |
| # Fetch to a FILE, not a pipe. curl can only discard the partial body | |
| # of a failed transfer when the target is a file — with a pipe it | |
| # re-sends the whole body on retry, jq receives partial+full, and the | |
| # parse fails anyway. Piped retries buy nothing here. | |
| # | |
| # --retry-all-errors because curl's default "transient" set is | |
| # timeouts plus 408/429/5xx. GitHub answers a rate limit with 403, | |
| # which is precisely the failure this is meant to survive, and plain | |
| # --retry makes exactly one attempt against it. | |
| # | |
| # --retry-max-time bounds the total wait: curl honours Retry-After | |
| # over --retry-delay, and GitHub's secondary limits send values in the | |
| # tens of seconds, so an unbounded retry could stall this optional | |
| # step for minutes. --max-time is per-attempt and resets on retry, so | |
| # it cannot bound this on its own. | |
| # | |
| # Authenticated first, then anonymous. /meta needs no auth, so the | |
| # token is upside for rate limits — GITHUB_TOKEN gets 1,000 req/hour | |
| # per REPOSITORY (shared with every other workflow here and with the | |
| # gh calls later in this one) instead of 60/hour per runner IP shared | |
| # across the whole pool. But authenticating introduces failures the | |
| # anonymous request cannot have: an empty or rejected token, or org IP | |
| # allow-lists that apply to authenticated calls only. The two draw on | |
| # different buckets, so trying both is strictly better than either. | |
| fetch_meta() { | |
| if [ -n "${1:-}" ]; then | |
| curl -fsS --max-time 15 --retry 3 --retry-delay 2 --retry-max-time 40 \ | |
| --retry-all-errors -H "Authorization: Bearer $1" \ | |
| -o "$META_JSON" https://api.github.com/meta | |
| else | |
| curl -fsS --max-time 15 --retry 3 --retry-delay 2 --retry-max-time 40 \ | |
| --retry-all-errors -o "$META_JSON" https://api.github.com/meta | |
| fi | |
| } | |
| if fetch_meta "${GH_META_TOKEN:-}"; then | |
| : | |
| elif fetch_meta ""; then | |
| echo "::warning::Authenticated api.github.com/meta fetch failed; anonymous fetch succeeded" | |
| fi | |
| jq -r '.ssh_keys[] | "github.com \(.)"' "$META_JSON" > "$KNOWN_HOSTS" 2>/dev/null || true | |
| if [ ! -s "$KNOWN_HOSTS" ]; then | |
| echo "config_available=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::Could not fetch GitHub SSH host keys; falling back to staging" | |
| exit 0 | |
| fi | |
| if GIT_SSH_COMMAND="ssh -i $KEY_FILE -o IdentitiesOnly=yes -o UserKnownHostsFile=$KNOWN_HOSTS -o StrictHostKeyChecking=yes" \ | |
| git clone --depth 1 git@github.com:stellar/freighter-config.git "$CLONE_DIR" 2>/tmp/fc-clone.err \ | |
| && [ -f "$CLONE_DIR/config.json" ] \ | |
| && jq empty "$CLONE_DIR/config.json" 2>/dev/null; then | |
| # `jq empty` validates the file is parseable JSON before we publish | |
| # config_available=true — otherwise a malformed config.json would | |
| # make the resolve step's jq abort under `set -e` (build failure) | |
| # instead of taking the documented staging fallback. | |
| cp "$CLONE_DIR/config.json" "$CONFIG_OUT" | |
| echo "config_available=true" >> "$GITHUB_OUTPUT" | |
| echo "Fetched freighter-config/config.json" | |
| else | |
| echo "config_available=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::freighter-config unreachable or config.json missing/invalid; falling back to staging" | |
| cat /tmp/fc-clone.err 2>/dev/null || true | |
| fi | |
| # ── Phase 2: choose sandbox vs staging and inject INDEXER_URL/INDEXER_V2_URL ── | |
| # No deploy key in scope here (fetch tore it down). Reads the cached | |
| # config.json (if the fetch succeeded), looks the PR author up by GitHub | |
| # login, and writes the resolved URLs to $GITHUB_ENV so the build bakes | |
| # them. Manages the `preview-degraded` label and exports BACKEND_DESC for | |
| # the release notes + sticky comment. Also fails fast if the resolved URLs | |
| # are empty (replaces the old "Validate required secrets" step). | |
| - name: Resolve backend URLs (sandbox vs staging) | |
| id: resolve_backend | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | |
| CONFIG_AVAILABLE: ${{ steps.fetch_config.outputs.config_available }} | |
| # Staging fallbacks (the values previously hardcoded in job env). | |
| STAGING_V1_URL: ${{ secrets.INDEXER_URL }} | |
| STAGING_V2_URL: ${{ secrets.INDEXER_V2_BETA_URL }} | |
| run: | | |
| set -euo pipefail | |
| CONFIG_OUT="${RUNNER_TEMP}/freighter-config.json" | |
| V1_URL=""; V2_URL=""; TARGET=""; DESC=""; DEGRADED="false" | |
| if [ "${CONFIG_AVAILABLE}" = "true" ]; then | |
| # Tolerate a malformed entry (valid JSON but unexpected shape): | |
| # jq errors -> empty -> staging fallback, never aborts the build. | |
| V1_URL=$(jq -r --arg u "$PR_AUTHOR" '.engineers[$u].v1 // empty' "$CONFIG_OUT" 2>/dev/null || echo "") | |
| V2_URL=$(jq -r --arg u "$PR_AUTHOR" '.engineers[$u].v2 // empty' "$CONFIG_OUT" 2>/dev/null || echo "") | |
| if [ -n "$V1_URL" ] && [ -n "$V2_URL" ]; then | |
| TARGET="sandbox"; DESC="sandbox (${PR_AUTHOR})" | |
| else | |
| TARGET="staging"; DESC="V1 prod + V2 beta (no sandbox configured for @${PR_AUTHOR})" | |
| fi | |
| else | |
| TARGET="staging-degraded"; DEGRADED="true" | |
| DESC="V1 prod + V2 beta — freighter-config unreachable (preview degraded)" | |
| fi | |
| if [ "$TARGET" != "sandbox" ]; then | |
| V1_URL="$STAGING_V1_URL" | |
| V2_URL="$STAGING_V2_URL" | |
| fi | |
| if [ -z "$V1_URL" ] || [ -z "$V2_URL" ]; then | |
| echo "::error::Resolved backend URLs are empty (V1='$V1_URL' V2='$V2_URL'). Check secrets INDEXER_URL / INDEXER_V2_BETA_URL." | |
| exit 1 | |
| fi | |
| { | |
| echo "INDEXER_URL=${V1_URL}" | |
| echo "INDEXER_V2_URL=${V2_URL}" | |
| echo "BACKEND_TARGET=${TARGET}" | |
| echo "BACKEND_DESC=${DESC}" | |
| } >> "$GITHUB_ENV" | |
| echo "Backend target: ${TARGET} — ${DESC}" | |
| # preview-degraded label: create-if-missing, then add on degrade / | |
| # remove otherwise so a fixed re-run self-corrects. Never fail the | |
| # build on label plumbing. | |
| gh label create preview-degraded --repo "$GH_REPO" \ | |
| --color B60205 \ | |
| --description "PR preview fell back to staging because freighter-config was unreachable" \ | |
| 2>/dev/null || true | |
| if [ "$DEGRADED" = "true" ]; then | |
| gh pr edit "$PR_NUMBER" --repo "$GH_REPO" --add-label preview-degraded || true | |
| else | |
| gh pr edit "$PR_NUMBER" --repo "$GH_REPO" --remove-label preview-degraded || true | |
| fi | |
| - name: Assert source manifest has no top-level `key` field | |
| run: | | |
| if jq -e 'has("key")' ./extension/public/static/manifest/v3.json > /dev/null; then | |
| echo "::error::manifest/v3.json contains a top-level 'key' field. Preview installs would share a Chromium extension ID with Web Store Freighter, leaking storage." | |
| exit 1 | |
| fi | |
| - name: Rewrite preview manifest identity (Chromium + Firefox) | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| PREVIEW_NAME="Freighter PR Preview #${PR_NUMBER}" | |
| PREVIEW_VERSION_NAME="pr-preview-${PR_NUMBER}" | |
| PREVIEW_GECKO_ID="freighter-pr-preview-${PR_NUMBER}@stellar.org" | |
| jq --arg name "$PREVIEW_NAME" --arg vn "$PREVIEW_VERSION_NAME" \ | |
| '.name = $name | .version_name = $vn' \ | |
| ./extension/public/static/manifest/v3.json > /tmp/v3.json | |
| mv /tmp/v3.json ./extension/public/static/manifest/v3.json | |
| jq --arg name "$PREVIEW_NAME" --arg vn "$PREVIEW_VERSION_NAME" --arg gid "$PREVIEW_GECKO_ID" \ | |
| '.name = $name | .version_name = $vn | .browser_specific_settings.gecko.id = $gid' \ | |
| ./extension/public/static/manifest/v2.json > /tmp/v2.json | |
| mv /tmp/v2.json ./extension/public/static/manifest/v2.json | |
| - name: Setup Node | |
| uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 | |
| with: | |
| node-version: 22 | |
| - name: Enable Corepack | |
| run: corepack enable | |
| - name: Install + build extension (production) | |
| run: yarn && yarn build:freighter-api && yarn build:extension:production | |
| - name: Use BETA icons | |
| run: | | |
| rm -rf ./extension/build/images | |
| mv ./extension/build/beta_images ./extension/build/images | |
| - name: Assert built manifest has no top-level `key` field | |
| # The source-manifest assertion above catches a hand-edited `key`, | |
| # but a webpack plugin / postinstall script / transitive dep could | |
| # still inject one into the built output. Re-check post-build, | |
| # pre-zip, so the shipping artifact is what we asserted on. | |
| run: | | |
| if jq -e 'has("key")' ./extension/build/manifest.json > /dev/null; then | |
| echo "::error::Built manifest.json contains a top-level 'key' field. The build pipeline injected an extension-ID-fixing key after the source assertion; preview install would collide with Web Store Freighter." | |
| exit 1 | |
| fi | |
| - name: Zip extension build | |
| working-directory: ./extension/build | |
| run: zip -qq -r ./build.zip * | |
| - name: Delete existing preview release (idempotent) | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| # Check-then-delete instead of `|| true`. `|| true` would also | |
| # swallow transient API errors (network, 422 tag-conflict) and let | |
| # the subsequent `gh release create` silently reuse a stale tag. | |
| run: | | |
| if gh release view "pr-preview-${PR_NUMBER}" > /dev/null 2>&1; then | |
| # `--cleanup-tag` 422s on draft releases because drafts don't | |
| # create the git tag until publish. Branch on isDraft so we | |
| # only ask for tag-cleanup when there is actually a tag. | |
| IS_DRAFT=$(gh release view "pr-preview-${PR_NUMBER}" --json isDraft --jq '.isDraft') | |
| if [ "$IS_DRAFT" = "true" ]; then | |
| gh release delete "pr-preview-${PR_NUMBER}" --yes | |
| else | |
| gh release delete "pr-preview-${PR_NUMBER}" --yes --cleanup-tag | |
| fi | |
| fi | |
| - name: Create draft preview release | |
| id: release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| run: | | |
| # Write notes to a file rather than $(cat <<EOF) because bash's | |
| # command-substitution parser tokenizes single quotes inside the | |
| # heredoc body, breaking on apostrophes in prose. | |
| cat > /tmp/release-notes.md <<EOF | |
| Internal preview build for PR [#${PR_NUMBER}](${PR_URL}). SDF collaborators only — non-SDF GitHub users get 404 on this page. Auto-deleted when the PR is closed. | |
| **Commit:** ${PR_HEAD_SHA} | |
| **Backend:** ${BACKEND_DESC} (read-only indexer; wallet writes go direct to Horizon/RPC) | |
| ### How to install (Chromium) | |
| 1. Download \`build.zip\` from the Assets section below and unzip it | |
| 2. Open \`chrome://extensions\` in Chrome, Edge, or Brave | |
| 3. Enable Developer Mode (toggle in the top-right) | |
| 4. Click "Load Unpacked" and select the unzipped folder | |
| 5. The extension installs as "Freighter PR Preview #${PR_NUMBER}" with beta icons | |
| ### Important | |
| This code is still under review and may contain bugs that have not been caught yet. **Use caution before signing transactions with real funds** — consider testing with a testnet wallet instead. | |
| EOF | |
| # Don't capture stdout from `gh release create` — it can include | |
| # progress/status lines, not just the URL. Query the URL with a | |
| # dedicated `gh release view --json url` call instead. | |
| # | |
| # No --target: if the draft is ever manually published, the tag | |
| # falls back to master HEAD (reviewed code) rather than the PR's | |
| # HEAD commit (unreviewed). Dropping the targeted-tag property | |
| # closes a one-click escalation path where any account that can | |
| # click "Publish release" on a draft mints an official-looking | |
| # release containing arbitrary PR-head code; branch-protection | |
| # rules do not extend to release publication. | |
| gh release create "pr-preview-${PR_NUMBER}" \ | |
| ./extension/build/build.zip \ | |
| --title "PR Preview #${PR_NUMBER}" \ | |
| --notes-file /tmp/release-notes.md \ | |
| --draft > /dev/null | |
| URL=$(gh release view "pr-preview-${PR_NUMBER}" --json url --jq '.url') | |
| echo "url=${URL}" >> "$GITHUB_OUTPUT" | |
| - name: Post/update sticky PR comment with preview link | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| RELEASE_URL: ${{ steps.release.outputs.url }} | |
| run: | | |
| MARKER="<!-- pr-preview-comment -->" | |
| BODY="${MARKER}"$'\n'"PR Preview build is ready: ${RELEASE_URL}"$'\n'"Backend: ${BACKEND_DESC}. SDF collaborators only — install instructions in the release description." | |
| # --paginate so this works on PRs with >30 comments (default page | |
| # size). Without it, the marker comment can fall off a later page | |
| # and we'd post a duplicate instead of editing in place. | |
| EXISTING=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ | |
| --jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| gh api -X PATCH "repos/${GH_REPO}/issues/comments/${EXISTING}" -f body="$BODY" | |
| else | |
| gh pr comment "${PR_NUMBER}" --body "$BODY" | |
| fi | |
| cleanup-release: | |
| name: Cleanup PR Preview Release | |
| if: | |
| github.event.action == 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| # Shares the workflow-level concurrency group with the build job so a | |
| # close-event cancels any in-flight build before deleting the release — | |
| # prevents the build from finishing AFTER cleanup and re-creating an | |
| # orphaned release. | |
| steps: | |
| - name: Delete draft release and tag | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| # Skip silently if the release doesn't exist (race with a cancelled | |
| # build), but fail loudly on any other delete error rather than | |
| # masking it with `|| true`. | |
| run: | | |
| if gh release view "pr-preview-${PR_NUMBER}" > /dev/null 2>&1; then | |
| # `--cleanup-tag` 422s on draft releases because drafts don't | |
| # create the git tag until publish. Branch on isDraft so we | |
| # only ask for tag-cleanup when there is actually a tag. | |
| IS_DRAFT=$(gh release view "pr-preview-${PR_NUMBER}" --json isDraft --jq '.isDraft') | |
| if [ "$IS_DRAFT" = "true" ]; then | |
| gh release delete "pr-preview-${PR_NUMBER}" --yes | |
| else | |
| gh release delete "pr-preview-${PR_NUMBER}" --yes --cleanup-tag | |
| fi | |
| fi |