Skip to content

fix(hutch): guard the return-URL parse in pendingSaveHostFrom and pin… #4215

fix(hutch): guard the return-URL parse in pendingSaveHostFrom and pin…

fix(hutch): guard the return-URL parse in pendingSaveHostFrom and pin… #4215

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
jobs:
# Lint/type/test/coverage suite for every Node project (web app, extensions,
# packages). The iOS suite cannot live in this job: it needs Xcode on a macOS
# runner and is path-gated to iOS changes (see ios-tests below).
web-tests:
runs-on: ubuntu-latest
environment: staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ap-southeast-2
PULUMI_BACKEND_URL: s3://hutch-pulumi-state-572337278115-ap-southeast-2
PULUMI_STACK: staging
PULUMI_CONFIG_PASSPHRASE: ''
PERSISTENCE: development
STATIC_BASE_URL: ''
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 9.15.0
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --reporter=append-only --loglevel=warn
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: playwright-${{ runner.os }}-
- run: pnpm check
# Only detect and deploy on pushes to main (not on PRs)
detect-projects:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ap-southeast-2
PULUMI_BACKEND_URL: s3://hutch-pulumi-state-572337278115-ap-southeast-2
PULUMI_STACK: staging
PULUMI_CONFIG_PASSPHRASE: ''
# Expose detection results so downstream jobs can conditionally run
outputs:
platform-affected: ${{ steps.detect.outputs.platform-affected }}
non-platform-projects: ${{ steps.detect.outputs.non-platform-projects }}
has-non-platform: ${{ steps.detect.outputs.has-non-platform }}
ff-affected: ${{ steps.detect.outputs.ff-affected }}
chrome-affected: ${{ steps.detect.outputs.chrome-affected }}
steps:
# check-infra runs `pulumi preview` against each project's Pulumi program,
# which evaluates top-level `requireEnv` calls (e.g. DEEPSEEK_API_KEY,
# GOOGLE_LOGIN_CLIENT_ID). Without secrets exported as env vars the program
# crashes before the resource graph is built and drift detection silently
# reports "No drift" — matches the secret-export step in
# project-deployment.yaml.
- name: Export all repository and environment secrets as env vars
env:
SECRETS_JSON: ${{ toJson(secrets) }}
run: |
DELIM="EOF_$(openssl rand -hex 16)"
printf '%s' "$SECRETS_JSON" | jq -r --arg d "$DELIM" '
to_entries[]
| select(.key != "github_token")
| "\(.key)<<\($d)\n\(.value)\n\($d)"
' >> "$GITHUB_ENV"
- uses: actions/checkout@v6
with:
# Full history needed for nx affected to compare commits
fetch-depth: 0
# Sets NX_BASE and NX_HEAD so nx affected knows what commits to compare.
# The publish workflows tag instead of committing back to main, so there
# are no bot version-bump commits in the diff and the action's default
# ("last successful workflow run on main") is the correct anchor.
- uses: nrwl/nx-set-shas@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.0
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --reporter=append-only --loglevel=warn
- id: detect
run: |
# Get the list of projects affected by the push (uses NX_BASE..NX_HEAD)
AFFECTED=$(pnpm nx show projects --affected --json)
echo "Affected projects: $AFFECTED"
# Dynamically discover all projects that have a "deploy-infra" target
DEPLOYABLE=$(pnpm nx show projects --with-target deploy-infra --json)
# Intersect: projects that are both affected AND deployable
ALREADY_DEPLOY=$(echo "$AFFECTED" "$DEPLOYABLE" | jq -s '.[0] as $a | .[1] | map(select(. as $p | $a | index($p)))')
echo "Code-affected deployable: $ALREADY_DEPLOY"
# Deployable projects NOT in the affected list — check for infra drift
UNCHECKED=$(echo "$DEPLOYABLE" "$ALREADY_DEPLOY" | jq -s '.[0] as $d | .[1] as $a | $d | map(select(. as $p | $a | index($p) | not))')
echo "Checking infra drift for: $UNCHECKED"
# Compile dependencies so check-infra can evaluate Pulumi programs
UNCHECKED_CSV=$(echo "$UNCHECKED" | jq -r 'join(",")')
if [ -n "$UNCHECKED_CSV" ]; then
pnpm nx run-many --target=compile --projects="$UNCHECKED_CSV" 2>&1 || true
fi
# Run check-infra for each unchecked project, detect drift from pulumi output.
# A non-zero exit from `nx run check-infra` means the Pulumi program crashed
# before the resource graph was built (e.g. missing env var), so drift is
# unknown — force a redeploy rather than silently reporting "no drift".
DRIFTED='[]'
for project in $(echo "$UNCHECKED" | jq -r '.[]'); do
echo "::group::check-infra $project"
# GH Actions runs steps with `bash -e`. A bare `var=$(failing_cmd)`
# triggers errexit and exits the script before $? can be captured,
# so use `|| INFRA_EXIT=$?` to put the assignment in errexit's
# exception list while preserving the inner command's exit code.
INFRA_EXIT=0
INFRA_OUTPUT=$(pnpm nx run "$project:check-infra" 2>&1) || INFRA_EXIT=$?
echo "$INFRA_OUTPUT"
if [ $INFRA_EXIT -ne 0 ]; then
echo "::error::check-infra crashed for $project (exit $INFRA_EXIT) — drift unknown, forcing redeploy"
DRIFTED=$(echo "$DRIFTED" | jq --arg p "$project" '. + [$p]')
elif echo "$INFRA_OUTPUT" | grep -qE 'to (update|create|delete|replace)'; then
echo "Drift detected in: $project"
DRIFTED=$(echo "$DRIFTED" | jq --arg p "$project" '. + [$p]')
else
echo "No drift in: $project"
fi
echo "::endgroup::"
done
echo "Drifted projects: $DRIFTED"
# Final deploy list: code-affected + infra-drifted
PROJECTS=$(echo "$ALREADY_DEPLOY" "$DRIFTED" | jq -s '.[0] + .[1] | unique')
NON_PLATFORM=$(echo "$PROJECTS" | jq -c '[.[] | select(. != "platform")]')
echo "Final deploy list: $PROJECTS"
echo "Non-platform: $NON_PLATFORM"
# Extension publish gates only fire when the compile target is affected,
# so test-only changes in dependencies (e.g. hutch *.test.ts) don't bump
# the Web Store / AMO version. compile inputs use `^production`, which
# excludes test files via the namedInputs in nx.json.
COMPILE_AFFECTED=$(pnpm nx show projects --affected --target=compile --json)
# Set outputs for downstream jobs to use in their `if:` conditions
echo "platform-affected=$(echo "$PROJECTS" | jq -e 'index("platform")' > /dev/null 2>&1 && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "non-platform-projects=$NON_PLATFORM" >> "$GITHUB_OUTPUT"
echo "has-non-platform=$(echo "$NON_PLATFORM" | jq -e 'length > 0' > /dev/null 2>&1 && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "ff-affected=$(echo "$COMPILE_AFFECTED" | jq -e 'index("firefox-extension")' > /dev/null 2>&1 && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "chrome-affected=$(echo "$COMPILE_AFFECTED" | jq -e 'index("chrome-extension")' > /dev/null 2>&1 && echo true || echo false)" >> "$GITHUB_OUTPUT"
# iOS change detection. Unlike detect-projects (which needs AWS secrets + Pulumi
# to check infra drift and therefore only runs on main pushes), this job only
# diffs paths, so it is cheap and safe to run on PRs too — which is what lets
# ios-tests gate pre-merge, exactly like web-tests runs `pnpm check` on every
# PR. nx-set-shas anchors the diff: the PR's merge base on PRs, the last
# successful main run on pushes.
detect-ios:
runs-on: ubuntu-latest
outputs:
ios-affected: ${{ steps.detect.outputs.ios-affected }}
ios-test-affected: ${{ steps.detect.outputs.ios-test-affected }}
steps:
- uses: actions/checkout@v6
with:
# Full history needed for nx-set-shas to resolve the diff base
fetch-depth: 0
- uses: nrwl/nx-set-shas@v4
- id: detect
run: |
# iOS TestFlight gate: fire only when the app's SHIPPING source changed
# (App/Shared/ShareExtension/project.yml). The iOS unit tests live in
# the SAME nx project, so nx affected would also fire on test/doc/CI
# changes — a path diff over NX_BASE..NX_HEAD is what keeps a build from
# shipping when "the code itself" didn't change.
IOS_CHANGED=$(git diff --name-only "$NX_BASE" "$NX_HEAD" -- \
projects/ios-readplace/App \
projects/ios-readplace/Shared \
projects/ios-readplace/ShareExtension \
projects/ios-readplace/project.yml)
echo "ios-affected=$([ -n "$IOS_CHANGED" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# iOS test gate: a superset of ios-affected that also fires on test
# changes, so a test-only change still runs the macOS suite (which gates
# the publish) without shipping a build.
IOS_TEST_CHANGED=$(git diff --name-only "$NX_BASE" "$NX_HEAD" -- \
projects/ios-readplace/App \
projects/ios-readplace/Shared \
projects/ios-readplace/ShareExtension \
projects/ios-readplace/Tests \
projects/ios-readplace/project.yml)
echo "ios-test-affected=$([ -n "$IOS_TEST_CHANGED" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Platform (EventBridge bus, shared infra) must deploy before any other project
# because other projects depend on platform resources (e.g. event bus ARN)
deploy-platform:
if: needs.detect-projects.outputs.platform-affected == 'true'
needs: [web-tests, detect-projects]
uses: ./.github/workflows/project-deployment.yaml
secrets: inherit
with:
project: platform
# Deploy all non-platform projects in parallel using a dynamic matrix
# Each project goes through staging → post-deploy verification → prod
# via the reusable project-deployment.yaml workflow
pipeline:
name: ${{ matrix.project }}
if: >-
always()
&& needs.web-tests.result == 'success'
&& needs.detect-projects.result == 'success'
&& needs.deploy-platform.result != 'failure'
&& needs.detect-projects.outputs.has-non-platform == 'true'
needs: [web-tests, detect-projects, deploy-platform]
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.detect-projects.outputs.non-platform-projects) }}
uses: ./.github/workflows/project-deployment.yaml
secrets: inherit
with:
project: ${{ matrix.project }}
# Submit Firefox extension to AMO for signing (runs after pipeline deploys infra)
# always() disarms the implicit success() guard so a skipped deploy-platform
# doesn't cascade into skipping this publish; pipeline.result == 'success'
# keeps it gated on the actual deploy chain.
ff-extension-publish:
if: >-
always()
&& needs.pipeline.result == 'success'
&& needs.detect-projects.outputs.ff-affected == 'true'
needs: [detect-projects, pipeline]
permissions:
contents: write
uses: ./.github/workflows/submit-ff-extension-for-signing.yml
secrets: inherit
# Publish Chrome extension to Chrome Web Store
chrome-extension-publish:
if: >-
always()
&& needs.pipeline.result == 'success'
&& needs.detect-projects.outputs.chrome-affected == 'true'
needs: [detect-projects, pipeline]
permissions:
contents: write
uses: ./.github/workflows/publish-chrome-extension.yml
secrets: inherit
# Run the iOS unit/integration test bundle on a macOS runner. Gates the
# TestFlight publish below, so a red test blocks the build. Mirrors the publish
# job's toolchain (macos-15 + latest-stable Xcode + xcodegen). Gated by
# detect-ios, which — unlike detect-projects — runs on PRs too, so iOS
# regressions are caught pre-merge like every other test suite. Fires whenever
# iOS code OR tests change; publish stays scoped to shipping code via
# ios-affected (⊆ ios-test-affected), so this always runs when publish would.
ios-tests:
if: needs.detect-ios.outputs.ios-test-affected == 'true'
needs: [detect-ios]
runs-on: macos-15
defaults:
run:
working-directory: projects/ios-readplace
steps:
- uses: actions/checkout@v6
- name: Select the newest stable Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install XcodeGen
run: brew install xcodegen
- name: Run the tests on a discovered iPhone simulator
run: |
# Match by UDID, not name: simctl ends each device line with trailing
# whitespace after the state column, so EOL-anchored name parsing
# silently keeps the "(UDID) (State)" suffix and xcodebuild matches
# no device. The UDID is the only stable token on the line.
UDID=$(xcrun simctl list devices available \
| grep -E '^[[:space:]]+iPhone' | head -n1 \
| grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true)
test -n "$UDID" || { echo "::error::No iPhone simulator available on the runner"; exit 1; }
xcrun simctl list devices | grep -F -- "$UDID" || true
make test SIM="platform=iOS Simulator,id=$UDID"
# Build + upload the iOS app (and its embedded share extension, in one build)
# to TestFlight. Independent of the infra deploy pipeline — gated on the web
# test suite passing, the iOS test bundle passing, and the iOS shipping code
# having changed.
ios-testflight-publish:
if: >-
always()
&& needs.web-tests.result == 'success'
&& needs.detect-projects.result == 'success'
&& needs.ios-tests.result == 'success'
&& needs.detect-ios.outputs.ios-affected == 'true'
needs: [web-tests, detect-projects, detect-ios, ios-tests]
permissions:
contents: write
uses: ./.github/workflows/publish-ios-testflight.yml
secrets: inherit