Skip to content

chore(e2e): refresh screenshot baselines and drop unread snapshot files #909

chore(e2e): refresh screenshot baselines and drop unread snapshot files

chore(e2e): refresh screenshot baselines and drop unread snapshot files #909

Workflow file for this run

# ----------------------------------------------------------------------
# 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