Skip to content

Commit 11a4ff7

Browse files
bloveclaude
andauthored
ci: PR-side deploy verification against real Vercel previews (#992)
* docs(specs): PR-side deploy verification design Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(plans): PR-side deploy verification implementation plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): playwright global setup that seeds the runtime origin bypass cookie Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(plans): keep the runtime bypass storage state out of uploaded artifact dirs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): harden the runtime bypass setup — redact, relocate state, SameSite=None, loopback tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): opt-in runtime bypass storage state for protected previews Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: smoke a throwaway cockpit preview on pull requests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): record the mixed-credential probe result for the runtime origin Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(cockpit-preview-smoke): fail on an empty deploy URL and remove the throwaway preview Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: run the Website suite against an aliased preview with a matching examples preview Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: drop --skip-domain from preview deploys, validate preview URLs, request a SameSite=None bypass cookie Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): gate the preview lanes only when they were eligible to run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: gate merges on the PR-side preview verifications when they were eligible to run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: name a skipped-but-eligible preview lane; document the PR-side lanes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: validate the cockpit preview URL before removal; keep the bypass cookie header off the runtime origin; cover the 307 bypass answer Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): match the implementation — no --skip-domain on previews, samesitenone, cockpit cleanup Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e64d6e9 commit 11a4ff7

9 files changed

Lines changed: 1967 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,70 @@ jobs:
354354
- run: npm ci
355355
- run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode preview --dry-run
356356

357+
cockpit-preview-smoke:
358+
name: Cockpit — immutable preview smoke
359+
timeout-minutes: 20
360+
needs: ci-scope
361+
# PR-side twin of the deploy job's cockpit verification. Deploys a
362+
# throwaway preview of the redirect service and runs the exhaustive smoke
363+
# against it, so platform behaviour (deployment protection, the CDN's
364+
# slash collapse, route ordering) is exercised before merge. Needs
365+
# repository secrets, so same-repo PRs and merge-queue candidates only;
366+
# the token-free dry-run job above still covers forks.
367+
if: >-
368+
github.event_name != 'push' &&
369+
needs.ci-scope.outputs.cockpit_deploy_smoke == 'true' &&
370+
(github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository)
371+
runs-on: ubuntu-latest
372+
steps:
373+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
374+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
375+
with:
376+
node-version: 22
377+
cache: npm
378+
- run: npm ci
379+
- name: Prepare cockpit Vercel project (preview)
380+
run: |
381+
mkdir -p .vercel
382+
cat > .vercel/project.json <<'EOF'
383+
{"projectId":"${{ secrets.VERCEL_COCKPIT_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-cockpit"}
384+
EOF
385+
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
386+
rm -rf .vercel/output
387+
- name: Build cockpit redirect service (preview)
388+
env:
389+
COCKPIT_WEBSITE_ORIGIN: https://threadplane.ai
390+
run: |
391+
npx vercel build --local-config vercel.cockpit.json --token=${{ secrets.VERCEL_TOKEN }}
392+
- name: Deploy throwaway cockpit preview
393+
id: deploy_cockpit_preview
394+
run: |
395+
set -euo pipefail
396+
url=$(npx vercel deploy --prebuilt --archive=tgz --yes --env COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
397+
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin cockpit preview URL"); process.stdout.write(parsed.origin)' "$url")
398+
echo "deployment_url=$url" >> "$GITHUB_OUTPUT"
399+
- name: Exhaustively verify the cockpit preview
400+
run: |
401+
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
402+
echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret."
403+
exit 1
404+
fi
405+
npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000
406+
env:
407+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }}
408+
- name: Remove the throwaway cockpit preview
409+
# Removal runs on every outcome, including a failed smoke: the smoke
410+
# log carries the full probe-by-probe report, and keeping a failed
411+
# artifact would only accumulate protected deployments nobody can
412+
# open without the bypass.
413+
# The artifact exists only to be smoked. `--safe` refuses to remove a
414+
# deployment that carries an alias, so this can never touch the
415+
# production artifact. `remove` takes a bare URL, so, like `promote`,
416+
# it needs the team scope spelled out.
417+
if: always() && steps.deploy_cockpit_preview.outputs.deployment_url != ''
418+
continue-on-error: true
419+
run: npx vercel remove "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --safe --yes --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
420+
357421
examples-chat-smoke:
358422
name: examples/chat — python smoke
359423
needs: ci-scope
@@ -681,6 +745,107 @@ jobs:
681745
npx playwright test apps/website/e2e/platform-production-smoke.spec.ts \
682746
--config apps/website/playwright.config.ts --list
683747
748+
website-preview-e2e:
749+
name: Website — e2e (deployed preview)
750+
timeout-minutes: 40
751+
needs: ci-scope
752+
# PR-side twin of the deploy job's post-promotion verification. The
753+
# ordinary suite runs against a real, protected Vercel preview of the
754+
# Website whose runtime iframe loads from a matching examples preview, so
755+
# remote-target assumptions and platform behaviour surface before merge.
756+
# Two deterministic aliases break the ordering problem: the examples are
757+
# assembled with the Website alias in their parent-origin policy, and the
758+
# Website is built with the examples alias as its runtime base. A later
759+
# push re-points both aliases; the deployments behind them are kept for
760+
# inspection. Needs repository secrets, so same-repo PRs and merge-queue
761+
# candidates only.
762+
if: >-
763+
github.event_name != 'push' &&
764+
needs.ci-scope.outputs.website_e2e == 'true' &&
765+
(github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository)
766+
runs-on: ubuntu-latest
767+
steps:
768+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
769+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
770+
with:
771+
node-version: 22
772+
cache: npm
773+
- run: npm ci
774+
- name: Cache Playwright browsers
775+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
776+
with:
777+
path: ~/.cache/ms-playwright
778+
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
779+
restore-keys: |
780+
playwright-${{ runner.os }}-
781+
- run: npx playwright install --with-deps chromium
782+
- name: Derive deterministic preview aliases
783+
id: aliases
784+
run: |
785+
if [ "${{ github.event_name }}" = "merge_group" ]; then
786+
key="mq-$(echo "${{ github.event.merge_group.head_sha }}" | cut -c1-8)"
787+
else
788+
key="pr-${{ github.event.pull_request.number }}"
789+
fi
790+
echo "website=threadplane-${key}-cacheplane.vercel.app" >> "$GITHUB_OUTPUT"
791+
echo "examples=threadplane-examples-${key}-cacheplane.vercel.app" >> "$GITHUB_OUTPUT"
792+
- name: Require preview bypass secrets
793+
# Both previews sit behind deployment protection, and bypass secrets
794+
# are issued per Vercel project. Fail before creating anything.
795+
run: |
796+
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
797+
echo "::error::VERCEL_AUTOMATION_BYPASS_SECRET is unset — the protected Website preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane project and store the value as this repository secret."
798+
exit 1
799+
fi
800+
if [ -z "${VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET}" ]; then
801+
echo "::error::VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET is unset — the runtime iframe from the protected examples preview cannot load. Enable 'Protection Bypass for Automation' on the Vercel threadplane-examples project and store the value as this repository secret."
802+
exit 1
803+
fi
804+
env:
805+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
806+
VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET }}
807+
- name: Build and assemble Angular examples for the preview
808+
run: npx tsx scripts/assemble-examples.ts
809+
env:
810+
RUNTIME_PARENT_PREVIEW_ORIGINS: https://${{ steps.aliases.outputs.website }}
811+
- name: Deploy examples preview and alias it
812+
working-directory: deploy/examples
813+
run: |
814+
set -euo pipefail
815+
mkdir -p .vercel
816+
cat > .vercel/project.json <<'EOF'
817+
{"projectId":"${{ secrets.VERCEL_EXAMPLES_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-examples"}
818+
EOF
819+
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
820+
url=$(npx vercel deploy --prebuilt --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
821+
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin examples preview URL"); process.stdout.write(parsed.origin)' "$url")
822+
echo "examples deployment: $url"
823+
npx vercel alias set "$url" "${{ steps.aliases.outputs.examples }}" --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
824+
- name: Build, deploy, and alias the Website preview
825+
env:
826+
GROWTH_FORM_POLICY: growth_v1
827+
NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL: https://${{ steps.aliases.outputs.examples }}
828+
run: |
829+
set -euo pipefail
830+
mkdir -p .vercel
831+
cat > .vercel/project.json <<'EOF'
832+
{"projectId":"${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane"}
833+
EOF
834+
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
835+
rm -rf .vercel/output
836+
npx vercel build --token=${{ secrets.VERCEL_TOKEN }}
837+
url=$(npx vercel deploy --prebuilt --archive=tgz --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
838+
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin Website preview URL"); process.stdout.write(parsed.origin)' "$url")
839+
echo "website deployment: $url"
840+
npx vercel alias set "$url" "${{ steps.aliases.outputs.website }}" --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
841+
- name: Run the Website suite against the aliased preview
842+
run: npx nx e2e website --skip-nx-cache
843+
env:
844+
BASE_URL: https://${{ steps.aliases.outputs.website }}
845+
RUNTIME_BYPASS_ORIGIN: https://${{ steps.aliases.outputs.examples }}
846+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
847+
VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET }}
848+
684849
required-pr-checks:
685850
name: CI — required
686851
needs:
@@ -697,6 +862,8 @@ jobs:
697862
- examples-ag-ui-e2e
698863
- cockpit-e2e-summary
699864
- website-e2e
865+
- website-preview-e2e
866+
- cockpit-preview-smoke
700867
- posthog-sync-plan
701868
- scripts-tests
702869
- growth-lifecycle
@@ -722,6 +889,8 @@ jobs:
722889
RESULT_EXAMPLES_AG_UI_E2E: ${{ needs.examples-ag-ui-e2e.result }}
723890
RESULT_COCKPIT_E2E: ${{ needs.cockpit-e2e-summary.result }}
724891
RESULT_WEBSITE_E2E: ${{ needs.website-e2e.result }}
892+
RESULT_WEBSITE_PREVIEW_E2E: ${{ needs.website-preview-e2e.result }}
893+
RESULT_COCKPIT_PREVIEW_SMOKE: ${{ needs.cockpit-preview-smoke.result }}
725894
RESULT_POSTHOG: ${{ needs.posthog-sync-plan.result }}
726895
RESULT_SCRIPTS_TESTS: ${{ needs.scripts-tests.result }}
727896
RESULT_GROWTH_LIFECYCLE: ${{ needs.growth-lifecycle.result }}
@@ -740,6 +909,11 @@ jobs:
740909
SCOPE_POSTHOG: ${{ needs.ci-scope.outputs.posthog }}
741910
SCOPE_SCRIPTS_TESTS: ${{ needs.ci-scope.outputs.scripts_tests }}
742911
SCOPE_GROWTH_LIFECYCLE: ${{ needs.ci-scope.outputs.growth_lifecycle }}
912+
# The preview lanes need repository secrets, so they skip on fork
913+
# PRs. Their scope keys are computed from changed files alone, so on
914+
# a fork they can be in scope yet legitimately skipped. This mirrors
915+
# the lanes' own `if` so the gate only demands them when they could run.
916+
PREVIEW_LANES_ELIGIBLE: ${{ github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository }}
743917
run: |
744918
set -euo pipefail
745919
@@ -775,6 +949,28 @@ jobs:
775949
fi
776950
}
777951
952+
# Scoped check for the PR-side preview lanes: enforced only when the
953+
# lanes were eligible to run; otherwise treated as unselected, where
954+
# only a real failure or cancellation is an error.
955+
require_preview() {
956+
local scope_key="$1"
957+
local label="$2"
958+
local result="$3"
959+
local scoped="$4"
960+
961+
if [[ "$PREVIEW_LANES_ELIGIBLE" == "true" && "$scoped" == "true" && "$result" == "skipped" ]]; then
962+
echo "::error::${label} was eligible and in scope ${scope_key} but was skipped — its job-level if no longer matches the gate's PREVIEW_LANES_ELIGIBLE expression."
963+
failed=1
964+
return
965+
fi
966+
967+
if [[ "$PREVIEW_LANES_ELIGIBLE" == "true" ]]; then
968+
require_scoped "$scope_key" "$label" "$result" "$scoped"
969+
else
970+
require_scoped "$scope_key" "$label" "$result" "false"
971+
fi
972+
}
973+
778974
require_always "CI scope" "$RESULT_CI_SCOPE"
779975
require_scoped "library" "Library — lint / test / build" "$RESULT_LIBRARY" "$SCOPE_LIBRARY"
780976
require_scoped \
@@ -796,6 +992,8 @@ jobs:
796992
"$SCOPE_EXAMPLES_AG_UI"
797993
require_scoped "cockpit_e2e" "Cockpit — e2e" "$RESULT_COCKPIT_E2E" "$SCOPE_COCKPIT_E2E"
798994
require_scoped "website_e2e" "Website — e2e" "$RESULT_WEBSITE_E2E" "$SCOPE_WEBSITE_E2E"
995+
require_preview "website_e2e" "Website — e2e (deployed preview)" "$RESULT_WEBSITE_PREVIEW_E2E" "$SCOPE_WEBSITE_E2E"
996+
require_preview "cockpit_deploy_smoke" "Cockpit — immutable preview smoke" "$RESULT_COCKPIT_PREVIEW_SMOKE" "$SCOPE_COCKPIT_DEPLOY_SMOKE"
799997
require_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG"
800998
require_scoped "scripts_tests" "Scripts — generator / proxy vitest suites" "$RESULT_SCRIPTS_TESTS" "$SCOPE_SCRIPTS_TESTS"
801999
require_scoped "growth_lifecycle" "Growth lifecycle — Node 22" "$RESULT_GROWTH_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE"

CONTRIBUTING.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,32 @@ branch protection and has no rulesets, so the unprivileged
195195
`GET /repos/{owner}/{repo}/rules/branches/main` endpoint returns `[]` and is
196196
not an alternative.
197197

198+
### PR-side deploy verification
199+
200+
Two lanes run the deploy job's verification on pull requests against real
201+
Vercel previews, so deploy-only failures surface before merge:
202+
203+
- **Website — e2e (deployed preview)** builds and deploys the Website and
204+
the examples as previews under deterministic aliases
205+
(`threadplane-pr-<n>-cacheplane.vercel.app` and
206+
`threadplane-examples-pr-<n>-cacheplane.vercel.app`; `mq-<sha8>` for
207+
merge-queue candidates) and runs the ordinary suite against the Website
208+
alias. The runtime iframe loads because the examples are assembled with
209+
the Website alias in their parent-origin policy and Playwright seeds the
210+
examples origin's bypass cookie (`apps/website/e2e/runtime-bypass-setup.ts`).
211+
A later push re-points both aliases; the deployments behind them are kept.
212+
- **Cockpit — immutable preview smoke** deploys a throwaway cockpit preview,
213+
runs the exhaustive redirect smoke against it, and removes it.
214+
215+
Both need repository secrets and therefore skip on fork PRs; the required
216+
gate only demands them when they were eligible to run. Each Vercel project
217+
has its own Protection Bypass for Automation secret:
218+
`VERCEL_AUTOMATION_BYPASS_SECRET` (Website),
219+
`VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET`, and
220+
`VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET`. A secret added while a run is in
221+
flight does not reach that run; re-run after provisioning. Never pass
222+
`--skip-domain` to a preview deploy; Vercel requires it to accompany `--prod`.
223+
198224
## Code review
199225

200226
Every PR gets a genuine advisory AI code review

0 commit comments

Comments
 (0)