fix(hutch): guard the return-URL parse in pendingSaveHostFrom and pin… #4215
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
| 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 |