diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 834d27d74..d6fcd9977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -27,6 +30,10 @@ jobs: run: bun run lint - name: No-comment policy run: bun run check-comments + - name: Source byte policy + run: bun run check-bytes + - name: No Bun built-ins in shipped server code + run: bun run check-bun-imports - name: One copy of every overridden dependency run: bun run check-deps - name: Typecheck @@ -131,8 +138,6 @@ jobs: build: name: Build runs-on: ubuntu-latest - permissions: - contents: read env: DATABASE_URL: postgres://orbit:orbit@localhost:5433/orbit REDIS_URL: redis://localhost:6380 diff --git a/.github/workflows/vercel-preview.yml b/.github/workflows/vercel-preview.yml new file mode 100644 index 000000000..004a66487 --- /dev/null +++ b/.github/workflows/vercel-preview.yml @@ -0,0 +1,54 @@ +name: Vercel Preview + +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled, unlabeled, closed] + workflow_run: + workflows: [CI] + types: [completed] + repository_dispatch: + types: [vercel-preview-reconcile] + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: vercel-preview-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pull_request || github.event.workflow_run.head_sha || github.run_id }} + cancel-in-progress: false + +jobs: + reconcile: + if: >- + github.event_name == 'pull_request_target' || + github.event_name == 'repository_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out trusted controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + persist-credentials: false + submodules: false + lfs: false + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: "1.3.14" + - name: Install trusted controller dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Reconcile Vercel Preview + run: bun scripts/vercel-preview-deploy.ts + env: + GITHUB_TOKEN: ${{ github.token }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + VERCEL_PROJECT_NAME: ${{ vars.VERCEL_PROJECT_NAME }} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index e18c80e26..7d42271e5 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,6 +1,12 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "installCommand": "rm -rf ../../node_modules node_modules && bun install --frozen-lockfile", + "git": { + "deploymentEnabled": { + "**": false, + "main": true + } + }, "regions": ["hnd1"], "crons": [ { "path": "/api/cron/analytics-snapshots", "schedule": "0 */6 * * *" }, diff --git a/docs/README.md b/docs/README.md index 553a3c5ef..91d5fa2ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ gated behind a plan. | Write or run the tests | [Testing](testing.md) | | Fix something that broke | [Troubleshooting](troubleshooting.md) | | See what is coming | [Roadmap](roadmap.md) | +| Operate gated Vercel Preview deployments | [Vercel Preview deployment gate](VERCEL_BUILD_GATE.md) | | Contribute | [CONTRIBUTING.md](https://github.com/Noveum/orbit/blob/main/CONTRIBUTING.md) | ## The five minute version diff --git a/docs/VERCEL_BUILD_GATE.md b/docs/VERCEL_BUILD_GATE.md new file mode 100644 index 000000000..692962774 --- /dev/null +++ b/docs/VERCEL_BUILD_GATE.md @@ -0,0 +1,201 @@ +# Vercel Preview deployment gate + +Orbit creates Vercel Preview deployments only after the exact pull request head +has passed CI and still satisfies the repository policy. Production deployments +from `main` remain enabled through the Vercel Git integration. + +## Eligibility + +The controller evaluates the current pull request from GitHub on every event. +`no-preview` takes precedence over every other state. + +| Pull request state | Labels | Result after exact-head CI succeeds | +| --- | --- | --- | +| Ready for review | neither managed label | eligible | +| Ready for review | `preview` | eligible | +| Ready for review | `no-preview`, with or without `preview` | ineligible | +| Draft | `preview` without `no-preview` | eligible | +| Draft | neither managed label | ineligible | +| Draft | `no-preview`, with or without `preview` | ineligible | +| Closed | any labels | ineligible | +| Fork or Dependabot | any state or labels | never eligible for an automatic Preview | + +An eligible pull request must also target `main`, come from the same repository, +not be authored by Dependabot, and change at least one web-impacting path: + +- `apps/web/**` +- `packages/**` +- `package.json` +- `bun.lock` +- `tsconfig.base.json` + +For a renamed file, either the current filename or GitHub's validated +`previous_filename` can make the change web-impacting. Moving code out of +`apps/web/**` or `packages/**` therefore still requires a Preview. + +Ready status or the `preview` label does not establish trust. The controller +also proves that the newest `CI` run belongs to the current head SHA, is +associated with the same pull request and current `main`, and completed +successfully. A state event can create a Preview immediately when that proof +already exists. Otherwise the successful `workflow_run` event reconciles the +pull request after CI finishes. A later non-green run blocks an older success. +Before Create, the controller repeats the CI proof and then refetches the pull +request once more. Any head, identity, state, draft, or label change during that +proof prevents the POST. + +## Trust boundary + +`Vercel Preview` is a privileged default-branch workflow. It checks out +`${{ github.sha }}`, which is the trusted base or default-branch commit for its +three event types. It never selects, fetches, installs, caches, downloads an +artifact from, builds, or executes pull request code. Dependency lifecycle +scripts are disabled. The only operational command is +`bun scripts/vercel-preview-deploy.ts`, and `VERCEL_TOKEN` exists only on that +step. + +Only the trusted GitHub controller is isolated from pull request code. A +successful `workflow_run` can receive Actions secrets even when Dependabot's +source CI run could not, so the controller treats a Dependabot pull request as +fork-equivalent before any Vercel request. The +API-created Vercel Preview still builds same-repository pull request code with +the project Preview environment scope. Git Fork Protection must remain enabled, +and forks are rejected by the controller, but maintainers must still treat the +Preview environment as available to same-repository pull request code. + +The `git.deploymentEnabled` map in `apps/web/vercel.json` disables automatic Git +deployments for `**` and enables them for `main`. This is a repository-controlled +cost policy, not a security boundary. A repository change can alter that policy, +so security depends on the trusted workflow and controller validation. + +Each API create remains a Vercel deployment. Canceled attempts and reused +deployments can remain visible in Vercel deployment history and counts. The gate +reduces unnecessary creation, but it does not promise that an ignored or +canceled attempt is free. + +## Reconciliation and Vercel API behavior + +The controller uses one deployment path: + +1. Vercel v7 lists Preview deployments by team, project, and branch before CI + to find active work from prior heads. It lists by exact head SHA again when + creating or reusing current-head work. +2. Vercel v9 validates the configured project before every mutation preflight. + The validated project ID, name, and account ID must match the configured + project and team. Create then re-proves CI, refreshes the pull request, and + re-reads `main`. Cancel refreshes its path-specific intent before the PATCH. +3. Vercel v13 creates or reads a deployment with the same-repository GitHub + repository ID, head ref, exact head SHA, and Orbit metadata. It omits a + target so Vercel uses the project's Preview environment. +4. Vercel v12 cancels matching active deployments. + +Deployment IDs are accepted only when they contain ASCII letters, digits, +underscores, and hyphens within the controller's fixed bound. The controller +checks IDs and URLs against both tokens before they can enter a result, and URL +encodes every deployment ID used as an API path segment. + +`QUEUED`, `INITIALIZING`, and `BUILDING` deployments are active. Making a pull +request ineligible by closing it, converting it to draft without `preview`, +removing `preview` from an otherwise ineligible draft, or adding `no-preview` +cancels matching active Preview work. A deployment that is already `READY` is +not canceled, so its ready URL remains available. + +Per-pull-request workflow runs remain serialized with in-progress cancellation +disabled. While the owner polls a queued, initializing, or building deployment, +it refetches the current pull request after every active detail response. If the +same exact head becomes closed or ineligible, that owner cancels only its exact +deployment and returns a canceled result. If only the head SHA changed while the +pull request, repositories, base, and head ref remain identical, the old owner +cancels its superseded active deployment. Any other identity change returns a +stale event without mutation. + +`pull_request_target` includes `synchronize`, so a pushed head immediately runs +the trusted controller. Every trusted current-candidate reconciliation also +sweeps active prior-head work before checking current-head CI. A candidate for +cancellation must have a different valid 40-character hexadecimal SHA and exact +configured project, repository ID, pull request number, and head ref metadata. +This repeated sweep lets later CI and repository-dispatch events recover after a +transient synchronize failure or an interrupted polling owner. READY prior-head +deployments are retained, so their URLs remain available. + +Events for stale heads cannot create or cancel work for the current head. An +existing exact ready or active deployment is reused. Terminal deployment +history can cause one forced create for the exact head, using the same v13 +endpoint rather than an alternate build path. + +## Repository and Vercel setup + +The GitHub repository must provide: + +- Secret `VERCEL_TOKEN` +- Variable `VERCEL_TEAM_ID` +- Variable `VERCEL_PROJECT_ID` +- Variable `VERCEL_PROJECT_NAME` + +Keep Vercel Git Fork Protection enabled. Synchronize the managed `preview` and +`no-preview` labels with the rest of the repository labels: + +```bash +bun run labels:sync +bun run labels:sync --apply +``` + +The first command is a dry run. Review its plan before applying it. + +After `.github/workflows/vercel-preview.yml` is present on `main`, remove these +legacy Vercel environment values: + +- `BUILD_GATE_GITHUB_TOKEN` +- `BUILD_GATE_WATCH_PATHS` +- `BUILD_GATE_READY_LABEL` +- `BUILD_GATE_BLOCK_LABEL` + +They belonged to the removed Ignored Build Step and are not read by the trusted +controller. + +## Manual recovery + +A maintainer can reconcile a positive numeric pull request number with an +authenticated repository dispatch: + +```bash +gh api repos/Noveum/orbit/dispatches \ + --method POST \ + -f event_type=vercel-preview-reconcile \ + -F 'client_payload[pull_request]=341' +``` + +The event type must be `vercel-preview-reconcile`, and +`client_payload.pull_request` must be a positive number. The event shares the +same per-pull-request concurrency group as state and CI events. Malformed input +can form an unused group but is rejected before any Vercel call. + +Do not add or use `workflow_dispatch` for recovery. A caller can select a +non-default ref for that trigger. GitHub runs `repository_dispatch` from the +last commit on the default branch, preserving the controller trust boundary. + +## Post-merge canary + +Run this procedure only after the workflow exists on `main`: + +1. Confirm Vercel Git Fork Protection is enabled. Configure `VERCEL_TOKEN` and + the `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and `VERCEL_PROJECT_NAME` + repository variables. +2. Remove the four legacy values only after the workflow is on `main`. +3. Open a same-repository, web-impacting draft at head A. Confirm it gets no + Preview, apply `preview`, let CI succeed for exact head A, and wait for its + deployment to reach `READY`. Record and retain head A's ready URL. +4. Keep `preview` applied and push a web-impacting head B. Let exact-head CI + succeed and wait until B's deployment is `QUEUED`, `INITIALIZING`, or + `BUILDING`. Apply `no-preview`. Confirm the polling owner observes the new + live state and cancels the deployment whose metadata names head B before it + reaches `READY`, while head A's recorded ready URL remains available. +5. Keep `no-preview` applied, make the pull request ready for review, and push a + web-impacting head C. Let exact-head CI succeed and confirm no deployment is + created for C while the label remains. Remove `no-preview`, then confirm a + deployment is created for exact head C. Confirm no new deployment is created + for head B and no SHA other than C is selected by this reconciliation. +6. Open a ready same-repository pull request with only a docs change, let its + exact-head CI succeed, and confirm it receives no automatic Preview. +7. Open a ready fork pull request with a web-impacting change, let its exact-head + CI succeed, and confirm it receives no automatic Preview. Repeat with a + Dependabot pull request if dependency updates are enabled. diff --git a/docs/superpowers/plans/2026-08-21-vercel-preview-deployment-gate.md b/docs/superpowers/plans/2026-08-21-vercel-preview-deployment-gate.md new file mode 100644 index 000000000..06eaff3c9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-vercel-preview-deployment-gate.md @@ -0,0 +1,507 @@ +# Vercel Preview Deployment Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the token-bearing Vercel Ignored Build Step with a trusted, CI-green GitHub dispatcher that creates only eligible same-repository Preview deployments. + +**Architecture:** Vercel keeps automatic production deployment for `main` and disables automatic feature-branch deployment with a minimatch branch map. A default-branch GitHub workflow reconciles pull request state after CI success or an eligibility transition, and a trusted Bun controller validates GitHub and Vercel data with shared Zod schemas before creating or canceling exact Preview deployments. + +**Tech Stack:** Bun 1.3.14, TypeScript 5.9, Zod 4, GitHub Actions, GitHub REST API, Vercel REST API, Bun test. + +**Spec:** `docs/superpowers/specs/2026-08-21-vercel-preview-deployment-gate-design.md` + +## Global Constraints + +- Use Bun only. No npm, pnpm, yarn, turbo. +- Shipped server code must not import Bun built-ins. +- No code comments except functional directives accepted by `bun run check-comments`. +- No em-dash characters in code, docs, commits, branch names, or pull request text. +- No AI attribution anywhere. +- Strict types: no `any`, no non-null assertions, and every external payload is parsed with a Zod schema exported from `@orbit/shared/validators`. +- Tests import from `bun:test`. Shared validator tests mirror `packages/shared/src/validators`; repository scripts use the existing `scripts/*.test.ts` convention. +- No new runtime or development dependency is added. +- The trusted workflow never checks out, fetches, installs, builds, caches, or executes pull request code and never downloads an untrusted artifact. +- Automatic token-backed deployment is limited to open pull requests whose head repository ID equals the base repository ID. +- A Preview requires successful `CI` for the exact current head SHA; `no-preview` wins over `preview`; active ineligible builds are canceled. +- Web-impacting paths are `apps/web/**`, `packages/**`, `package.json`, `bun.lock`, and `tsconfig.base.json`. For a rename, either the current filename or validated `previous_filename` can match. +- Vercel branch suppression uses `"**": false`, not `"*": false`, because unspecified slash-containing branches default to enabled. +- Git Fork Protection stays enabled. Fork Preview automation is out of scope. +- Branch: `chore/gate-preview-builds`. Merge current `origin/main` before implementation and retain both the root script-test command and main's dependency overrides. + +--- + +## File structure map + +- Create `packages/shared/src/validators/vercel-preview.ts`: schemas and inferred types for GitHub events, GitHub pull request/files/workflow responses, Vercel deployment pages and mutations, and controller environment. +- Modify `packages/shared/src/validators/index.ts`: export the new validator module. +- Create `packages/shared/tests/validators/vercel-preview.test.ts`: accepted and rejected external payload shapes. +- Create `scripts/vercel-preview-policy.ts`: pure eligibility, repository, path, state, and deployment-matching rules. +- Create `scripts/vercel-preview-policy.test.ts`: policy truth tables, label precedence, slash-path coverage, and deployment identity. +- Create `scripts/vercel-preview-deploy.ts`: event resolution, bounded HTTP client, GitHub reconciliation, Vercel creation/cancellation, and CLI entry point. +- Create `scripts/vercel-preview-deploy.test.ts`: injected-fetch orchestration tests with no real network calls. +- Create `scripts/vercel-preview-config.test.ts`: repository configuration, managed labels, workflow trust invariants, and removal of the old token gate. +- Create `.github/workflows/vercel-preview.yml`: default-branch metadata-only dispatcher. +- Modify `.github/workflows/ci.yml`: make hosted CI include the two repository verification checks currently omitted from its static job. +- Modify `apps/web/vercel.json`: remove `ignoreCommand`; disable automatic feature branches and retain `main`. +- Modify `scripts/labels.ts`: manage `preview` and `no-preview`. +- Delete `scripts/vercel-build-gate.sh` and `scripts/vercel-build-gate.test.ts`. +- Rewrite `docs/VERCEL_BUILD_GATE.md`: CI-green behavior, setup, security, billing scope, transitions, and canary procedure. +- Modify `docs/README.md`: link the deployment gate guide. +- Modify the pull request body after push: describe the final architecture and tests, remove stale counts and prohibited attribution. + +--- + +### Task 1: Shared schemas and pure Preview policy + +**Files:** +- Create: `packages/shared/src/validators/vercel-preview.ts` +- Modify: `packages/shared/src/validators/index.ts` +- Test: `packages/shared/tests/validators/vercel-preview.test.ts` +- Create: `scripts/vercel-preview-policy.ts` +- Test: `scripts/vercel-preview-policy.test.ts` + +**Interfaces:** +- Produces `githubPreviewPullRequestSchema`, `githubPreviewPullRequestTargetEventSchema`, `githubPreviewWorkflowRunEventSchema`, `githubPreviewRepositoryDispatchEventSchema`, `githubPreviewFilesSchema`, `githubPreviewWorkflowSchema`, `githubPreviewWorkflowRunsSchema`, `githubPreviewRefSchema`, `vercelDeploymentSchema`, `vercelDeploymentsPageSchema`, `vercelCreatedDeploymentSchema`, `vercelDeploymentDetailSchema`, `vercelCanceledDeploymentSchema`, and `vercelPreviewEnvironmentSchema`. +- Produces inferred `GithubPreviewPullRequest`, `GithubPreviewFile`, `VercelDeployment`, and `VercelPreviewEnvironment` types. +- Produces `PREVIEW_LABEL`, `NO_PREVIEW_LABEL`, `isPreviewEligible`, `isSameRepositoryPullRequest`, `isWebPreviewFile`, `isActiveVercelDeployment`, `isReadyVercelDeployment`, and `matchesVercelPullRequest`. +- The controller in Task 2 consumes every interface above and does not define a second copy of validation or policy. + +- [x] **Step 1: Write failing validator tests** + +Create fixtures with a 40-character lowercase SHA and assert the schemas accept a complete open pull request, state event including `closed`, successful CI workflow event, repository-dispatch recovery input, GitHub file page, canonical workflow response, workflow-run page with `total_count`, live Git ref response, Vercel deployment page with `pagination.count`, create response, detail response, cancel response, and complete environment. Add rejection cases for a short SHA, missing repository ID, missing draft state, unknown Vercel ready state, nonnumeric recovery input, invalid pagination, and missing secrets. Workflow-run linked pull requests use GitHub's minimal repository shape with ID and name rather than the full repository shape with `owner.login`. Vercel list items require `projectId` but accept missing metadata as an empty record; mutation and detail responses require `id`, `projectId`, a nonempty URL, target, state, and metadata. + +```ts +expect(githubPreviewPullRequestSchema.parse(pullRequest).head.sha).toBe(SHA); +expect(() => + githubPreviewPullRequestSchema.parse({ + ...pullRequest, + head: { ...pullRequest.head, sha: 'short' }, + }), +).toThrow(); +expect(() => + vercelPreviewEnvironmentSchema.parse({ + GITHUB_EVENT_NAME: 'pull_request_target', + GITHUB_EVENT_PATH: '/tmp/event.json', + }), +).toThrow(); +``` + +- [x] **Step 2: Run the validator test and confirm failure** + +Run: `bun test packages/shared/tests/validators/vercel-preview.test.ts` + +Expected: FAIL because `../../src/validators/vercel-preview.ts` does not exist. + +- [x] **Step 3: Implement the external schemas** + +Use strict discriminants for known event, file, and deployment states, a reusable 40-character lowercase SHA, positive integer identifiers, bounded nonempty strings, and `.passthrough()` only where GitHub or Vercel legitimately returns unrelated fields. A renamed file requires a bounded `previous_filename`; other documented file statuses retain an optional validated value. Deployment IDs use the bounded `^[A-Za-z0-9_-]+$` grammar without trimming. The pull request schema must include this complete decision surface: + +```ts +export const githubPreviewPullRequestSchema = z.object({ + number: z.number().int().positive(), + state: z.enum(['open', 'closed']), + draft: z.boolean(), + labels: z.array(z.object({ name: z.string().min(1).max(100) })), + head: z.object({ + sha: gitShaSchema, + ref: z.string().min(1).max(255), + repo: githubPreviewRepositorySchema, + }), + base: z.object({ + ref: z.string().min(1).max(255), + repo: githubPreviewRepositorySchema, + }), +}); +``` + +The workflow-run schema must retain run `id`, `workflow_id`, `name`, `event`, `head_sha`, `status`, `conclusion`, creation time, and linked pull request head and base identity; the page retains nonnegative `total_count`. The live Git ref schema retains `object.sha`. Vercel metadata accepts string, number, boolean, or null values. Vercel pagination retains nonnegative `count` and finite nonnegative `next` and `prev` cursors or null. Export all inferred types and add `export * from './vercel-preview.ts';` to the validator index. + +- [x] **Step 4: Run the validator tests** + +Run: `bun test packages/shared/tests/validators/vercel-preview.test.ts` + +Expected: PASS. + +- [x] **Step 5: Write failing pure-policy tests** + +Cover this truth table and identity behavior: + +```ts +expect(isPreviewEligible(readyPullRequest)).toBe(true); +expect(isPreviewEligible(draftPullRequest)).toBe(false); +expect(isPreviewEligible(withLabels(draftPullRequest, ['preview']))).toBe(true); +expect(isPreviewEligible(withLabels(readyPullRequest, ['preview', 'no-preview']))).toBe(false); +expect(isSameRepositoryPullRequest(readyPullRequest)).toBe(true); +expect(isSameRepositoryPullRequest(forkPullRequest)).toBe(false); +expect(isWebPreviewFile({ filename: 'apps/web/src/app/page.tsx', status: 'modified' })).toBe(true); +expect(isWebPreviewFile({ filename: 'packages/shared/src/index.ts', status: 'modified' })).toBe(true); +expect(isWebPreviewFile({ + filename: 'docs/feature.ts', + status: 'renamed', + previous_filename: 'apps/web/src/feature.ts', +})).toBe(true); +expect(isWebPreviewFile({ filename: 'docs/README.md', status: 'modified' })).toBe(false); +``` + +Add rename-in, rename-out, and malformed-rename fixtures. Add deployment fixtures proving that a Preview must match repository ID, pull request number, ref, and SHA; a production deployment never matches; `QUEUED`, `INITIALIZING`, and `BUILDING` are active; only `READY` is ready. Reject path delimiters and whitespace in list, create, detail, and cancel IDs while accepting documented system and custom forms. + +- [x] **Step 6: Run the policy tests and confirm failure** + +Run: `bun test scripts/vercel-preview-policy.test.ts` + +Expected: FAIL because `./vercel-preview-policy.ts` does not exist. + +- [x] **Step 7: Implement the pure policy** + +Use fixed label and path values: + +```ts +export const PREVIEW_LABEL = 'preview'; +export const NO_PREVIEW_LABEL = 'no-preview'; + +export function isPreviewEligible(pullRequest: GithubPreviewPullRequest): boolean { + const labels = new Set(pullRequest.labels.map(({ name }) => name.toLowerCase())); + if (labels.has(NO_PREVIEW_LABEL)) return false; + return !pullRequest.draft || labels.has(PREVIEW_LABEL); +} + +function isWebPreviewPath(filename: string): boolean { + return ( + filename.startsWith('apps/web/') || + filename.startsWith('packages/') || + filename === 'package.json' || + filename === 'bun.lock' || + filename === 'tsconfig.base.json' + ); +} + +export function isWebPreviewFile(file: GithubPreviewFile): boolean { + return isWebPreviewPath(file.filename) || + (file.status === 'renamed' && isWebPreviewPath(file.previous_filename)); +} +``` + +`matchesVercelPullRequest` receives the configured project ID, requires exact `projectId` and `target === null`, and compares normalized metadata values for `orbitGithubRepositoryId`, `orbitGithubPrNumber`, `orbitGithubHeadRef`, and, when supplied, `orbitGithubHeadSha`. Production, staging, missing target, another project, and absent metadata never match. + +- [x] **Step 8: Run Task 1 checks and commit** + +Run: `bun test packages/shared/tests/validators/vercel-preview.test.ts scripts/vercel-preview-policy.test.ts` + +Run: `bun run --filter '@orbit/shared' typecheck && bun x tsc -p scripts/tsconfig.json --noEmit` + +Expected: all commands PASS. + +Commit: `feat(ci): define preview deployment policy` + +--- + +### Task 2: Trusted GitHub and Vercel reconciler + +**Files:** +- Create: `scripts/vercel-preview-deploy.ts` +- Test: `scripts/vercel-preview-deploy.test.ts` + +**Interfaces:** +- Consumes all Task 1 schemas, types, constants, and pure policy functions. +- Produces `PreviewRuntime`, `PreviewResult`, and `reconcileVercelPreviews(runtime): Promise`. +- `PreviewRuntime` supplies `env`, `readText`, `fetch`, `sleep`, `scheduleTimeout`, `now`, and `log` so tests never access the network, process secrets, clocks, timers, or real event files. +- `PreviewResult` is a closed discriminated union with `kind: 'skipped' | 'created' | 'canceled'`, a pull request number, and a stable reason. `created` and each `canceled` result include deployment ID and URL; `skipped` results do not. An active poll can return a canceled result when its exact head becomes ineligible, or `stale-event` without cancellation when identity drifts. No serialized result may contain either token. Candidates and canceled results are sorted for deterministic output. +- One monotonic 23-minute controller deadline bounds every request, retry, pagination loop, observation, poll, and sleep beneath the workflow's 25-minute timeout. + +Use this closed reason vocabulary: `event-not-actionable`, `workflow-run-unassociated`, `stale-event`, `repository-mismatch`, `fork-pull-request`, `base-mismatch`, `preview-eligible`, `no-active-deployment`, `web-unaffected`, `ci-unavailable`, `ci-not-current`, `ci-not-green`, `ready-deployment-reused`, `active-deployment-reused`, `created-ready`, and `canceled-active`. Stale candidate identity and pre-creation pull request state changes return `stale-event`. A cancellation abandoned because the live pull request became eligible returns `preview-eligible`. Configuration, malformed external data, incomplete pagination, transport failure, deployment-response identity drift, and terminal build failure throw redacted errors rather than returning a skipped result. + +- [x] **Step 1: Write failing event and eligibility tests** + +Use an injected fetch router that records method, URL, headers, and parsed body. Cover: + +- A successful `workflow_run` for the current ready same-repository head creates one deployment. +- `pull_request_target` ready and `preview` transitions create only when the exact SHA already has a successful CI run. +- Draft without `preview`, either control label combination, stale event SHA, fork head, wrong base, failed CI, in-progress CI, and unrelated files create zero deployments. Rename-in and rename-out changes affecting a web path create, while malformed rename history fails closed. +- `closed`, converted-to-draft, and `no-preview` transitions cancel matching active deployments without requiring CI; a stale state event cannot cancel a different live head. +- `repository_dispatch` parses its pull request input and follows the same live-state and CI checks. +- An empty workflow-run pull request list fails closed because it cannot prove the run's base SHA and repository association. More than one distinct linked pull request also fails closed because the workflow cannot provide per-PR serialization for that event. +- Duplicate workflow-run associations resolve a pull request once, event and embedded PR numbers must agree, and event repository identity must match `GITHUB_REPOSITORY` plus the live base repository. +- GitHub files paginate until a short page, with a 30-page cap and early relevant-file exit. Workflow runs paginate against `total_count` with a 10-page cap. A full final files page or an unexhausted run count fails closed. +- A newer queued, failed, canceled, or stale-base run blocks an older success. Equal creation times use the larger run ID. Wrong canonical workflow path, name, ID, or state fails closed. + +The creation assertion must inspect the exact request: + +```ts +expect(createRequest.method).toBe('POST'); +expect(createRequest.url).toContain('/v13/deployments'); +expect(createRequest.url).not.toContain('forceNew=1'); +expect(createRequest.body).toEqual({ + name: 'orbit', + project: 'prj_orbit', + gitSource: { + type: 'github', + repoId: 123, + ref: 'feature/preview', + sha: SHA, + }, + meta: { + orbitDeploymentReason: 'ci-green-pr-preview', + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubPrNumber: '341', + orbitGithubRepositoryId: '123', + orbitGithubWorkflowRunId: '987654321', + }, +}); +expect(createRequest.body).not.toHaveProperty('target'); +``` + +- [x] **Step 2: Run the focused tests and confirm failure** + +Run: `bun test scripts/vercel-preview-deploy.test.ts --test-name-pattern 'event|eligibility|creates'` + +Expected: FAIL because `./vercel-preview-deploy.ts` does not exist. + +- [x] **Step 3: Implement event resolution and GitHub reconciliation** + +Parse `GITHUB_EVENT_NAME`, read `GITHUB_EVENT_PATH`, and select the matching event schema. Resolve candidates as follows: + +```ts +type PreviewCandidate = { + readonly number: number; + readonly expectedHeadSha: string | null; +}; +``` + +- `pull_request_target`: one candidate from the matching event and embedded PR number plus event head SHA; `closed` and `synchronize` are accepted transitions. +- `workflow_run`: no candidates unless the workflow is `CI`, source event is `pull_request`, conclusion is `success`, action is `completed`, and the payload links exactly one distinct pull request; duplicate links to that same number are deduplicated. Empty or ambiguous linked lists log a stable reason and return no candidates. +- `repository_dispatch`: one candidate from the validated positive integer `client_payload.pull_request` and no expected SHA. + +For every candidate, refetch `/repos/{owner}/{repo}/pulls/{number}` and prove event repository ID and slug, base repository ID, base ref `main`, same-repository head, and exact expected SHA when one exists. A fork or unrelated repository is a no-op. Current closed, draft, or label-ineligible state follows the cancellation path without requiring successful CI. + +For every eligible candidate, resolve `/repos/{owner}/{repo}/actions/workflows/ci.yml` and require exact path `.github/workflows/ci.yml`, name `CI`, and active state. Fetch `/repos/{owner}/{repo}/git/ref/heads/main`, then paginate `/repos/{owner}/{repo}/actions/workflows/{workflowId}/runs?event=pull_request&head_sha={sha}&per_page=100&page=N`. Require a stable `total_count`, stop only when that count is exhausted, and fail after ten pages if results remain. Select the maximum `(created_at, id)` pair before checking conclusion. Require its workflow ID, event, status, conclusion, linked pull request number, head repository ID, ref, and SHA, base repository ID and ref, and linked base SHA equal to the separately fetched live `main` SHA. This prevents an older green run from winning over newer queued, failed, canceled, or stale-base work. + +Query pull request files with `per_page=100&page=N`. Return true as soon as an `isWebPreviewFile` match appears against either the current path or a renamed file's validated previous path. A short page proves no relevant path; a full page at the 30-page cap fails closed. Immediately before a create or cancel mutation, refetch the live pull request. Before create, repeat the live-main and latest-CI proof, then refetch the pull request once more after that proof. Abort the mutation when identity, head, state, labels, base, or CI changed during reconciliation. + +- [x] **Step 4: Write failing idempotency and cancellation tests** + +Cover Vercel pages with an exact deployment on page 2 and prove: + +- Exact `QUEUED`, `INITIALIZING`, `BUILDING`, or `READY` produces no create call. +- Exact `CANCELED`, `ERROR`, `BLOCKED`, or `DELETED` allows exactly one forced create call. +- Same SHA in another project, staging or production target, missing target, repository, pull request, or ref does not suppress creation. +- A list item without metadata is ignored without rejecting its page. +- An ineligible current state cancels every matching active Preview for that PR and does not cancel ready, canceled, errored, staging, production, another project, ref, or repository. +- An existing active deployment is polled instead of duplicated; an existing ready deployment is reused. +- A created or reused active deployment is canceled by its polling owner when the same exact head becomes ineligible before `READY`. When only the SHA changes for the same head ref and pull request identity, the owner cancels its superseded active deployment. Other identity changes return `stale-event` without cancellation. +- Every trusted eligible reconciliation cancels only validated active prior-head deployments with a different valid SHA and exact project, repository, pull request, and head ref metadata. READY prior-head deployments remain available. +- Exact READY wins over duplicate active and terminal items; exact active wins over terminal items. +- Successful create, detail, and cancel responses must retain requested ID, project, null target, and Orbit metadata. Cancel must return `CANCELED`. +- A cancel 400 or ambiguous response reads detail once and accepts a now-terminal state without retrying PATCH; an active or identity-drifted detail fails. +- A second reconciliation after creation sees the created metadata and is a no-op. + +- [x] **Step 5: Run the idempotency tests and confirm failure** + +Run: `bun test scripts/vercel-preview-deploy.test.ts --test-name-pattern 'existing|cancel|duplicate|pagination'` + +Expected: FAIL because deployment listing, matching, and cancellation are not implemented. + +- [x] **Step 6: Implement bounded Vercel reconciliation** + +List `/v7/deployments` with `teamId`, `projectId`, `branch`, `sha` where appropriate, and `limit=100`. The current endpoint has no documented metadata query. Follow validated `pagination.next` through `until`, preserve every original filter, reject repeated cursors including zero, and fail when a non-null cursor remains at the finite page cap. Filter again in trusted code with exact project, null target, and `matchesVercelPullRequest`. Parse list identifiers from `uid`; create, detail, and cancel responses use `id`. Accept `url: null` only on list items. + +Before current-head CI, list the branch without SHA and cancel active prior-head deployments only when their metadata carries a different valid 40-character hexadecimal SHA and exact project, repository ID, pull request number, and head ref. Run this sweep for every trusted current candidate so `workflow_run` and `repository_dispatch` repair a missed `synchronize` cleanup. Leave every non-active state, including READY, unchanged. + +For eligible PRs, prefer any exact ready deployment, then any exact active deployment, then terminal history. Fetch detail when a READY list item has no URL. Poll an active deployment immediately, then allow at most 240 five-second sleeps followed by a final GET. After every active detail response, refetch live pull request identity and eligibility. If the same exact head is now closed or ineligible, cancel only that exact deployment and return its canceled result. If only the SHA changed for the same pull request identity and head ref, cancel the superseded active deployment. Other identity drift returns `stale-event` without canceling. Keep per-PR serialization and `cancel-in-progress: false`. The 23-minute controller deadline may stop this sequence earlier. Require ID, project, null target, and Orbit metadata on every detail. `READY` with a nonempty URL succeeds; `ERROR`, `CANCELED`, `BLOCKED`, or `DELETED` fails; an active final response times out. + +When no ready or active exact deployment exists, create one deployment with `POST /v13/deployments?teamId={teamId}`, omitted `target`, exact Git source, and the metadata shown in Step 1. Add `forceNew=1` only when the complete pre-create list already contained an exact terminal deployment. Record all pre-create deployment IDs and enforce one POST per reconciliation. + +Immediately before every Create or Cancel mutation, read `/v9/projects/{projectId}?teamId={teamId}` and validate the response. Require its project ID, project name, and account ID to equal the configured project ID, project name, and team ID. Repeat this proof at each mutation boundary so a long poll cannot rely on stale project ownership. + +An ambiguous create outcome is a network error, timeout, 429, 5xx, 409, or successful response that cannot be parsed, validated, or matched to the requested identity. Ordinary 4xx responses are definitive. After ambiguity, set `createAttempted` and make a second POST impossible. Run three exact-list observation attempts separated by two seconds. Reuse only a newly visible ready or active ID that was not in the pre-create set. A new terminal deployment or no new exact ID fails visibly. A later event starts a new reconciliation from a complete list and may decide independently. + +For current ineligible state, list with `teamId`, `projectId`, `branch`, and `limit=100` without SHA, then call `PATCH /v12/deployments/{id}/cancel?teamId={teamId}` only for locally matched active Preview deployments. Refetch and revalidate live pull request identity and ineligibility immediately before every individual PATCH. Never retry PATCH. Validate a successful cancel as the requested ID, project, null target, Orbit metadata, and `CANCELED`, then emit one `canceled-active` result. After a 400 or ambiguous PATCH, read v13 detail once; accept `CANCELED`, `READY`, or another terminal state as a completed race with no active spend, and fail if the deployment remains active or its identity drifted. Race-only reconciliation emits `no-active-deployment`; mixed reconciliation emits results only for deployments actually canceled. A CI failure does not cancel a previously ready Preview; cancellation is driven by current pull request state. + +- [x] **Step 7: Write failing transport and secret-safety tests** + +Cover missing configuration, 401, ordinary 403, rate-limited GitHub 403, 429 with delta-seconds and HTTP-date `Retry-After`, 500, network failure, body-read failure, a real injected timeout abort, redirects, invalid JSON, invalid schema, endpoint-specific exhausted or repeated pagination, polling timeout, every terminal build state, and malformed create/detail/cancel responses. Assert safe GET requests use at most three total attempts; ordinary 401/403 do not retry; explicit GitHub rate-limit 403, 429, 5xx, network failure, body-read failure, and timeout may retry within the same cap; and excessive waits fail rather than sleeping without bound. + +Assert network, timeout, 429, 500, invalid-success body, and 409 create outcomes each perform one POST total and enter the three-attempt exact-list observation. Active or ready visibility is reused; terminal or absent visibility fails. Assert definitive 400, 401, 403, and 422 create responses send no reconciliation retry and no second POST. Cover cancel 400 plus ambiguous PATCH network, 429, 5xx, and invalid-success outcomes with one PATCH and one detail reconciliation. Assert exact mutation, observation, detail, and bounded-sleep counts. No error body, thrown error, log line, URL, request summary, external deployment ID, or serialized result may contain either token. + +- [x] **Step 8: Implement the bounded JSON client and CLI entry point** + +The JSON client applies a fresh 15-second AbortController per request through the injected timer scheduler and clears its timer after body consumption. It rejects redirects and sends a fixed `User-Agent`; GitHub requests also send `Accept: application/vnd.github+json` and `X-GitHub-Api-Version: 2022-11-28`. Parse response text as JSON, validate with the supplied shared schema, and throw only a token-redacted bounded error. Never include headers or raw external bodies in errors. Validate every deployment ID with the shared safe grammar, check it against both tokens before use, and encode every dynamic Vercel deployment path segment. + +Safe GET reads retry network errors, per-attempt timeout, 429, 5xx, and GitHub 403 only with explicit rate-limit evidence. Cap total read attempts at three. Parse delta-seconds and HTTP-date `Retry-After` using injected `now`, bound any sleep to 30 seconds, and treat invalid or excessive waits as failure. Mutations are single-attempt and use the explicit create-observation or cancel-detail rules from Step 6 instead of transport retries. + +The executable path uses `Bun.file(path).text()`, global `fetch`, a timer-backed sleep, and `console.log`. Guard it with `if (import.meta.main)`, set `process.exitCode = 1` on error, and print only the redacted error message. + +- [x] **Step 9: Run Task 2 checks and commit** + +Run: `bun test scripts/vercel-preview-deploy.test.ts` + +Run: `bun x tsc -p scripts/tsconfig.json --noEmit && bun run lint -- scripts/vercel-preview-deploy.ts scripts/vercel-preview-deploy.test.ts` + +Expected: all commands PASS. + +Commit: `feat(ci): deploy previews after successful checks` + +--- + +### Task 3: Trusted workflow, Vercel configuration, labels, and operations guide + +**Files:** +- Create: `.github/workflows/vercel-preview.yml` +- Modify: `.github/workflows/ci.yml` +- Modify: `apps/web/vercel.json` +- Modify: `scripts/labels.ts` +- Delete: `scripts/vercel-build-gate.sh` +- Delete: `scripts/vercel-build-gate.test.ts` +- Create: `scripts/vercel-preview-config.test.ts` +- Rewrite: `docs/VERCEL_BUILD_GATE.md` +- Modify: `docs/README.md` + +**Interfaces:** +- Consumes `scripts/vercel-preview-deploy.ts` as the only workflow command. +- Requires secret `VERCEL_TOKEN` and variables `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and `VERCEL_PROJECT_NAME`. +- Uses immutable `actions/checkout` SHA `3d3c42e5aac5ba805825da76410c181273ba90b1` and immutable `oven-sh/setup-bun` SHA `0c5077e51419868618aeaa5fe8019c62421857d6`. +- Produces managed labels named exactly `preview` and `no-preview`. + +- [x] **Step 1: Write failing repository configuration tests** + +Parse `apps/web/vercel.json`, read the workflow and docs as text, and import `LABELS`. Assert the branch map and removal checks below, then use anchored assertions over the intentionally fixed workflow structure rather than global word searches: + +```ts +expect(vercel.git.deploymentEnabled).toEqual({ '**': false, main: true }); +expect(vercel).not.toHaveProperty('ignoreCommand'); +expect(LABELS.filter(({ name }) => name === 'preview')).toHaveLength(1); +expect(LABELS.filter(({ name }) => name === 'no-preview')).toHaveLength(1); +expect(allGateFiles).not.toContain('BUILD_GATE_GITHUB_TOKEN'); +``` + +Require the exact top-level read-only permissions; only the three intended triggers and their exact activity types; no `workflow_dispatch`; the workflow-run job guard for a `pull_request` source and `success` conclusion; `timeout-minutes: 25`; and `cancel-in-progress: false`. Require the exact immutable checkout and setup-Bun SHAs, checkout of `${{ github.repository }}` at `${{ github.sha }}`, disabled persisted credentials, submodules, and LFS, Bun 1.3.14, and `bun install --frozen-lockfile --ignore-scripts`. Prove no cache or artifact-download action exists, no pull request head/ref expression can select checkout code, and the controller is the only run step receiving `VERCEL_TOKEN`. + +Assert the `static` job, rather than merely the whole CI file, contains named `bun run check-bytes` and `bun run check-bun-imports` steps. Assert each managed label description matches its executable semantics: `preview` enables an otherwise eligible draft after CI, while `no-preview` suppresses creation and cancels active Preview work. Assert the deployment map is exactly `{ '**': false, main: true }`, with the wildcard rule before the `main` override, without claiming that the test executes Vercel's glob matcher. + +- [x] **Step 2: Run the configuration test and confirm failure** + +Run: `bun test scripts/vercel-preview-config.test.ts` + +Expected: FAIL because the workflow and managed labels do not exist and `ignoreCommand` remains. + +- [x] **Step 3: Replace the Vercel gate and manage labels** + +Remove `ignoreCommand` from `apps/web/vercel.json` and add: + +```json +"git": { + "deploymentEnabled": { + "**": false, + "main": true + } +} +``` + +Add `preview` and `no-preview` beside the status labels in `scripts/labels.ts`, with descriptions matching the design. Delete the old shell gate and its tests. Keep the root `bun test scripts` command so all new script tests remain part of CI. + +Add named `Source byte policy` and `No Bun built-ins in shipped server code` steps to the `static` job in `.github/workflows/ci.yml`, invoking `bun run check-bytes` and `bun run check-bun-imports`. This makes the canonical `CI` success used by the dispatcher cover every non-test verification command from `bun run verify`. + +- [x] **Step 4: Add the default-branch workflow** + +Create a workflow named `Vercel Preview` with this trigger and trust boundary: + +```yaml +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled, unlabeled, closed] + workflow_run: + workflows: [CI] + types: [completed] + repository_dispatch: + types: [vercel-preview-reconcile] + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: vercel-preview-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pull_request || github.event.workflow_run.head_sha || github.run_id }} + cancel-in-progress: false +``` + +Keep `cancel-in-progress: false`: canceling a controller after an ambiguous Create could lose its read-only observation and permit a later duplicate attempt. Timely ineligibility and same-ref head supersession are handled inside the polling owner, which refetches exact live identity and eligibility after every active detail response. + +The controller rejects a workflow run linked to more than one distinct pull request, so the first linked number is used only for an event that resolves to one PR. Recovery requires a positive numeric `client_payload.pull_request`, which shares the same per-PR group as state and CI events; malformed recovery input may form an unused group but is rejected before any Vercel call. The job condition allows state events and repository dispatch, and allows a workflow run only when `github.event.workflow_run.event == 'pull_request'` and its conclusion is success. Set `timeout-minutes: 25`; the controller's own 23-minute deadline remains the primary bound. GitHub documents that `repository_dispatch` uses the last commit on the default branch, unlike `workflow_dispatch`, which can run a workflow version from a selected non-default ref. Checkout the trusted default-branch workflow commit using the pinned checkout action, `repository: ${{ github.repository }}`, `ref: ${{ github.sha }}`, `persist-credentials: false`, `submodules: false`, and `lfs: false`. Set up Bun 1.3.14 with the pinned setup action, run `bun install --frozen-lockfile --ignore-scripts`, then run `bun scripts/vercel-preview-deploy.ts` with tokens and settings scoped only to that controller step through `env`. + +- [x] **Step 5: Rewrite the operations guide and documentation index** + +Document the exact eligibility table, CI-green timing, same-repository restriction, active cancellation including closed pull requests, web path list, Vercel API behavior, GitHub secret and variables, label synchronization, Git Fork Protection, the deployment-count caveat, the repository-controlled cost-policy limitation, and removal of all four old `BUILD_GATE_*` Vercel values. State precisely that only the trusted GitHub controller is isolated from pull request code: the API-created Vercel Preview still builds same-repository pull request code with the project's Preview environment scope. Manual recovery uses a maintainer-authenticated `repository_dispatch` named `vercel-preview-reconcile` with numeric `client_payload.pull_request`; explicitly forbid `workflow_dispatch` because a caller can select a non-default ref. Link the guide from `docs/README.md` under the contributor/operations entries. + +Make the post-merge canary an ordered procedure: confirm Git Fork Protection and configure the secret plus three variables; remove the four legacy values only after the workflow is on `main`; open a same-repository web-impacting draft and prove no Preview until `preview` is applied and exact-head CI succeeds; on a new active head, add `no-preview` and prove the polling owner cancels that exact deployment before `READY` while an older ready URL remains; make the PR ready, push a new relevant head, and prove only that exact SHA deploys; then repeat with a docs-only change and a fork and prove neither gets an automatic Preview. + +Do not claim that ignored builds are free, that Ready alone is trust, that fork previews are automatic, or that the privileged workflow can be exercised before it exists on `main`. + +- [x] **Step 6: Run Task 3 checks and commit** + +Run: `bun test scripts/vercel-preview-config.test.ts scripts/vercel-preview-policy.test.ts scripts/vercel-preview-deploy.test.ts packages/shared/tests/validators/vercel-preview.test.ts` + +Run: `bun run lint && bun run check-comments && bun run check-bytes && bun run check-bun-imports && bun run typecheck` + +Expected: all commands PASS. + +Run: `rg -n 'BUILD_GATE_|vercel-build-gate|Generated[[:space:]]with' apps/web/vercel.json scripts .github/workflows/vercel-preview.yml` + +Run: `rg -n 'Generated[[:space:]]with' docs/VERCEL_BUILD_GATE.md docs/README.md` + +Expected: no old gate setting, prohibited attribution, or em-dash match. A link or historical plan outside this task's changed files is not edited. + +Commit: `chore(vercel): gate previews after CI` + +--- + +### Task 4: Full verification and existing pull request handoff + +**Files:** +- Modify only files required by current-main conflict resolution or a verification failure caused by this branch. +- Update remote pull request 341 body and review-thread replies after the branch is verified and pushed. + +**Interfaces:** +- Consumes the complete branch from Tasks 1 through 3. +- Produces a branch merged with current `origin/main`, a green local verification record, an updated remote branch, an accurate PR body, and resolved addressed threads. + +- [x] **Step 1: Merge the latest main and rerun focused tests** + +Run: `git fetch origin main && git merge --no-edit origin/main` + +Resolve `package.json` by retaining `"test": "bun test scripts && bun run --filter '*' test"` and every current-main override. Do not touch the dirty ordinary checkout. + +Run: `bun install --frozen-lockfile` + +Run: `bun test scripts/vercel-preview-config.test.ts scripts/vercel-preview-policy.test.ts scripts/vercel-preview-deploy.test.ts packages/shared/tests/validators/vercel-preview.test.ts` + +Expected: PASS. + +- [ ] **Step 2: Run the complete repository verification** + +Run: `ORBIT_TEST_LANE=preview-gate bun run verify` + +Expected: lint, comment policy, source-byte check, Bun-import check, dependency dedupe, all typechecks, and all tests PASS. If a database service is unavailable, start the repository's existing infrastructure and initialize only the isolated `preview-gate` test lane before rerunning. + +- [x] **Step 3: Review the final diff and operational text** + +Run: `git diff --check origin/main...HEAD` + +Run: `git diff --stat origin/main...HEAD && git log --oneline origin/main..HEAD` + +Run the attribution and em-dash scan against every changed text file. Run the four legacy-setting scan against changed code and workflow files, excluding `docs/VERCEL_BUILD_GATE.md`, where the removal instructions intentionally name them. + +Expected: no whitespace error, prohibited attribution, em dash, or old token/config reference in changed files except the operations guide's explicit removal instructions for the four legacy setting names. + +- [x] **Step 4: Push and update pull request 341** + +Push `chore/gate-preview-builds` after all local checks pass. Replace the PR body with the final motivation, architecture, security boundary, setup requirements, test evidence, and the honest post-merge canary limitation. Remove the stale test count and all attribution. + +Reply to the unresolved event-trigger review thread with the trusted workflow and exact event paths. Reply to the old Greptile thread with the final test coverage. Resolve a thread only when its cited issue is demonstrably addressed by the pushed diff. + +- [ ] **Step 5: Inspect hosted checks and merge readiness** + +Wait for CodeRabbit, Greptile, GitHub Actions, and Vercel/GitHub deployment checks to complete. Reconcile every current head comment and check. Do not merge while a required check is red, a current thread is unresolved, the branch is behind `main`, or a human approval is still required. + +The PR is ready to merge when current-main ancestry, hosted checks, review threads, required approval, labels, GitHub secret/variables, and the documented Vercel project settings are all confirmed. The privileged deployment behavior receives its real canary only after the workflow exists on `main`. diff --git a/docs/superpowers/specs/2026-08-21-vercel-preview-deployment-gate-design.md b/docs/superpowers/specs/2026-08-21-vercel-preview-deployment-gate-design.md new file mode 100644 index 000000000..3ac5fe919 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-vercel-preview-deployment-gate-design.md @@ -0,0 +1,263 @@ +# Vercel Preview Deployment Gate Design + +## Problem + +Orbit currently lets Vercel create a Preview deployment for every pushed commit. Most pull +request commits do not need a live preview, and failed commits should not consume Vercel build +minutes. Pull request 341 attempted to reduce that cost with a repository-provided Ignored Build +Step that reads pull request state using a GitHub token. + +That design has three structural problems. Vercel runs the ignored command from the pull request +checkout with environment access, so the pull request controls code that receives the GitHub +token. Ready-for-review and label changes do not create a Vercel deployment, so they cannot act as +the promised preview button. Ignored builds also still count as deployments and occupy concurrent +build capacity, even when they avoid the application build. + +## Outcome + +Vercel automatically deploys `main`, but does not automatically deploy feature branches. A +trusted GitHub Actions workflow creates a Preview deployment only when all of these conditions are +true for the current pull request head: + +- The pull request comes from the Orbit repository rather than a fork or Dependabot. +- The pull request is ready for review, or carries the managed `preview` label. +- The pull request does not carry the managed `no-preview` label. +- The `CI` workflow completed successfully for the exact head SHA. +- At least one changed file affects the web deployment. +- No active or ready Preview deployment already exists for the same pull request and SHA. + +The `no-preview` label wins when both control labels are present. Converting a pull request back to +draft also makes it ineligible unless `preview` remains present. When a state change makes a pull +request ineligible, the workflow cancels active Preview deployments for that pull request but does +not delete completed previews. The reconciler that owns an active deployment poll checks live pull +request state after every active detail response, so it can cancel its exact deployment while a +later state event waits in the serialized workflow group. + +This design optimizes for avoided Vercel application builds. The small GitHub metadata job still +uses GitHub Actions time, and an API-created eligible preview is still a normal Vercel deployment. + +## Deployment ownership + +`apps/web/vercel.json` replaces the Ignored Build Step with a Git deployment rule: + +```json +{ + "git": { + "deploymentEnabled": { + "**": false, + "main": true + } + } +} +``` + +The minimatch catch-all prevents automatic Preview deployments, including branch names containing +slashes. The explicit `main` rule preserves the existing production path. Vercel documents that a +true match wins when more than one branch rule matches, so `main` remains enabled even though it +also matches `**`. Unspecified branches default to enabled, which is why a single-star pattern is +not sufficient. + +This repository setting is an operational cost policy, not a security boundary. A same-repository +author can change `vercel.json` in a branch, and Vercel does not document that the deployment rule +is always read from the default branch. Git Fork Protection remains the security boundary for +forks. A future move to a disconnected or dedicated Preview project can make automatic-deployment +suppression independent of pull request contents; that external migration is not required to +remove the token exposure or to stop normal feature-branch builds in this change. + +The repository removes `scripts/vercel-build-gate.sh` and its tests. The trusted GitHub controller +never exposes `VERCEL_TOKEN` to a pull request checkout, and the gate no longer depends on Vercel +system environment variables or an ignored-build exit-code convention. The Preview deployment +still builds same-repository pull request code inside Vercel and can receive that project's Preview +environment values. Same-repository authors therefore remain inside the Vercel project trust +boundary. + +## Trusted workflow + +`.github/workflows/vercel-preview.yml` runs from the default branch through three trusted event +paths: + +- `pull_request_target` handles `opened`, `reopened`, `synchronize`, `ready_for_review`, + `converted_to_draft`, `labeled`, `unlabeled`, and `closed` state transitions. +- `workflow_run` handles a completed `CI` workflow and proceeds only when the source event was a + pull request and the conclusion was success. +- `repository_dispatch` accepts the custom `vercel-preview-reconcile` event with a pull request + number for maintainer recovery when an event was missed or a deployment needs to be retried. + +The workflow checks out only the trusted workflow SHA from the default branch with persisted Git +credentials disabled. It never +fetches, checks out, installs, builds, caches, or executes the pull request head, and it never +downloads an artifact from the untrusted CI run. Pull request data appears only in environment +values and API request bodies, never as shell source. + +Permissions are limited to `actions: read`, `contents: read`, and `pull-requests: read`. The Vercel +token is a GitHub Actions secret. Team ID, project ID, and project name are Actions variables. +Concurrency is keyed by pull request number when the event supplies one and does not cancel an +in-progress reconciler. Recovery dispatches require a bounded positive numeric pull request input +and share that pull request's group. Vercel metadata checks provide a second idempotency boundary. +A polling owner refetches the current pull request between active deployment reads. It cancels only +the deployment whose validated metadata names the same pull request head when that exact identity +becomes closed or ineligible. It also cancels that deployment when only the head SHA is superseded +and the pull request, repositories, base, and head ref still match. Other identity drift ends the old +poll without mutation. This keeps one Create POST and per-pull-request serialization while allowing +timely cost cancellation. +A workflow-run payload with no linked pull request, or with more than one distinct linked pull +request, is rejected because one Actions concurrency group cannot serialize multiple pull requests +and the event cannot prove one unambiguous base association. + +## Controller and validation + +`scripts/vercel-preview-deploy.ts` is the only executable controller. It imports Zod schemas from +`@orbit/shared/validators` and validates the GitHub event payload, every GitHub response, every +Vercel response, and configuration before using them. + +The controller resolves one pull request number from an actionable event, then refetches that pull +request from GitHub. Duplicate links to the same pull request are deduplicated. An event is stale +when its recorded head SHA no longer equals the live pull request head. Stale events are no-ops. +Fork heads and pull requests targeting another repository or branch are also no-ops. A proven +closed pull request follows the active-cancellation path without requiring CI. + +Before CI, every trusted current-candidate reconciliation lists the branch and cancels only active +prior-head deployments with a different valid 40-character hexadecimal SHA and exact project, +repository ID, pull request number, and head ref metadata. This lets `synchronize` cancel promptly +and lets later CI or recovery events repair a missed cleanup. READY prior-head deployments remain +unchanged. + +For every eligible event, the controller resolves the canonical `.github/workflows/ci.yml`, fetches +the live `main` ref, and queries every bounded page of runs for the exact head SHA. It selects the +maximum creation time and run ID rather than filtering to successful runs. The run must be +completed successfully and its linked pull request must match the current number, repository IDs, +head ref and SHA, base ref, and live base SHA. A newer queued, failed, canceled, or stale-base run +blocks deployment even when an older green run exists. The triggering `workflow_run` is only a +wake-up signal and is not trusted as proof. Immediately before mutation, the controller refetches +live pull request state and repeats the current CI proof. It then refetches the pull request once +more after that proof and requires the exact identity, head, state, and labels to remain eligible +before the Create POST, so a transition during the proof cannot authorize stale work. + +Changed files are read from the paginated pull request files endpoint. Each item retains its +validated GitHub status, and a renamed item must include a bounded `previous_filename`. The web +deployment is affected when either the current filename or, for a rename, the previous filename is +below `apps/web/` or `packages/`, or is exactly `package.json`, `bun.lock`, or +`tsconfig.base.json`. Configuration is fixed in trusted code rather than split between Vercel and +GitHub settings. + +GitHub and Vercel requests have a finite timeout. One monotonic 23-minute reconciliation deadline +also bounds every request, retry, pagination loop, observation, poll, and sleep beneath the +workflow's 25-minute job limit. Safe reads use bounded retries for network failures, server +failures, 429 responses, and 403 responses carrying explicit GitHub rate-limit evidence. Mutations +are never retried blindly. Authentication failures, malformed payloads, exhausted pagination, and +unsuccessful mutations fail the workflow visibly. Redirects are rejected for token-bearing +requests, and logs never contain either token. + +## Vercel API contract + +Before mutation, the controller lists deployments through Vercel's current `/v7/deployments` +endpoint, using the configured project plus branch and SHA filters where applicable. It filters +again by exact project ID, null Preview target, and namespaced Orbit metadata for repository ID, +pull request number, branch ref, and commit SHA. List metadata may be absent on unrelated historical +deployments. Pagination is bounded, repeated cursors are rejected, and every page is validated. +Deployment IDs must contain only ASCII letters, digits, underscores, and hyphens within the fixed +length bound. Every ID is checked against both tokens before use, and every dynamic deployment path +segment is URL encoded. + +Before each Create or Cancel mutation preflight, the controller reads `/v9/projects/{projectId}` +through the configured team scope. The validated project ID, name, and account ID must match the +configured project and team, including after a long deployment poll. Create then re-proves current +CI, refreshes pull request state, and re-reads `main` immediately before POST. Cancel refreshes the +path-specific eligibility or superseded-head intent immediately before PATCH. + +If an exact deployment is ready, it is reused. An exact queued, initializing, or building +deployment is polled rather than duplicated. If no such deployment exists, the controller calls +Vercel's Create Deployment endpoint with the linked GitHub repository ID, exact branch ref, and +exact SHA. `target` is omitted so Vercel selects the Preview environment. Namespaced metadata +repeats the repository, pull request, branch, SHA, workflow run ID, and reason so later runs can +identify the deployment without guessing. `forceNew=1` is used only when a new reconciliation has +already observed an exact terminal failed deployment. Create, detail, and cancel responses must +prove deployment ID, project ID, null Preview target, state, and Orbit metadata. The workflow polls +the created deployment immediately and then through at most 240 five-second sleeps plus one final +GET to a ready or terminal state, requiring a nonempty final URL. After every active detail, the +polling owner refetches the pull request. If the same exact identity is now ineligible, it cancels +only that deployment and returns a canceled result. If only the SHA changed for the same head ref +and pull request identity, it cancels the superseded active deployment. Other identity changes +return a stale result without canceling. The 23-minute controller deadline may stop this sequence +earlier. + +Create Deployment has no idempotency key. After a network error, timeout, 429, 5xx, 409, or an +unparseable success response, the controller marks the one POST as attempted and performs only a +short bounded exact-list observation. A newly visible ready or active deployment is reused; a +terminal deployment or no visible new deployment fails the workflow. The same reconciliation never +sends a second create request. + +When current pull request state is ineligible, active deployments associated with that pull +request are canceled through Vercel's cancel endpoint. The filter requires the configured project, +null Preview target, repository ID, pull request number, and branch ref before cancellation. Ready, +failed, and canceled deployments are left unchanged. Live pull request identity and ineligibility +are refetched immediately before every individual cancellation mutation. A 400 or ambiguous cancel +response is followed by one validated detail read so a normal transition to a terminal state is not +mistaken for an unsafe retry. + +## Fork policy + +The token-backed workflow never creates a deployment for a fork or a Dependabot pull request. +GitHub treats Dependabot workflows as fork-equivalent and withholds Actions secrets from the source +run, while a later `workflow_run` can receive those secrets. The controller therefore validates the +live pull request author and rejects Dependabot before any Vercel request. This preserves the +security boundary Vercel documents for Git Fork Protection: unreviewed code must not automatically +receive Preview environment variables or OIDC authority. + +Fork previews remain a manual maintainer decision. A maintainer can use Vercel's explicit Git +reference deployment flow after reviewing the code, or deploy into a separate Preview project +whose environment has no sensitive values. The `preview` label does not authorize a fork in this +change. Automating fork previews requires a separate design for a credential-free project and an +auditable maintainer approval signal. + +## Labels + +`preview` and `no-preview` become fixed entries in `scripts/labels.ts`. This makes the documented +controls available in GitHub and prevents `scripts/sync-labels.ts --prune` from deleting them. + +- `preview`: build a Preview for an otherwise eligible draft after CI succeeds. +- `no-preview`: suppress Preview creation and cancel an active Preview build. + +## Setup and operations + +The repository requires these GitHub Actions settings: + +- Secret `VERCEL_TOKEN`, scoped to the team that owns the Orbit project. +- Variable `VERCEL_TEAM_ID`. +- Variable `VERCEL_PROJECT_ID`. +- Variable `VERCEL_PROJECT_NAME`, currently `orbit`. + +After the new flow is merged, the old `BUILD_GATE_GITHUB_TOKEN`, +`BUILD_GATE_WATCH_PATHS`, `BUILD_GATE_READY_LABEL`, and `BUILD_GATE_BLOCK_LABEL` values should be +removed from Vercel. Git Fork Protection stays enabled. + +The workflow is defined by the default branch, so pull request 341 can prove its controller and +workflow structure locally but cannot exercise the new privileged event path until the workflow +has landed on `main`. The first same-repository test pull request after merge is the production +canary. It starts as a web-impacting draft with no Preview, applies `preview` and waits for exact-head +CI plus one Preview, then removes the label or applies `no-preview` to prove active cancellation +by the polling owner without deleting a ready URL. A new ready head proves exact-SHA behavior. +Separate docs-only and fork cases prove that neither receives an automatic Preview. + +## Tests + +Shared validator tests cover accepted payloads and rejection of missing identifiers, invalid SHAs, +unknown states, malformed pagination, malformed rename history, and unsafe deployment IDs. + +Controller tests use injected fetch and delay functions. They cover ready and draft policy, +control-label precedence, state transitions, CI success for the exact SHA, stale events, closed +pull requests, fork refusal, relevant and irrelevant paths, GitHub pagination, Vercel pagination, +existing active and ready deployments, exact project and Preview identity, one exact create, +poll-owner cancellation and head drift, post-CI state races, ambiguous create observation, active +cancellation races, bounded retries, real abort timeouts, network and body-read failures, invalid +JSON, invalid response shapes, token-safe results, and missing settings. + +Repository checks cover the branch deployment map, the managed labels, removal of the old ignored +command and token references, discovery of the script tests by the root test command, and the two +source-policy checks that hosted CI previously omitted from `bun run verify`. + +## Out of scope + +Automatic fork previews, a new credential-free Vercel project, deletion of completed previews, +promotion of a Preview to production, application database migrations, and changes to the main +production deployment path are outside this change. diff --git a/package.json b/package.json index f4d3c93e6..42ebe362c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build": "bun run --filter '*' build", "dev": "bun run --filter '*' dev", "typecheck": "tsc -p scripts/tsconfig.json --noEmit && bun run --filter '*' typecheck", - "test": "bun test ./tests/*.test.ts && bun run --filter '*' test", + "test": "bun test scripts && bun test ./tests/*.test.ts && bun run --filter '*' test", "test:e2e": "bun run --filter '@orbit/web' test:e2e", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/packages/services/tests/notifications/notifications.test.ts b/packages/services/tests/notifications/notifications.test.ts index 8f35f6acc..11beed9c4 100644 --- a/packages/services/tests/notifications/notifications.test.ts +++ b/packages/services/tests/notifications/notifications.test.ts @@ -550,10 +550,12 @@ describe('notifyMany', () => { await withRollback(async (tx) => { const fixture = await seed(tx); await seedSlackDmConnection(tx, fixture); + const now = new Date('2026-07-22T12:00:00Z'); await notifyMany(tx, [eventFor(fixture, { userIds: [fixture.adaId], reason: 'mentioned' })], { + now, slackEnabled: true, }); - const [delivery] = await claimSlackDmDeliveries(tx, 10, new Date()); + const [delivery] = await claimSlackDmDeliveries(tx, 10, now); if (delivery === undefined) throw new Error('Expected an unavailable Slack DM claim.'); await markSlackDmUnavailable( tx, @@ -561,7 +563,7 @@ describe('notifyMany', () => { delivery.claimedAt ?? new Date(0), 'missing_scope', ); - expect(await claimSlackDmDeliveries(tx, 10, new Date())).toHaveLength(0); + expect(await claimSlackDmDeliveries(tx, 10, now)).toHaveLength(0); const [stored] = await tx .select({ status: notificationDelivery.status, lastError: notificationDelivery.lastError }) .from(notificationDelivery) diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ade3ba6b8..e9bf5e11d 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -18,6 +18,7 @@ export * from './profile.ts'; export * from './project.ts'; export * from './team.ts'; export * from './upload.ts'; +export * from './vercel-preview.ts'; export * from './view.ts'; export * from './view-preference.ts'; export * from './vitals.ts'; diff --git a/packages/shared/src/validators/vercel-preview.ts b/packages/shared/src/validators/vercel-preview.ts new file mode 100644 index 000000000..61b6c42f3 --- /dev/null +++ b/packages/shared/src/validators/vercel-preview.ts @@ -0,0 +1,286 @@ +import { z } from 'zod'; + +const boundedString = (maximum: number) => z.string().trim().min(1).max(maximum); +const positiveIntegerSchema = z.number().int().positive(); +const githubPullRequestNumberSchema = positiveIntegerSchema.max(2_147_483_647); +const nonnegativeCursorSchema = z.number().finite().int().nonnegative().nullable(); +const nonnegativeIntegerSchema = z.number().finite().int().nonnegative(); +const githubWorkflowConclusionSchema = z + .enum([ + 'success', + 'failure', + 'neutral', + 'cancelled', + 'skipped', + 'timed_out', + 'action_required', + 'startup_failure', + 'stale', + ]) + .nullable(); +const githubWorkflowRunStatusSchema = z.enum([ + 'queued', + 'in_progress', + 'completed', + 'waiting', + 'requested', + 'pending', +]); +const vercelMetadataValueSchema = z.union([z.string(), z.number().finite(), z.boolean(), z.null()]); +const vercelDeploymentIdSchema = z + .string() + .min(1) + .max(100) + .regex(/^[A-Za-z0-9_-]+$/); +const vercelTargetSchema = z.enum(['production', 'staging']).nullable(); +const vercelReadyStateSchema = z.enum([ + 'QUEUED', + 'INITIALIZING', + 'BUILDING', + 'READY', + 'ERROR', + 'CANCELED', + 'BLOCKED', + 'DELETED', +]); + +export const gitShaSchema = z.string().regex(/^[a-f0-9]{40}$/); + +export const githubPreviewRepositorySchema = z + .object({ + id: positiveIntegerSchema, + name: boundedString(100), + owner: z.object({ login: boundedString(100) }), + }) + .passthrough(); +export type GithubPreviewRepository = z.infer; + +export const githubPreviewPullRequestSchema = z.object({ + number: positiveIntegerSchema, + state: z.enum(['open', 'closed']), + draft: z.boolean(), + labels: z.array(z.object({ name: boundedString(100) })).max(100), + user: z.object({ login: boundedString(100) }).passthrough(), + head: z.object({ + sha: gitShaSchema, + ref: boundedString(255), + repo: githubPreviewRepositorySchema, + }), + base: z.object({ + ref: boundedString(255), + repo: githubPreviewRepositorySchema, + }), +}); +export type GithubPreviewPullRequest = z.infer; + +export const githubPreviewRefSchema = z + .object({ object: z.object({ sha: gitShaSchema }).passthrough() }) + .passthrough(); +export type GithubPreviewRef = z.infer; + +export const githubPreviewWorkflowSchema = z + .object({ + id: positiveIntegerSchema, + name: boundedString(255), + path: z + .string() + .regex(/^\.github\/workflows\/.+\.ya?ml$/) + .max(1024), + state: z.enum([ + 'active', + 'deleted', + 'disabled_fork', + 'disabled_inactivity', + 'disabled_manually', + ]), + }) + .passthrough(); +export type GithubPreviewWorkflow = z.infer; + +export const githubPreviewPullRequestTargetEventSchema = z + .object({ + action: z.enum([ + 'opened', + 'reopened', + 'synchronize', + 'ready_for_review', + 'converted_to_draft', + 'labeled', + 'unlabeled', + 'closed', + ]), + number: positiveIntegerSchema, + pull_request: githubPreviewPullRequestSchema, + repository: githubPreviewRepositorySchema, + }) + .passthrough(); +export type GithubPreviewPullRequestTargetEvent = z.infer< + typeof githubPreviewPullRequestTargetEventSchema +>; + +export const githubPreviewRepositoryDispatchEventSchema = z + .object({ + action: z.literal('vercel-preview-reconcile'), + client_payload: z.object({ pull_request: githubPullRequestNumberSchema }), + repository: githubPreviewRepositorySchema, + }) + .passthrough(); +export type GithubPreviewRepositoryDispatchEvent = z.infer< + typeof githubPreviewRepositoryDispatchEventSchema +>; + +export const githubPreviewWorkflowLinkedRepositorySchema = z + .object({ + id: positiveIntegerSchema, + name: boundedString(100), + }) + .passthrough(); +export type GithubPreviewWorkflowLinkedRepository = z.infer< + typeof githubPreviewWorkflowLinkedRepositorySchema +>; + +const githubPreviewWorkflowPullRequestSchema = z + .object({ + number: positiveIntegerSchema, + head: z.object({ + sha: gitShaSchema, + ref: boundedString(255), + repo: githubPreviewWorkflowLinkedRepositorySchema, + }), + base: z.object({ + sha: gitShaSchema, + ref: boundedString(255), + repo: githubPreviewWorkflowLinkedRepositorySchema, + }), + }) + .passthrough(); + +const githubPreviewWorkflowRunSchema = z + .object({ + id: positiveIntegerSchema, + workflow_id: positiveIntegerSchema, + name: boundedString(255), + event: boundedString(100), + head_sha: gitShaSchema, + status: githubWorkflowRunStatusSchema, + conclusion: githubWorkflowConclusionSchema, + created_at: z.iso.datetime({ offset: true }), + pull_requests: z.array(githubPreviewWorkflowPullRequestSchema).max(100), + }) + .passthrough(); +export type GithubPreviewWorkflowRun = z.infer; + +export const githubPreviewWorkflowRunEventSchema = z + .object({ + action: z.enum(['completed', 'requested', 'in_progress']), + repository: githubPreviewRepositorySchema, + workflow_run: githubPreviewWorkflowRunSchema, + }) + .passthrough(); +export type GithubPreviewWorkflowRunEvent = z.infer; + +const githubPreviewNonRenameStatusSchema = z.enum([ + 'added', + 'removed', + 'modified', + 'copied', + 'changed', + 'unchanged', +]); + +export const githubPreviewFileSchema = z.discriminatedUnion('status', [ + z + .object({ + filename: boundedString(1024), + status: z.literal('renamed'), + previous_filename: boundedString(1024), + }) + .passthrough(), + z + .object({ + filename: boundedString(1024), + status: githubPreviewNonRenameStatusSchema, + previous_filename: boundedString(1024).optional(), + }) + .passthrough(), +]); +export type GithubPreviewFile = z.infer; + +export const githubPreviewFilesSchema = z.array(githubPreviewFileSchema).max(100); +export type GithubPreviewFiles = z.infer; + +export const githubPreviewWorkflowRunsSchema = z + .object({ + total_count: nonnegativeIntegerSchema, + workflow_runs: z.array(githubPreviewWorkflowRunSchema).max(100), + }) + .passthrough(); +export type GithubPreviewWorkflowRuns = z.infer; + +export const vercelDeploymentSchema = z + .object({ + uid: vercelDeploymentIdSchema, + projectId: boundedString(255), + url: boundedString(255).nullable(), + target: vercelTargetSchema.optional(), + readyState: vercelReadyStateSchema, + meta: z.record(z.string(), vercelMetadataValueSchema).optional().default({}), + }) + .passthrough(); +export type VercelDeployment = z.infer; + +export const vercelDeploymentsPageSchema = z + .object({ + deployments: z.array(vercelDeploymentSchema).max(100), + pagination: z.object({ + count: nonnegativeIntegerSchema, + next: nonnegativeCursorSchema, + prev: nonnegativeCursorSchema, + }), + }) + .passthrough(); +export type VercelDeploymentsPage = z.infer; + +export const vercelProjectSchema = z + .object({ + id: boundedString(255), + name: boundedString(100), + accountId: boundedString(255), + }) + .passthrough(); +export type VercelProject = z.infer; + +const vercelDeploymentMutationSchema = z + .object({ + id: vercelDeploymentIdSchema, + projectId: boundedString(255), + url: boundedString(255), + target: vercelTargetSchema, + readyState: vercelReadyStateSchema, + meta: z.record(z.string(), vercelMetadataValueSchema), + }) + .passthrough(); + +export const vercelCreatedDeploymentSchema = vercelDeploymentMutationSchema; +export type VercelCreatedDeployment = z.infer; + +export const vercelDeploymentDetailSchema = vercelDeploymentMutationSchema; +export type VercelDeploymentDetail = z.infer; + +export const vercelCanceledDeploymentSchema = vercelDeploymentMutationSchema; +export type VercelCanceledDeployment = z.infer; + +export const vercelPreviewEnvironmentSchema = z.object({ + GITHUB_EVENT_NAME: z.enum(['pull_request_target', 'workflow_run', 'repository_dispatch']), + GITHUB_EVENT_PATH: boundedString(4096), + GITHUB_REPOSITORY: z + .string() + .regex(/^[^/\s]+\/[^/\s]+$/) + .max(255), + GITHUB_TOKEN: boundedString(2048), + VERCEL_TOKEN: boundedString(2048), + VERCEL_TEAM_ID: boundedString(255), + VERCEL_PROJECT_ID: boundedString(255), + VERCEL_PROJECT_NAME: boundedString(100), +}); +export type VercelPreviewEnvironment = z.infer; diff --git a/packages/shared/tests/validators/vercel-preview.test.ts b/packages/shared/tests/validators/vercel-preview.test.ts new file mode 100644 index 000000000..2b1572425 --- /dev/null +++ b/packages/shared/tests/validators/vercel-preview.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, test } from 'bun:test'; +import { + githubPreviewFilesSchema, + githubPreviewPullRequestSchema, + githubPreviewPullRequestTargetEventSchema, + githubPreviewRefSchema, + githubPreviewRepositoryDispatchEventSchema, + githubPreviewWorkflowRunEventSchema, + githubPreviewWorkflowRunsSchema, + githubPreviewWorkflowSchema, + vercelCanceledDeploymentSchema, + vercelCreatedDeploymentSchema, + vercelDeploymentDetailSchema, + vercelDeploymentSchema, + vercelDeploymentsPageSchema, + vercelPreviewEnvironmentSchema, + vercelProjectSchema, +} from '../../src/validators/vercel-preview.ts'; + +const SHA = 'a'.repeat(40); + +const repository = { + id: 123, + name: 'orbit', + owner: { login: 'Noveum' }, +}; + +const pullRequest = { + number: 341, + state: 'open' as const, + draft: false, + labels: [{ name: 'preview' }], + user: { login: 'maintainer' }, + head: { + sha: SHA, + ref: 'feature/preview', + repo: repository, + }, + base: { + ref: 'main', + repo: repository, + }, +}; + +const deployment = { + uid: 'dpl_preview', + projectId: 'prj_orbit', + url: null, + target: null, + readyState: 'BLOCKED' as const, + meta: { + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubPrNumber: '341', + orbitGithubRepositoryId: 123, + orbitGithubWorkflowRunId: 987, + orbitDeploymentReason: 'ci-green', + retried: false, + note: null, + }, +}; + +const mutationDeployment = { + id: 'dpl_preview', + projectId: 'prj_orbit', + url: 'orbit-preview.vercel.app', + target: null, + readyState: 'INITIALIZING' as const, + meta: deployment.meta, +}; + +const workflowRun = { + id: 987, + workflow_id: 456, + name: 'CI', + event: 'pull_request', + head_sha: SHA, + status: 'completed' as const, + conclusion: 'success', + created_at: '2026-08-21T00:00:00Z', + pull_requests: [ + { + number: pullRequest.number, + head: { + sha: SHA, + ref: 'feature/preview', + repo: { id: 123, name: 'orbit', url: 'https://api.github.com/repos/Noveum/orbit' }, + }, + base: { + sha: 'b'.repeat(40), + ref: 'main', + repo: { id: 123, name: 'orbit', url: 'https://api.github.com/repos/Noveum/orbit' }, + }, + }, + ], +}; + +const environment = { + GITHUB_EVENT_NAME: 'repository_dispatch', + GITHUB_EVENT_PATH: '/tmp/event.json', + GITHUB_REPOSITORY: 'Noveum/orbit', + GITHUB_TOKEN: 'github-token', + VERCEL_TOKEN: 'vercel-token', + VERCEL_TEAM_ID: 'team_orbit', + VERCEL_PROJECT_ID: 'prj_orbit', + VERCEL_PROJECT_NAME: 'orbit', +}; + +describe('Vercel Preview GitHub schemas', () => { + test('accept a complete open pull request', () => { + const parsed = githubPreviewPullRequestSchema.parse(pullRequest); + + expect(parsed.head.sha).toBe(SHA); + expect(parsed.user.login).toBe('maintainer'); + }); + + test('accept a Git reference with an immutable object SHA', () => { + expect(githubPreviewRefSchema.parse({ object: { sha: SHA } }).object.sha).toBe(SHA); + }); + + test('accept the canonical CI workflow identity', () => { + expect( + githubPreviewWorkflowSchema.parse({ + id: 456, + name: 'CI', + path: '.github/workflows/ci.yml', + state: 'active', + }).path, + ).toBe('.github/workflows/ci.yml'); + }); + + test('accept a pull request state event', () => { + expect( + githubPreviewPullRequestTargetEventSchema.parse({ + action: 'closed', + number: pullRequest.number, + pull_request: pullRequest, + repository, + }), + ).toMatchObject({ action: 'closed', number: pullRequest.number }); + }); + + test('accept a pull request synchronize event', () => { + expect( + githubPreviewPullRequestTargetEventSchema.parse({ + action: 'synchronize', + number: pullRequest.number, + pull_request: pullRequest, + repository, + }), + ).toMatchObject({ action: 'synchronize', number: pullRequest.number }); + }); + + test('accept a successful CI workflow event', () => { + expect( + githubPreviewWorkflowRunEventSchema.parse({ + action: 'completed', + repository, + workflow_run: workflowRun, + }), + ).toMatchObject({ workflow_run: { head_sha: SHA } }); + }); + + test('accept a repository dispatch pull request input', () => { + expect( + githubPreviewRepositoryDispatchEventSchema.parse({ + action: 'vercel-preview-reconcile', + client_payload: { pull_request: 341 }, + repository, + }).client_payload.pull_request, + ).toBe(341); + }); + + test('accept GitHub files and workflow runs with minimal linked repositories', () => { + expect( + githubPreviewFilesSchema.parse([ + { filename: 'apps/web/src/app/page.tsx', status: 'modified' }, + ]), + ).toHaveLength(1); + expect( + githubPreviewWorkflowRunsSchema.parse({ + total_count: 1, + workflow_runs: [workflowRun], + }).workflow_runs, + ).toHaveLength(1); + }); + + test('retains validated rename history from GitHub file responses', () => { + const files = githubPreviewFilesSchema.parse([ + { + filename: 'docs/feature.ts', + status: 'renamed', + previous_filename: 'apps/web/src/feature.ts', + }, + { filename: 'packages/shared/src/index.ts', status: 'modified' }, + ]); + + expect(files).toEqual([ + { + filename: 'docs/feature.ts', + status: 'renamed', + previous_filename: 'apps/web/src/feature.ts', + }, + { filename: 'packages/shared/src/index.ts', status: 'modified' }, + ]); + }); + + test.each([ + ['missing previous filename', { filename: 'docs/feature.ts', status: 'renamed' }], + [ + 'empty previous filename', + { filename: 'docs/feature.ts', status: 'renamed', previous_filename: '' }, + ], + [ + 'oversized previous filename', + { filename: 'docs/feature.ts', status: 'renamed', previous_filename: 'a'.repeat(1025) }, + ], + [ + 'nonnumeric status', + { filename: 'docs/feature.ts', status: 1, previous_filename: 'apps/web/src/feature.ts' }, + ], + ])('reject a renamed file with %s', (_name, file) => { + expect(() => githubPreviewFilesSchema.parse([file])).toThrow(); + }); + + test('requires a workflow run total count', () => { + expect(() => githubPreviewWorkflowRunsSchema.parse({ workflow_runs: [workflowRun] })).toThrow(); + }); + + test('accept Vercel deployment pages and mutation responses', () => { + expect(vercelDeploymentSchema.parse(deployment).readyState).toBe('BLOCKED'); + const page = vercelDeploymentsPageSchema.parse({ + deployments: [ + deployment, + { + uid: 'dpl_unrelated', + projectId: 'prj_orbit', + url: null, + target: null, + readyState: 'READY', + }, + ], + pagination: { count: 2, next: 123, prev: null }, + }); + expect(page.pagination.next).toBe(123); + expect(page.deployments[1]?.meta).toEqual({}); + expect(vercelCreatedDeploymentSchema.parse(mutationDeployment).id).toBe('dpl_preview'); + expect(vercelDeploymentDetailSchema.parse(mutationDeployment).url).toBe( + 'orbit-preview.vercel.app', + ); + expect( + vercelCanceledDeploymentSchema.parse({ ...mutationDeployment, readyState: 'CANCELED' }).id, + ).toBe('dpl_preview'); + }); + + test('validate the Vercel project identity boundary', () => { + expect( + vercelProjectSchema.parse({ id: 'prj_orbit', name: 'orbit', accountId: 'team_orbit' }), + ).toMatchObject({ id: 'prj_orbit', name: 'orbit', accountId: 'team_orbit' }); + expect(() => vercelProjectSchema.parse({ id: 'prj_orbit', name: 'orbit' })).toThrow(); + }); + + test('accept a complete controller environment', () => { + expect(vercelPreviewEnvironmentSchema.parse(environment).VERCEL_PROJECT_ID).toBe('prj_orbit'); + }); + + test('reject a manual workflow dispatch environment', () => { + expect(() => + vercelPreviewEnvironmentSchema.parse({ + ...environment, + GITHUB_EVENT_NAME: 'workflow_dispatch', + }), + ).toThrow(); + }); + + test('reject a short pull request head SHA', () => { + expect(() => + githubPreviewPullRequestSchema.parse({ + ...pullRequest, + head: { ...pullRequest.head, sha: 'short' }, + }), + ).toThrow(); + }); + + test('reject a pull request without a repository ID', () => { + expect(() => + githubPreviewPullRequestSchema.parse({ + ...pullRequest, + head: { ...pullRequest.head, repo: { ...repository, id: undefined } }, + }), + ).toThrow(); + }); + + test('reject a pull request without draft state', () => { + const { draft: _draft, ...withoutDraft } = pullRequest; + expect(() => githubPreviewPullRequestSchema.parse(withoutDraft)).toThrow(); + }); + + test('reject a pull request without an author identity', () => { + const { user: _user, ...withoutUser } = pullRequest; + + expect(githubPreviewPullRequestSchema.safeParse(withoutUser).success).toBe(false); + }); + + test('reject an unknown Vercel ready state', () => { + expect(() => vercelDeploymentSchema.parse({ ...deployment, readyState: 'UNKNOWN' })).toThrow(); + }); + + test('reject mutation responses without complete deployment identity', () => { + expect(() => vercelCreatedDeploymentSchema.parse({ ...mutationDeployment, id: '' })).toThrow(); + expect(() => { + const { projectId: _projectId, ...withoutProjectId } = mutationDeployment; + return vercelDeploymentDetailSchema.parse(withoutProjectId); + }).toThrow(); + expect(() => + vercelCanceledDeploymentSchema.parse({ ...mutationDeployment, url: null }), + ).toThrow(); + expect(() => { + const { meta: _meta, ...withoutMetadata } = mutationDeployment; + return vercelCreatedDeploymentSchema.parse(withoutMetadata); + }).toThrow(); + expect(() => { + const { target: _target, ...withoutTarget } = mutationDeployment; + return vercelDeploymentDetailSchema.parse(withoutTarget); + }).toThrow(); + }); + + test.each([ + ['list', vercelDeploymentSchema, { ...deployment, uid: '../dpl?unsafe#fragment' }], + [ + 'create', + vercelCreatedDeploymentSchema, + { ...mutationDeployment, id: '../dpl?unsafe#fragment' }, + ], + [ + 'detail', + vercelDeploymentDetailSchema, + { ...mutationDeployment, id: '../dpl?unsafe#fragment' }, + ], + [ + 'cancel', + vercelCanceledDeploymentSchema, + { ...mutationDeployment, id: '../dpl?unsafe#fragment' }, + ], + ])('reject a %s response with a path-delimited deployment ID', (_name, schema, value) => { + expect(() => schema.parse(value)).toThrow(); + }); + + test.each([' dpl_preview', 'dpl_preview '])( + 'reject a deployment ID with unsupported whitespace %j', + (id) => { + expect(() => vercelDeploymentSchema.parse({ ...deployment, uid: id })).toThrow(); + expect(() => vercelCreatedDeploymentSchema.parse({ ...mutationDeployment, id })).toThrow(); + }, + ); + + test.each(['dpl_7Gw5ZMBpQA8h9GF832KGp7nwbuh3', 'custom-deployment_123'])( + 'accept supported Vercel deployment ID %s', + (id) => { + expect(vercelDeploymentSchema.parse({ ...deployment, uid: id }).uid).toBe(id); + expect(vercelCreatedDeploymentSchema.parse({ ...mutationDeployment, id }).id).toBe(id); + }, + ); + + test('reject malformed repository dispatch pull request inputs', () => { + const event = { + action: 'vercel-preview-reconcile', + repository, + client_payload: { pull_request: 341 }, + }; + + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + action: event.action, + repository: event.repository, + client_payload: {}, + }), + ).toThrow(); + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + ...event, + client_payload: { pull_request: '341' }, + }), + ).toThrow(); + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + ...event, + client_payload: { pull_request: 341.5 }, + }), + ).toThrow(); + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + ...event, + client_payload: { pull_request: 0 }, + }), + ).toThrow(); + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + ...event, + client_payload: { pull_request: -1 }, + }), + ).toThrow(); + expect(() => + githubPreviewRepositoryDispatchEventSchema.parse({ + ...event, + client_payload: { pull_request: 2_147_483_648 }, + }), + ).toThrow(); + }); + + test('reject pagination without a finite cursor', () => { + expect(() => + vercelDeploymentsPageSchema.parse({ + deployments: [], + pagination: { next: null, prev: null }, + }), + ).toThrow(); + expect(() => + vercelDeploymentsPageSchema.parse({ + deployments: [], + pagination: { count: 0, next: Number.POSITIVE_INFINITY, prev: null }, + }), + ).toThrow(); + }); + + test('reject an environment with missing secrets', () => { + expect(() => + vercelPreviewEnvironmentSchema.parse({ + GITHUB_EVENT_NAME: 'pull_request_target', + GITHUB_EVENT_PATH: '/tmp/event.json', + }), + ).toThrow(); + }); +}); diff --git a/scripts/labels.ts b/scripts/labels.ts index 4f66abe80..2e4ae4d12 100644 --- a/scripts/labels.ts +++ b/scripts/labels.ts @@ -44,6 +44,16 @@ export const LABELS: readonly LabelDefinition[] = [ { name: 'tests', color: 'c2e0c6', description: 'Test coverage and test infrastructure' }, { name: 'question', color: 'd876e3', description: 'Needs more information to act on' }, + { + name: 'preview', + color: '0e8a16', + description: 'Enables a Preview for an otherwise eligible draft after CI succeeds', + }, + { + name: 'no-preview', + color: BLOCKED_COLOR, + description: 'Suppresses Preview creation and cancels active Preview work', + }, { name: 'needs triage', color: STATUS_COLOR, diff --git a/scripts/vercel-preview-config.test.ts b/scripts/vercel-preview-config.test.ts new file mode 100644 index 000000000..ecc5c81de --- /dev/null +++ b/scripts/vercel-preview-config.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { LABELS } from './labels.ts'; + +const ROOT = join(import.meta.dir, '..'); +const WORKFLOW_PATH = join(ROOT, '.github/workflows/vercel-preview.yml'); +const CI_PATH = join(ROOT, '.github/workflows/ci.yml'); +const VERCEL_PATH = join(ROOT, 'apps/web/vercel.json'); +const GUIDE_PATH = join(ROOT, 'docs/VERCEL_BUILD_GATE.md'); +const legacySettingNames = [ + ['BUILD', 'GATE', 'GITHUB', 'TOKEN'].join('_'), + ['BUILD', 'GATE', 'WATCH', 'PATHS'].join('_'), + ['BUILD', 'GATE', 'READY', 'LABEL'].join('_'), + ['BUILD', 'GATE', 'BLOCK', 'LABEL'].join('_'), +]; + +type VercelConfiguration = { + readonly git?: { + readonly deploymentEnabled?: Readonly>; + }; +}; + +function readIfPresent(path: string): string { + return existsSync(path) ? readFileSync(path, 'utf8') : ''; +} + +function capture(text: string, expression: RegExp): string { + return expression.exec(text)?.[1] ?? ''; +} + +const workflow = readIfPresent(WORKFLOW_PATH); +const ci = readFileSync(CI_PATH, 'utf8'); +const guide = readFileSync(GUIDE_PATH, 'utf8'); +const vercel = JSON.parse(readFileSync(VERCEL_PATH, 'utf8')) as VercelConfiguration; +const oldGatePaths = [ + join(ROOT, 'scripts/vercel-build-gate.sh'), + join(ROOT, 'scripts/vercel-build-gate.test.ts'), +]; +const allGateFiles = [ + WORKFLOW_PATH, + CI_PATH, + VERCEL_PATH, + join(ROOT, 'scripts/labels.ts'), + ...oldGatePaths, +] + .map(readIfPresent) + .join('\n'); + +const LEGACY_EXPECTED_HEADER = `name: Vercel Preview + +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled, unlabeled, closed] + workflow_run: + workflows: [CI] + types: [completed] + repository_dispatch: + types: [vercel-preview-reconcile] + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: vercel-preview-\${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pull_request || github.event.workflow_run.head_sha || github.run_id }} + cancel-in-progress: false +`; + +const EXPECTED_WORKFLOW = `${LEGACY_EXPECTED_HEADER} +jobs: + reconcile: + if: >- + github.event_name == 'pull_request_target' || + github.event_name == 'repository_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out trusted controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: \${{ github.repository }} + ref: \${{ github.sha }} + persist-credentials: false + submodules: false + lfs: false + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: "1.3.14" + - name: Install trusted controller dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Reconcile Vercel Preview + run: bun scripts/vercel-preview-deploy.ts + env: + GITHUB_TOKEN: \${{ github.token }} + VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: \${{ vars.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: \${{ vars.VERCEL_PROJECT_ID }} + VERCEL_PROJECT_NAME: \${{ vars.VERCEL_PROJECT_NAME }} +`; + +function partialWorkflowContractAccepts(candidate: string): boolean { + const job = capture(candidate, /\njobs:\n {2}reconcile:\n([\s\S]*)$/); + const steps = capture(candidate, /\n {4}steps:\n([\s\S]*)$/); + const guard = + /^ {4}if: >-\n {6}github\.event_name == 'pull_request_target' \|\|\n {6}github\.event_name == 'repository_dispatch' \|\|\n {6}\(github\.event_name == 'workflow_run' &&\n {6}github\.event\.workflow_run\.event == 'pull_request' &&\n {6}github\.event\.workflow_run\.conclusion == 'success'\)\n {4}runs-on: ubuntu-latest\n {4}timeout-minutes: 25\n/; + const uses = steps.match(/^\s*uses: .+$/gm); + const runs = steps.match(/^\s*run: .+$/gm); + const tokenExpression = ['VERCEL_TOKEN: $', '{{ secrets.VERCEL_TOKEN }}'].join(''); + return ( + candidate.startsWith(LEGACY_EXPECTED_HEADER) && + !candidate.includes('workflow_dispatch') && + guard.test(job) && + steps.includes('uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1') && + steps.includes('uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6') && + steps.includes('run: bun install --frozen-lockfile --ignore-scripts') && + JSON.stringify(uses) === + JSON.stringify([ + ' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + ' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6', + ]) && + JSON.stringify(runs) === + JSON.stringify([ + ' run: bun install --frozen-lockfile --ignore-scripts', + ' run: bun scripts/vercel-preview-deploy.ts', + ]) && + steps.includes(tokenExpression) && + candidate.match(/VERCEL_TOKEN/g)?.length === 2 + ); +} + +function trustedWorkflowContractAccepts(candidate: string): boolean { + return candidate === EXPECTED_WORKFLOW; +} + +const projectNameExpression = [ + ' VERCEL_PROJECT_NAME: $', + '{{ vars.VERCEL_PROJECT_NAME }}', +].join(''); + +const legacyAcceptedUnsafeVariants = [ + [ + 'job-level write permissions', + workflow.replace( + ' timeout-minutes: 25\n steps:', + ' timeout-minutes: 25\n permissions:\n contents: write\n steps:', + ), + ], + [ + 'workflow run defaults', + workflow.replace( + ' cancel-in-progress: false\n\njobs:', + ' cancel-in-progress: false\n\ndefaults:\n run:\n shell: bash -e {0}\n\njobs:', + ), + ], + [ + 'job run defaults', + workflow.replace( + ' timeout-minutes: 25\n steps:', + ' timeout-minutes: 25\n defaults:\n run:\n shell: bash -e {0}\n steps:', + ), + ], + [ + 'install step shell override', + workflow.replace( + ' run: bun install --frozen-lockfile --ignore-scripts', + ' run: bun install --frozen-lockfile --ignore-scripts\n shell: bash -e {0}', + ), + ], + [ + 'controller step shell override', + workflow.replace(projectNameExpression, `${projectNameExpression}\n shell: bash -e {0}`), + ], + [ + 'continued install command', + workflow.replace( + ' run: bun install --frozen-lockfile --ignore-scripts', + ' run: bun install --frozen-lockfile --ignore-scripts\n && bun scripts/vercel-preview-deploy.ts', + ), + ], +] as const; + +const continuedControllerWorkflow = workflow.replace( + ' run: bun scripts/vercel-preview-deploy.ts\n env:', + ' run: bun scripts/vercel-preview-deploy.ts\n && bun scripts/another.ts\n env:', +); + +describe('Vercel Preview repository configuration', () => { + test('configures automatic Git deployments only for main without an ignore command', () => { + expect(vercel.git?.deploymentEnabled).toEqual({ '**': false, main: true }); + expect(Object.keys(vercel.git?.deploymentEnabled ?? {})).toEqual(['**', 'main']); + expect(vercel).not.toHaveProperty('ignoreCommand'); + }); + + test('defines each managed Preview label exactly once with executable semantics', () => { + expect(LABELS.filter(({ name }) => name === 'preview')).toHaveLength(1); + expect(LABELS.filter(({ name }) => name === 'no-preview')).toHaveLength(1); + expect(LABELS.find(({ name }) => name === 'preview')?.description).toBe( + 'Enables a Preview for an otherwise eligible draft after CI succeeds', + ); + expect(LABELS.find(({ name }) => name === 'no-preview')?.description).toBe( + 'Suppresses Preview creation and cancels active Preview work', + ); + }); + + test('removes the old build gate and its Vercel settings', () => { + expect(oldGatePaths.every((path) => !existsSync(path))).toBe(true); + for (const legacySettingName of legacySettingNames) { + expect(allGateFiles).not.toContain(legacySettingName); + } + }); + + test('uses only the exact trusted triggers, permissions, and serialized concurrency', () => { + expect(workflow.startsWith(LEGACY_EXPECTED_HEADER)).toBe(true); + expect(workflow).not.toContain('workflow_dispatch'); + }); + + test('matches the complete trusted workflow and effective reconcile job', () => { + expect(trustedWorkflowContractAccepts(workflow)).toBe(true); + }); + + test('guards successful pull request CI while allowing state and recovery events', () => { + const job = capture(workflow, /\njobs:\n {2}reconcile:\n([\s\S]*)$/); + expect(job).toMatch( + /^ {4}if: >-\n {6}github\.event_name == 'pull_request_target' \|\|\n {6}github\.event_name == 'repository_dispatch' \|\|\n {6}\(github\.event_name == 'workflow_run' &&\n {6}github\.event\.workflow_run\.event == 'pull_request' &&\n {6}github\.event\.workflow_run\.conclusion == 'success'\)\n {4}runs-on: ubuntu-latest\n {4}timeout-minutes: 25\n/, + ); + }); + + test('checks out and installs only trusted default-branch code with immutable actions', () => { + const steps = capture(workflow, /\n {4}steps:\n([\s\S]*)$/); + expect(steps).toContain( + ` - name: Check out trusted controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: \${{ github.repository }} + ref: \${{ github.sha }} + persist-credentials: false + submodules: false + lfs: false`, + ); + expect(steps).toContain( + ` - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: "1.3.14"`, + ); + expect(steps).toContain( + ` - name: Install trusted controller dependencies + run: bun install --frozen-lockfile --ignore-scripts`, + ); + expect(steps.match(/^\s*uses: .+$/gm)).toEqual([ + ' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + ' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6', + ]); + expect(steps).not.toMatch(/actions\/cache|download-artifact|pull_request\.head|head_ref/); + }); + + test('runs only dependency installation and the approved controller with scoped credentials', () => { + const steps = capture(workflow, /\n {4}steps:\n([\s\S]*)$/); + expect(steps.match(/^\s*run: .+$/gm)).toEqual([ + ' run: bun install --frozen-lockfile --ignore-scripts', + ' run: bun scripts/vercel-preview-deploy.ts', + ]); + expect(steps).toContain( + ` - name: Reconcile Vercel Preview + run: bun scripts/vercel-preview-deploy.ts + env: + GITHUB_TOKEN: \${{ github.token }} + VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: \${{ vars.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: \${{ vars.VERCEL_PROJECT_ID }} + VERCEL_PROJECT_NAME: \${{ vars.VERCEL_PROJECT_NAME }}`, + ); + expect(workflow.match(/VERCEL_TOKEN/g)).toHaveLength(2); + }); + + test('keeps source policies inside the static CI job used as deployment proof', () => { + const staticJob = capture(ci, /\n {2}static:\n([\s\S]*?)\n {2}test:\n/); + expect(staticJob).toContain( + ' - name: Source byte policy\n run: bun run check-bytes', + ); + expect(staticJob).toContain( + ' - name: No Bun built-ins in shipped server code\n run: bun run check-bun-imports', + ); + }); + + test('gives every CI job only read access to repository contents', () => { + expect(ci).toContain('\npermissions:\n contents: read\n\nconcurrency:\n'); + expect(ci.match(/^ *permissions:/gm)).toEqual(['permissions:']); + }); + + test('documents the Preview build security boundary and safe recovery event', () => { + const prose = guide.replace(/\s+/g, ' '); + expect(prose).toContain( + 'The API-created Vercel Preview still builds same-repository pull request code with the project Preview environment scope.', + ); + expect(guide).toContain('repository_dispatch'); + expect(guide).toContain('vercel-preview-reconcile'); + expect(guide).toContain('client_payload.pull_request'); + expect(guide).toContain('workflow_dispatch'); + }); + + test.each(legacyAcceptedUnsafeVariants)( + 'rejects %s even when the partial workflow assertions accept it', + (_name, unsafeWorkflow) => { + expect(partialWorkflowContractAccepts(unsafeWorkflow)).toBe(true); + expect(trustedWorkflowContractAccepts(unsafeWorkflow)).toBe(false); + }, + ); + + test('rejects a continued token-bearing controller command', () => { + expect(trustedWorkflowContractAccepts(continuedControllerWorkflow)).toBe(false); + }); +}); diff --git a/scripts/vercel-preview-deploy.test.ts b/scripts/vercel-preview-deploy.test.ts new file mode 100644 index 000000000..ce63183d0 --- /dev/null +++ b/scripts/vercel-preview-deploy.test.ts @@ -0,0 +1,2044 @@ +import { describe, expect, test } from 'bun:test'; +import type { PreviewRuntime } from './vercel-preview-deploy.ts'; +import { reconcileVercelPreviews } from './vercel-preview-deploy.ts'; + +const SHA = 'a'.repeat(40); +const NEW_SHA = 'c'.repeat(40); +const MAIN_SHA = 'b'.repeat(40); +const GITHUB_TOKEN = 'github-secret-token'; +const VERCEL_TOKEN = 'vercel-secret-token'; + +type RecordedRequest = { + readonly method: string; + readonly url: string; + readonly headers: Headers; + readonly body: unknown; + readonly signal: AbortSignal | null; +}; + +type Scenario = { + eventName?: string; + event?: unknown; + pullRequest?: Record; + files?: readonly (string | Record)[]; + workflowRuns?: readonly Record[]; + deployments?: readonly Record[]; + detailStates?: readonly string[]; + respond?: ( + request: RecordedRequest, + requestNumber: number, + ) => Response | Promise | undefined; + now?: () => number; + sleep?: (milliseconds: number) => Promise; + scheduleTimeout?: (callback: () => void, milliseconds: number) => () => void; +}; + +const repository = { + id: 123, + name: 'orbit', + owner: { login: 'Noveum' }, +}; + +function pullRequest(overrides: Record = {}) { + return { + number: 341, + state: 'open', + draft: false, + labels: [], + user: { login: 'maintainer' }, + head: { sha: SHA, ref: 'feature/preview', repo: repository }, + base: { ref: 'main', repo: repository }, + ...overrides, + }; +} + +function workflowRun(overrides: Record = {}) { + return { + id: 987654321, + workflow_id: 456, + name: 'CI', + event: 'pull_request', + head_sha: SHA, + status: 'completed', + conclusion: 'success', + created_at: '2026-08-21T00:00:00Z', + pull_requests: [ + { + number: 341, + head: { sha: SHA, ref: 'feature/preview', repo: { id: 123, name: 'orbit' } }, + base: { sha: MAIN_SHA, ref: 'main', repo: { id: 123, name: 'orbit' } }, + }, + ], + ...overrides, + }; +} + +function workflowRunEvent(overrides: Record = {}) { + return { + action: 'completed', + repository, + workflow_run: workflowRun(), + ...overrides, + }; +} + +function deployment( + readyState: string, + overrides: Record = {}, +): Record { + return { + uid: `dpl_${readyState.toLowerCase()}`, + projectId: 'prj_orbit', + url: readyState === 'READY' ? 'orbit-preview.vercel.app' : null, + target: null, + readyState, + meta: { + orbitDeploymentReason: 'ci-green-pr-preview', + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubPrNumber: '341', + orbitGithubRepositoryId: '123', + orbitGithubWorkflowRunId: '987654321', + }, + ...overrides, + }; +} + +function metadata(overrides: Record = {}) { + return { + orbitDeploymentReason: 'ci-green-pr-preview', + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubPrNumber: '341', + orbitGithubRepositoryId: '123', + orbitGithubWorkflowRunId: '987654321', + ...overrides, + }; +} + +function mutationDeployment( + id: string, + readyState: string, + overrides: Record = {}, +) { + const listed = deployment(readyState, { uid: id, ...overrides }); + const { uid: _uid, ...rest } = listed; + return { id, ...rest, url: 'orbit-preview.vercel.app' }; +} + +function vercelProject(overrides: Record = {}) { + return { + id: 'prj_orbit', + name: 'orbit', + accountId: 'team_orbit', + ...overrides, + }; +} + +function json(value: unknown, status = 200, headers?: Record): Response { + return new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function unreadableJson(message: string): Response { + const response = json({ message: 'unreadable' }); + Object.defineProperty(response, 'text', { + value: () => Promise.reject(new Error(message)), + }); + return response; +} + +async function rejectionMessage(promise: Promise): Promise { + try { + await promise; + return 'resolved'; + } catch (error) { + return String(error); + } +} + +function createHarness(scenario: Scenario = {}) { + const requests: RecordedRequest[] = []; + const sleeps: number[] = []; + const logs: string[] = []; + const currentPullRequest = scenario.pullRequest ?? pullRequest(); + const event = + scenario.event ?? + (scenario.eventName === 'repository_dispatch' + ? { + action: 'vercel-preview-reconcile', + client_payload: { pull_request: 341 }, + repository, + } + : workflowRunEvent()); + const detailStates = [...(scenario.detailStates ?? ['READY'])]; + + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + await Promise.resolve(); + const url = String(input); + const method = init?.method ?? 'GET'; + const headers = new Headers(init?.headers); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + const request = { method, url, headers, body, signal: init?.signal ?? null }; + requests.push(request); + const customResponse = await scenario.respond?.(request, requests.length); + if (customResponse) return customResponse; + + if (url.includes('/pulls/341/files')) { + return json( + (scenario.files ?? ['apps/web/src/app/page.tsx']).map((file) => + typeof file === 'string' ? { filename: file, status: 'modified' } : file, + ), + ); + } + if (url.endsWith('/pulls/341')) return json(currentPullRequest); + if (url.endsWith('/actions/workflows/ci.yml')) { + return json({ id: 456, name: 'CI', path: '.github/workflows/ci.yml', state: 'active' }); + } + if (url.endsWith('/git/ref/heads/main')) return json({ object: { sha: MAIN_SHA } }); + if (url.includes('/actions/workflows/456/runs')) { + const runs = scenario.workflowRuns ?? [workflowRun()]; + return json({ total_count: runs.length, workflow_runs: runs }); + } + if (url.includes('/v9/projects/')) return json(vercelProject()); + if (url.includes('/v7/deployments')) { + const deployments = scenario.deployments ?? []; + return json({ + deployments, + pagination: { count: deployments.length, next: null, prev: null }, + }); + } + if (url.includes('/v13/deployments/') && method === 'GET') { + const id = url.split('/').at(-1)?.split('?')[0] ?? 'dpl_created'; + return json(mutationDeployment(id, detailStates.shift() ?? 'READY')); + } + if (url.includes('/v13/deployments') && method === 'POST') { + return json(mutationDeployment('dpl_created', 'QUEUED')); + } + if (url.includes('/cancel') && method === 'PATCH') { + const id = url.split('/').at(-2) ?? 'dpl_building'; + return json(mutationDeployment(id, 'CANCELED')); + } + return json({ message: 'unexpected request' }, 500); + }; + + const runtime: PreviewRuntime = { + env: { + GITHUB_EVENT_NAME: scenario.eventName ?? 'workflow_run', + GITHUB_EVENT_PATH: '/event.json', + GITHUB_REPOSITORY: 'Noveum/orbit', + GITHUB_TOKEN, + VERCEL_TOKEN, + VERCEL_TEAM_ID: 'team_orbit', + VERCEL_PROJECT_ID: 'prj_orbit', + VERCEL_PROJECT_NAME: 'orbit', + }, + readText: async () => JSON.stringify(event), + fetch, + sleep: async (milliseconds) => { + sleeps.push(milliseconds); + await scenario.sleep?.(milliseconds); + }, + scheduleTimeout: + scenario.scheduleTimeout ?? + ((callback, milliseconds) => { + const timeout = setTimeout(callback, milliseconds); + return () => clearTimeout(timeout); + }), + now: scenario.now ?? (() => Date.parse('2026-08-21T00:00:00Z')), + log: (message) => { + logs.push(message); + }, + }; + + return { runtime, requests, sleeps, logs }; +} + +describe('event and eligibility reconciliation', () => { + test('successful workflow_run for the current ready head creates one deployment', async () => { + const harness = createHarness(); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'created', + pullRequestNumber: 341, + reason: 'created-ready', + deploymentId: 'dpl_created', + url: 'orbit-preview.vercel.app', + }, + ]); + const creates = harness.requests.filter( + ({ method, url }) => method === 'POST' && url.includes('/v13/deployments'), + ); + expect(creates).toHaveLength(1); + const createRequest = creates[0]; + if (createRequest === undefined) { + throw new Error('Expected one Vercel deployment creation request'); + } + expect(createRequest.method).toBe('POST'); + expect(createRequest.url).toContain('/v13/deployments'); + expect(createRequest.url).not.toContain('forceNew=1'); + expect(createRequest.body).toEqual({ + name: 'orbit', + project: 'prj_orbit', + gitSource: { type: 'github', repoId: 123, ref: 'feature/preview', sha: SHA }, + meta: { + orbitDeploymentReason: 'ci-green-pr-preview', + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubPrNumber: '341', + orbitGithubRepositoryId: '123', + orbitGithubWorkflowRunId: '987654321', + }, + }); + expect(createRequest.body).not.toHaveProperty('target'); + const projectLookupIndex = harness.requests.findIndex( + ({ method, url }) => method === 'GET' && url.includes('/v9/projects/prj_orbit'), + ); + expect(projectLookupIndex).toBeGreaterThanOrEqual(0); + expect(projectLookupIndex).toBeLessThan(harness.requests.indexOf(createRequest)); + expect( + new URL(harness.requests[projectLookupIndex]?.url ?? '').searchParams.get('teamId'), + ).toBe('team_orbit'); + expect( + harness.requests.filter( + ({ method, url }) => method === 'GET' && url.includes('/v9/projects/'), + ), + ).toHaveLength(1); + }); + + test('repository_dispatch follows current eligibility and exact CI proof', async () => { + const harness = createHarness({ eventName: 'repository_dispatch' }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ kind: 'created', reason: 'created-ready' }); + }); + + test.each([ + ['ready pull request', false, []], + ['preview-labeled draft', true, [{ name: 'preview' }]], + ])('%s state event creates after exact CI success', async (_name, draft, labels) => { + const embeddedPullRequest = pullRequest({ draft, labels }); + const harness = createHarness({ + eventName: 'pull_request_target', + event: { + action: draft ? 'labeled' : 'ready_for_review', + number: 341, + pull_request: embeddedPullRequest, + repository, + }, + pullRequest: embeddedPullRequest, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ kind: 'created', reason: 'created-ready' }); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); + + test.each(['closed', 'converted_to_draft', 'labeled'])( + '%s state transition cancels active deployments without CI', + async (action) => { + const livePullRequest = pullRequest({ + state: action === 'closed' ? 'closed' : 'open', + draft: action === 'converted_to_draft', + labels: action === 'labeled' ? [{ name: 'no-preview' }] : [], + }); + const harness = createHarness({ + eventName: 'pull_request_target', + event: { action, number: 341, pull_request: livePullRequest, repository }, + pullRequest: livePullRequest, + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_active', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect(harness.requests.some(({ url }) => url.includes('/actions/workflows/'))).toBe(false); + }, + ); + + test.each([ + ['draft without preview', { draft: true }, 'no-active-deployment'], + [ + 'mixed case no-preview wins', + { labels: [{ name: 'PrEvIeW' }, { name: 'No-PrEvIeW' }] }, + 'no-active-deployment', + ], + ['wrong base branch', { base: { ref: 'release', repo: repository } }, 'base-mismatch'], + ] as const)('%s eligibility creates no deployment', async (_name, overrides, reason) => { + const harness = createHarness({ pullRequest: pullRequest(overrides) }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason }]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test.each([ + ['failed', 'completed', 'failure', 'ci-not-green'], + ['in-progress', 'in_progress', null, 'ci-not-green'], + ] as const)('%s CI creates no deployment', async (_name, status, conclusion, reason) => { + const harness = createHarness({ workflowRuns: [workflowRun({ status, conclusion })] }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason }]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('unrelated files create no deployment', async () => { + const harness = createHarness({ files: ['docs/README.md'] }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'web-unaffected' }, + ]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test.each([ + [ + 'into a web path', + { + filename: 'apps/web/src/feature.ts', + status: 'renamed', + previous_filename: 'docs/feature.ts', + }, + ], + [ + 'out of a web path', + { + filename: 'docs/feature.ts', + status: 'renamed', + previous_filename: 'apps/web/src/feature.ts', + }, + ], + ])('a rename %s creates a deployment', async (_name, file) => { + const harness = createHarness({ files: [file] }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ kind: 'created', reason: 'created-ready' }); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); + + test('a malformed rename fails closed before Vercel mutation', async () => { + const harness = createHarness({ + files: [{ filename: 'apps/web/src/feature.ts', status: 'renamed' }], + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('schema'); + expect(harness.requests.some(({ method }) => method === 'POST' || method === 'PATCH')).toBe( + false, + ); + }); + + test('stale event SHA cannot create or cancel the current head', async () => { + const event = workflowRunEvent({ workflow_run: workflowRun({ head_sha: 'c'.repeat(40) }) }); + const harness = createHarness({ event }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.some(({ method }) => method === 'POST' || method === 'PATCH')).toBe( + false, + ); + }); + + test('fork heads create no deployment', async () => { + const harness = createHarness({ + pullRequest: pullRequest({ + head: { + sha: SHA, + ref: 'feature/preview', + repo: { id: 999, name: 'orbit', owner: { login: 'contributor' } }, + }, + }), + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'fork-pull-request' }, + ]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('a Dependabot workflow run cannot deploy a same-repository pull request', async () => { + const harness = createHarness({ + pullRequest: pullRequest({ user: { login: 'dependabot[bot]' } }), + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'fork-pull-request' }, + ]); + expect( + harness.requests.some(({ url }) => new URL(url).origin === 'https://api.vercel.com'), + ).toBe(false); + }); + + test('a newer non-green run blocks an older success', async () => { + const harness = createHarness({ + workflowRuns: [ + workflowRun(), + workflowRun({ + id: 987654322, + created_at: '2026-08-21T00:01:00Z', + status: 'queued', + conclusion: null, + }), + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'ci-not-green' }]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('event repository identity must match the configured and live repository', async () => { + const harness = createHarness({ + event: workflowRunEvent({ + repository: { ...repository, id: 999, name: 'other' }, + }), + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'repository-mismatch' }, + ]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('a pull request target event rejects disagreement between event numbers', async () => { + const harness = createHarness({ + eventName: 'pull_request_target', + event: { + action: 'opened', + number: 342, + pull_request: pullRequest(), + repository, + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('event pull request'); + }); + + test('an empty workflow run association logs a closed reason without inventing a candidate', async () => { + const event = workflowRunEvent({ workflow_run: workflowRun({ pull_requests: [] }) }); + const harness = createHarness({ event }); + + await expect(reconcileVercelPreviews(harness.runtime)).resolves.toEqual([]); + expect(harness.logs).toContain('workflow-run-unassociated'); + }); +}); + +describe('existing deployments, cancellation, and pagination', () => { + test.each(['QUEUED', 'INITIALIZING', 'BUILDING'])( + 'existing exact %s deployment is polled without a duplicate create', + async (readyState) => { + const harness = createHarness({ + deployments: [deployment(readyState, { uid: 'dpl_active' })], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'created', + pullRequestNumber: 341, + reason: 'active-deployment-reused', + deploymentId: 'dpl_active', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }, + ); + + test('existing exact ready deployment wins over active and terminal duplicates', async () => { + const harness = createHarness({ + deployments: [ + deployment('ERROR', { uid: 'dpl_error' }), + deployment('BUILDING', { uid: 'dpl_active' }), + deployment('READY', { uid: 'dpl_ready' }), + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'created', + pullRequestNumber: 341, + reason: 'ready-deployment-reused', + deploymentId: 'dpl_ready', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + expect(harness.requests.some(({ url }) => url.includes('/v13/deployments/'))).toBe(false); + }); + + test.each(['CANCELED', 'ERROR', 'BLOCKED', 'DELETED'])( + 'existing terminal %s deployment allows one forced create', + async (readyState) => { + const harness = createHarness({ + deployments: [deployment(readyState, { uid: 'dpl_terminal' })], + }); + + await reconcileVercelPreviews(harness.runtime); + + const creates = harness.requests.filter(({ method }) => method === 'POST'); + expect(creates).toHaveLength(1); + expect(creates[0]?.url).toContain('forceNew=1'); + }, + ); + + test.each([ + ['another project', { projectId: 'prj_other' }], + ['staging target', { target: 'staging' }], + ['production target', { target: 'production' }], + ['missing target', { target: undefined }], + ['another repository', { meta: metadata({ orbitGithubRepositoryId: '999' }) }], + ['another pull request', { meta: metadata({ orbitGithubPrNumber: '342' }) }], + ['another ref', { meta: metadata({ orbitGithubHeadRef: 'other' }) }], + ])('%s does not suppress an exact-SHA create', async (_name, overrides) => { + const harness = createHarness({ deployments: [deployment('READY', overrides)] }); + + await reconcileVercelPreviews(harness.runtime); + + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); + + test('an unrelated list item without metadata is ignored', async () => { + const harness = createHarness({ + deployments: [ + { + uid: 'dpl_unrelated', + projectId: 'prj_orbit', + url: null, + target: null, + readyState: 'READY', + }, + ], + }); + + await reconcileVercelPreviews(harness.runtime); + + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); + + test('Vercel pagination preserves filters and finds exact deployment on page two', async () => { + let listPage = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/v7/deployments')) return undefined; + if (!new URL(url).searchParams.has('sha')) { + return json({ deployments: [], pagination: { count: 0, next: null, prev: null } }); + } + listPage += 1; + if (listPage === 1) { + return json({ deployments: [], pagination: { count: 0, next: 123, prev: null } }); + } + return json({ + deployments: [deployment('READY', { uid: 'dpl_page_two' })], + pagination: { count: 1, next: null, prev: 123 }, + }); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ + reason: 'ready-deployment-reused', + deploymentId: 'dpl_page_two', + }); + const pages = harness.requests.filter(({ url }) => url.includes('/v7/deployments')); + const exactHeadPages = pages.filter(({ url }) => new URL(url).searchParams.has('sha')); + expect(pages).toHaveLength(3); + expect(exactHeadPages).toHaveLength(2); + for (const request of pages) { + const url = new URL(request.url); + expect(url.searchParams.get('teamId')).toBe('team_orbit'); + expect(url.searchParams.get('projectId')).toBe('prj_orbit'); + expect(url.searchParams.get('branch')).toBe('feature/preview'); + expect(url.searchParams.get('limit')).toBe('100'); + } + for (const request of exactHeadPages) { + expect(new URL(request.url).searchParams.get('sha')).toBe(SHA); + } + expect(new URL(exactHeadPages[1]?.url ?? '').searchParams.get('until')).toBe('123'); + }); + + test('an exact active deployment wins over terminal history', async () => { + const harness = createHarness({ + deployments: [ + deployment('ERROR', { uid: 'dpl_error' }), + deployment('BUILDING', { uid: 'dpl_active' }), + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ + reason: 'active-deployment-reused', + deploymentId: 'dpl_active', + }); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('a ready list item with a null URL reads validated detail', async () => { + const harness = createHarness({ + deployments: [deployment('READY', { uid: 'dpl_ready', url: null })], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ + reason: 'ready-deployment-reused', + deploymentId: 'dpl_ready', + url: 'orbit-preview.vercel.app', + }); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_ready')), + ).toHaveLength(1); + }); + + test('ineligible state cancels only matching active Preview deployments in ID order', async () => { + const livePullRequest = pullRequest({ draft: true }); + const matching = deployment('BUILDING', { uid: 'dpl_b' }); + const alsoMatching = deployment('QUEUED', { uid: 'dpl_a' }); + const harness = createHarness({ + pullRequest: livePullRequest, + deployments: [ + matching, + deployment('READY', { uid: 'dpl_ready' }), + deployment('ERROR', { uid: 'dpl_error' }), + deployment('BUILDING', { uid: 'dpl_stage', target: 'staging' }), + deployment('BUILDING', { uid: 'dpl_project', projectId: 'prj_other' }), + deployment('BUILDING', { + uid: 'dpl_repo', + meta: metadata({ orbitGithubRepositoryId: '999' }), + }), + alsoMatching, + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results.map((result) => result.kind === 'canceled' && result.deploymentId)).toEqual([ + 'dpl_a', + 'dpl_b', + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(2); + }); + + test('synchronize cancels only validated active prior heads and retains ready history', async () => { + const currentPullRequest = pullRequest({ + head: { sha: NEW_SHA, ref: 'feature/preview', repo: repository }, + }); + const harness = createHarness({ + eventName: 'pull_request_target', + event: { + action: 'synchronize', + number: 341, + pull_request: currentPullRequest, + repository, + }, + pullRequest: currentPullRequest, + workflowRuns: [], + deployments: [ + deployment('BUILDING', { uid: 'dpl_prior' }), + deployment('READY', { uid: 'dpl_prior_ready' }), + deployment('BUILDING', { + uid: 'dpl_current', + meta: metadata({ orbitGithubHeadSha: NEW_SHA }), + }), + deployment('BUILDING', { uid: 'dpl_project', projectId: 'prj_other' }), + deployment('BUILDING', { + uid: 'dpl_repository', + meta: metadata({ orbitGithubRepositoryId: '999' }), + }), + deployment('BUILDING', { + uid: 'dpl_pull_request', + meta: metadata({ orbitGithubPrNumber: '342' }), + }), + deployment('BUILDING', { + uid: 'dpl_ref', + meta: metadata({ orbitGithubHeadRef: 'feature/other' }), + }), + deployment('BUILDING', { + uid: 'dpl_malformed_sha', + meta: metadata({ orbitGithubHeadSha: 'not-a-sha' }), + }), + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_prior', + url: 'orbit-preview.vercel.app', + }, + { kind: 'skipped', pullRequestNumber: 341, reason: 'ci-unavailable' }, + ]); + expect( + harness.requests + .filter(({ method }) => method === 'PATCH') + .map(({ url }) => url.split('/').at(-2)), + ).toEqual(['dpl_prior']); + expect( + harness.requests.some(({ url }) => url.includes('/v13/deployments/dpl_prior_ready')), + ).toBe(false); + }); + + test('repository dispatch recovers an active deployment left by a prior head', async () => { + const currentPullRequest = pullRequest({ + head: { sha: NEW_SHA, ref: 'feature/preview', repo: repository }, + }); + const harness = createHarness({ + eventName: 'repository_dispatch', + pullRequest: currentPullRequest, + workflowRuns: [], + deployments: [deployment('BUILDING', { uid: 'dpl_prior' })], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_prior', + url: 'orbit-preview.vercel.app', + }, + { kind: 'skipped', pullRequestNumber: 341, reason: 'ci-unavailable' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + }); + + test('cancellation reversal reports that the pull request became eligible', async () => { + let pullRequestReads = 0; + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullRequestReads += 1; + return json(pullRequest({ draft: pullRequestReads === 1 })); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'preview-eligible' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test('eligibility changing during project validation prevents cancellation', async () => { + let projectValidated = false; + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ url }) => { + if (url.includes('/v9/projects/')) { + projectValidated = true; + return json(vercelProject()); + } + if (url.endsWith('/pulls/341')) { + return json(pullRequest({ draft: !projectValidated })); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'preview-eligible' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test('head changing during project validation prevents prior-head cancellation', async () => { + let projectValidated = false; + const currentPullRequest = pullRequest({ + head: { sha: NEW_SHA, ref: 'feature/preview', repo: repository }, + }); + const harness = createHarness({ + eventName: 'repository_dispatch', + pullRequest: currentPullRequest, + workflowRuns: [], + deployments: [deployment('BUILDING', { uid: 'dpl_prior' })], + respond: ({ url }) => { + if (url.includes('/v9/projects/')) { + projectValidated = true; + return json(vercelProject()); + } + if (url.endsWith('/pulls/341')) { + return json( + projectValidated + ? pullRequest({ + head: { sha: 'd'.repeat(40), ref: 'feature/preview', repo: repository }, + }) + : currentPullRequest, + ); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test('eligibility changing after the first cancellation prevents a second PATCH', async () => { + let canceled = false; + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [ + deployment('BUILDING', { uid: 'dpl_b' }), + deployment('BUILDING', { uid: 'dpl_a' }), + ], + respond: ({ method, url }) => { + if (url.endsWith('/pulls/341')) { + return json(pullRequest({ draft: !canceled })); + } + if (method === 'PATCH') { + const id = url.split('/').at(-2) ?? 'missing'; + canceled = true; + return json(mutationDeployment(id, 'CANCELED')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_a', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect(harness.requests.filter(({ url }) => url.endsWith('/pulls/341'))).toHaveLength(4); + }); + + test('cancel 400 accepts a terminal detail race without retrying PATCH', async () => { + const livePullRequest = pullRequest({ draft: true }); + const harness = createHarness({ + pullRequest: livePullRequest, + deployments: [deployment('BUILDING', { uid: 'dpl_race' })], + respond: ({ method, url }) => { + if (method === 'PATCH') return json({ message: 'not cancelable' }, 400); + if (url.includes('/v13/deployments/dpl_race')) { + return json(mutationDeployment('dpl_race', 'READY')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'no-active-deployment' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_race')), + ).toHaveLength(1); + }); + + test.each(['network', '429', '503', 'invalid-success'])( + 'ambiguous cancel %s sends one PATCH and reconciles through one detail read', + async (outcome) => { + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [deployment('BUILDING', { uid: 'dpl_race' })], + detailStates: ['CANCELED'], + respond: ({ method }) => { + if (method !== 'PATCH') return undefined; + if (outcome === 'network') throw new Error(`${GITHUB_TOKEN} ${VERCEL_TOKEN}`); + if (outcome === '429') return json({ message: VERCEL_TOKEN }, 429); + if (outcome === '503') return json({ message: GITHUB_TOKEN }, 503); + return new Response('{', { status: 200 }); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'no-active-deployment' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_race')), + ).toHaveLength(1); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + expect(harness.sleeps).toHaveLength(0); + }, + ); + + test('a second reconciliation reuses the deployment created by the first', async () => { + const stored: Record[] = []; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.includes('/v7/deployments')) { + return json({ + deployments: stored, + pagination: { count: stored.length, next: null, prev: null }, + }); + } + if (method === 'POST') { + stored.push(deployment('READY', { uid: 'dpl_created' })); + return json(mutationDeployment('dpl_created', 'READY')); + } + return undefined; + }, + }); + + await reconcileVercelPreviews(harness.runtime); + const second = await reconcileVercelPreviews(harness.runtime); + + expect(second[0]).toMatchObject({ reason: 'ready-deployment-reused' }); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); +}); + +describe('endpoint pagination and final freshness', () => { + test('workflow runs paginate to total_count and a newer page-two run blocks creation', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => + workflowRun({ id: index + 1, created_at: '2026-08-20T00:00:00Z' }), + ); + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/actions/workflows/456/runs')) return undefined; + const page = new URL(url).searchParams.get('page'); + return page === '1' + ? json({ total_count: 101, workflow_runs: firstPage }) + : json({ + total_count: 101, + workflow_runs: [ + workflowRun({ + id: 999, + created_at: '2026-08-22T00:00:00Z', + status: 'queued', + conclusion: null, + }), + ], + }); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'ci-not-green' }]); + expect( + harness.requests.filter(({ url }) => url.includes('/actions/workflows/456/runs')), + ).toHaveLength(2); + }); + + test('workflow run pages reject duplicate IDs across pages', async () => { + const page = Array.from({ length: 100 }, (_, index) => workflowRun({ id: index + 1 })); + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/actions/workflows/456/runs')) return undefined; + const pageNumber = new URL(url).searchParams.get('page'); + return pageNumber === '1' + ? json({ total_count: 101, workflow_runs: page }) + : json({ total_count: 101, workflow_runs: [workflowRun({ id: 1 })] }); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('duplicate'); + }); + + test('workflow run pages reject inconsistent total_count', async () => { + const page = Array.from({ length: 100 }, (_, index) => workflowRun({ id: index + 1 })); + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/actions/workflows/456/runs')) return undefined; + const pageNumber = new URL(url).searchParams.get('page'); + return pageNumber === '1' + ? json({ total_count: 101, workflow_runs: page }) + : json({ total_count: 102, workflow_runs: [workflowRun({ id: 101 })] }); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('total_count'); + }); + + test('pull request files paginate until a relevant file appears', async () => { + const fullIrrelevantPage = Array.from({ length: 100 }, (_, index) => ({ + filename: `docs/page-${index}.md`, + status: 'modified', + })); + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/pulls/341/files')) return undefined; + return new URL(url).searchParams.get('page') === '1' + ? json(fullIrrelevantPage) + : json([{ filename: 'packages/shared/src/index.ts', status: 'modified' }]); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ kind: 'created', reason: 'created-ready' }); + expect(harness.requests.filter(({ url }) => url.includes('/pulls/341/files'))).toHaveLength(2); + }); + + test('a full final files page fails closed at the 30-page cap', async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + filename: `docs/page-${index}.md`, + status: 'modified', + })); + const harness = createHarness({ + respond: ({ url }) => (url.includes('/pulls/341/files') ? json(fullPage) : undefined), + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('files pagination'); + expect(harness.requests.filter(({ url }) => url.includes('/pulls/341/files'))).toHaveLength(30); + }); + + test('final label freshness prevents mutation after a concurrent state change', async () => { + let pullRequestReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullRequestReads += 1; + return json( + pullRequestReads === 1 + ? pullRequest() + : pullRequest({ labels: [{ name: 'no-preview' }] }), + ); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test.each([ + ['label', pullRequest({ labels: [{ name: 'no-preview' }] })], + [ + 'head', + pullRequest({ + head: { sha: 'c'.repeat(40), ref: 'feature/preview-next', repo: repository }, + }), + ], + ])('a %s change during the final CI proof prevents Create', async (_name, changedPullRequest) => { + let finalCiProofStarted = false; + let workflowRunReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (url.endsWith('/pulls/341')) { + return json(finalCiProofStarted ? changedPullRequest : pullRequest()); + } + if (url.includes('/actions/workflows/456/runs')) { + workflowRunReads += 1; + if (workflowRunReads === 2) finalCiProofStarted = true; + return json({ total_count: 1, workflow_runs: [workflowRun()] }); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + expect(workflowRunReads).toBe(2); + }); + + test('main advancing during project validation prevents Create', async () => { + let projectValidated = false; + const harness = createHarness({ + respond: ({ url }) => { + if (url.includes('/v9/projects/')) { + projectValidated = true; + return json(vercelProject()); + } + if (url.endsWith('/git/ref/heads/main')) { + return json({ object: { sha: projectValidated ? NEW_SHA : MAIN_SHA } }); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { kind: 'skipped', pullRequestNumber: 341, reason: 'ci-not-current' }, + ]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + }); + + test('pull request state changing during project validation prevents Create', async () => { + let projectValidated = false; + const harness = createHarness({ + respond: ({ url }) => { + if (url.includes('/v9/projects/')) { + projectValidated = true; + return json(vercelProject()); + } + if (url.endsWith('/pulls/341')) { + return json( + projectValidated ? pullRequest({ labels: [{ name: 'no-preview' }] }) : pullRequest(), + ); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + }); + + test('workflow_run with multiple distinct PR associations logs ambiguity and does no work', async () => { + const event = workflowRunEvent({ + workflow_run: workflowRun({ + pull_requests: [ + ...workflowRun().pull_requests, + { + number: 342, + head: { sha: SHA, ref: 'feature/other', repo: { id: 123, name: 'orbit' } }, + base: { sha: MAIN_SHA, ref: 'main', repo: { id: 123, name: 'orbit' } }, + }, + ], + }), + }); + const harness = createHarness({ event }); + + await expect(reconcileVercelPreviews(harness.runtime)).resolves.toEqual([]); + expect(harness.logs).toContain('workflow-run-ambiguous-associations'); + expect(harness.requests).toHaveLength(0); + }); +}); + +describe('bounded transport, polling, and ambiguity recovery', () => { + test('a safe read uses at most three attempts before the later freshness read', async () => { + let pullReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return pullReads < 3 ? json({ message: 'temporary' }, 500) : json(pullRequest()); + }, + }); + + await reconcileVercelPreviews(harness.runtime); + + expect(pullReads).toBe(5); + expect(harness.sleeps.slice(0, 2)).toEqual([1000, 1000]); + }); + + test.each([401, 403])('ordinary %s read failures do not retry', async (status) => { + let pullReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return json({ message: 'denied' }, status); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow(String(status)); + expect(pullReads).toBe(1); + }); + + test.each([ + ['network', 3, 2], + ['body-read', 3, 2], + ['invalid-json', 1, 0], + ['invalid-schema', 1, 0], + ] as const)( + 'safe-read %s failures have bounded attempts and no mutation', + async (failure, expectedReads, expectedSleeps) => { + let pullReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + if (failure === 'network') throw new Error(`${GITHUB_TOKEN} ${VERCEL_TOKEN}`); + if (failure === 'body-read') { + return unreadableJson(`${GITHUB_TOKEN} ${VERCEL_TOKEN}`); + } + if (failure === 'invalid-json') return new Response('{', { status: 200 }); + return json({ number: 341 }); + }, + }); + + const message = await rejectionMessage(reconcileVercelPreviews(harness.runtime)); + + expect(message).not.toBe('resolved'); + expect(message).not.toContain(GITHUB_TOKEN); + expect(message).not.toContain(VERCEL_TOKEN); + expect(pullReads).toBe(expectedReads); + expect(harness.sleeps).toHaveLength(expectedSleeps); + expect(harness.requests.some(({ method }) => method === 'POST' || method === 'PATCH')).toBe( + false, + ); + }, + ); + + test('an injected request timeout aborts each safe-read attempt', async () => { + let pendingTimeout: (() => void) | null = null; + let pullReads = 0; + let scheduledTimeouts = 0; + const harness = createHarness({ + scheduleTimeout: (callback, milliseconds) => { + expect(milliseconds).toBe(15_000); + scheduledTimeouts += 1; + pendingTimeout = callback; + return () => { + if (pendingTimeout === callback) pendingTimeout = null; + }; + }, + respond: (request) => { + if (!request.url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return new Promise((resolve, reject) => { + request.signal?.addEventListener( + 'abort', + () => reject(new DOMException('timed out', 'AbortError')), + { once: true }, + ); + queueMicrotask(() => pendingTimeout?.()); + queueMicrotask(() => resolve(json({ message: 'timeout was not injected' }, 400))); + }); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow(); + + expect(pullReads).toBe(3); + expect(scheduledTimeouts).toBe(3); + expect(harness.requests.every(({ signal }) => signal?.aborted === true)).toBe(true); + expect(harness.sleeps).toEqual([1000, 1000]); + }); + + test('rate-limited GitHub 403 retries with bounded Retry-After', async () => { + let pullReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return pullReads === 1 + ? json({ message: 'limited' }, 403, { 'Retry-After': '2' }) + : json(pullRequest()); + }, + }); + + await reconcileVercelPreviews(harness.runtime); + + expect(pullReads).toBeGreaterThan(1); + expect(harness.sleeps[0]).toBe(2000); + }); + + test('redirects are rejected without following token-bearing requests', async () => { + const harness = createHarness({ + respond: ({ url }) => + url.endsWith('/pulls/341') + ? new Response('', { status: 302, headers: { location: 'https://example.com' } }) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('redirect rejected'); + expect(harness.requests).toHaveLength(1); + }); + + test('the polling owner cancels its created deployment when the exact head becomes ineligible', async () => { + let createAttempted = false; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.endsWith('/pulls/341')) { + return json( + createAttempted ? pullRequest({ labels: [{ name: 'no-preview' }] }) : pullRequest(), + ); + } + if (method === 'POST') { + createAttempted = true; + return json(mutationDeployment('dpl_created', 'QUEUED')); + } + if (method === 'GET' && url.includes('/v13/deployments/dpl_created')) { + return json(mutationDeployment('dpl_created', 'BUILDING')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_created', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect(harness.sleeps.filter((milliseconds) => milliseconds === 5000)).toHaveLength(0); + }); + + test('project identity drift before a poll-triggered cancel blocks the PATCH', async () => { + let createAttempted = false; + let projectReads = 0; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.includes('/v9/projects/')) { + projectReads += 1; + return json(vercelProject(projectReads === 1 ? {} : { accountId: 'team_transferred' })); + } + if (url.endsWith('/pulls/341')) { + return json( + createAttempted ? pullRequest({ labels: [{ name: 'no-preview' }] }) : pullRequest(), + ); + } + if (method === 'POST') { + createAttempted = true; + return json(mutationDeployment('dpl_created', 'QUEUED')); + } + if (method === 'GET' && url.includes('/v13/deployments/dpl_created')) { + return json(mutationDeployment('dpl_created', 'BUILDING')); + } + return undefined; + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('project identity'); + expect(projectReads).toBe(2); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test('the polling owner cancels an existing active deployment when the exact head becomes draft', async () => { + let pullRequestReads = 0; + const harness = createHarness({ + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ method, url }) => { + if (url.endsWith('/pulls/341')) { + pullRequestReads += 1; + return json(pullRequest({ draft: pullRequestReads > 1 })); + } + if (method === 'GET' && url.includes('/v13/deployments/dpl_active')) { + return json(mutationDeployment('dpl_active', 'BUILDING')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_active', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect(harness.sleeps.filter((milliseconds) => milliseconds === 5000)).toHaveLength(0); + }); + + test('the polling owner cancels its active deployment after a same-ref head change', async () => { + let createAttempted = false; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.endsWith('/pulls/341')) { + return json( + createAttempted + ? pullRequest({ + head: { sha: NEW_SHA, ref: 'feature/preview', repo: repository }, + }) + : pullRequest(), + ); + } + if (method === 'POST') { + createAttempted = true; + return json(mutationDeployment('dpl_created', 'QUEUED')); + } + if (method === 'GET' && url.includes('/v13/deployments/dpl_created')) { + return json(mutationDeployment('dpl_created', 'BUILDING')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([ + { + kind: 'canceled', + pullRequestNumber: 341, + reason: 'canceled-active', + deploymentId: 'dpl_created', + url: 'orbit-preview.vercel.app', + }, + ]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + expect(harness.sleeps.filter((milliseconds) => milliseconds === 5000)).toHaveLength(0); + }); + + test('the polling owner does not cancel after the pull request head ref changes', async () => { + let createAttempted = false; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.endsWith('/pulls/341')) { + return json( + createAttempted + ? pullRequest({ + head: { sha: NEW_SHA, ref: 'feature/new-head', repo: repository }, + }) + : pullRequest(), + ); + } + if (method === 'POST') { + createAttempted = true; + return json(mutationDeployment('dpl_created', 'QUEUED')); + } + if (method === 'GET' && url.includes('/v13/deployments/dpl_created')) { + return json(mutationDeployment('dpl_created', 'BUILDING')); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'stale-event' }]); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test('active polling sleeps through transitions and returns READY', async () => { + const harness = createHarness({ + deployments: [deployment('QUEUED', { uid: 'dpl_active' })], + detailStates: ['INITIALIZING', 'BUILDING', 'READY'], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ reason: 'active-deployment-reused' }); + expect(harness.sleeps.filter((milliseconds) => milliseconds === 5000)).toHaveLength(2); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_active')), + ).toHaveLength(3); + }); + + test('active through the final polling GET stops after 240 sleeps and 241 GETs', async () => { + let detailReads = 0; + const harness = createHarness({ + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ method, url }) => { + if (method !== 'GET' || !url.includes('/v13/deployments/dpl_active')) return undefined; + detailReads += 1; + return json(mutationDeployment('dpl_active', detailReads === 242 ? 'READY' : 'BUILDING')); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('timed out'); + expect(detailReads).toBe(241); + expect(harness.sleeps.filter((milliseconds) => milliseconds === 5000)).toHaveLength(240); + }); + + test.each(['ERROR', 'CANCELED', 'BLOCKED', 'DELETED'])( + 'terminal polling state %s fails visibly', + async (readyState) => { + const harness = createHarness({ + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + detailStates: [readyState], + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow(readyState); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }, + ); + + test.each(['network', '429', '500', '409', 'invalid-success'])( + 'ambiguous create outcome %s sends one POST and observes a newly visible ready deployment', + async (outcome) => { + let listReads = 0; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.includes('/v7/deployments')) { + if (!new URL(url).searchParams.has('sha')) { + return json({ deployments: [], pagination: { count: 0, next: null, prev: null } }); + } + listReads += 1; + const items = listReads === 1 ? [] : [deployment('READY', { uid: 'dpl_observed' })]; + return json({ + deployments: items, + pagination: { count: items.length, next: null, prev: null }, + }); + } + if (method === 'POST') { + if (outcome === 'network') throw new Error(`${GITHUB_TOKEN} ${VERCEL_TOKEN}`); + if (outcome === '429') return json({ message: VERCEL_TOKEN }, 429); + if (outcome === '500') return json({ message: VERCEL_TOKEN }, 500); + if (outcome === '409') return json({ message: GITHUB_TOKEN }, 409); + return new Response('{', { status: 200 }); + } + return undefined; + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ deploymentId: 'dpl_observed' }); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(listReads).toBe(2); + expect(harness.requests.filter(({ url }) => url.includes('/v7/deployments'))).toHaveLength(3); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_observed')), + ).toHaveLength(0); + expect(harness.sleeps).toHaveLength(0); + }, + ); + + test('an ambiguous Create timeout sends one POST and uses read-only observation', async () => { + let pendingTimeout: (() => void) | null = null; + let listReads = 0; + const harness = createHarness({ + scheduleTimeout: (callback) => { + pendingTimeout = callback; + return () => { + if (pendingTimeout === callback) pendingTimeout = null; + }; + }, + respond: (request) => { + if (request.url.includes('/v7/deployments')) { + if (!new URL(request.url).searchParams.has('sha')) { + return json({ deployments: [], pagination: { count: 0, next: null, prev: null } }); + } + listReads += 1; + const items = listReads === 1 ? [] : [deployment('READY', { uid: 'dpl_observed' })]; + return json({ + deployments: items, + pagination: { count: items.length, next: null, prev: null }, + }); + } + if (request.method !== 'POST') return undefined; + return new Promise((resolve, reject) => { + request.signal?.addEventListener( + 'abort', + () => reject(new DOMException('timed out', 'AbortError')), + { once: true }, + ); + queueMicrotask(() => pendingTimeout?.()); + queueMicrotask(() => resolve(json({ message: 'timeout was not injected' }, 400))); + }); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results[0]).toMatchObject({ deploymentId: 'dpl_observed' }); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(listReads).toBe(2); + expect(harness.requests.filter(({ url }) => url.includes('/v7/deployments'))).toHaveLength(3); + expect( + harness.requests.filter(({ url }) => url.includes('/v13/deployments/dpl_observed')), + ).toHaveLength(0); + expect(harness.sleeps).toHaveLength(0); + }); + + test('ambiguous create ignores a pre-existing exact ID and fails without a second POST', async () => { + const existing = deployment('ERROR', { uid: 'dpl_existing' }); + const harness = createHarness({ + deployments: [existing], + respond: ({ method, url }) => { + if (method === 'POST') return json({ message: 'temporary' }, 500); + return url.includes('/v7/deployments') + ? json({ deployments: [existing], pagination: { count: 1, next: null, prev: null } }) + : undefined; + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('ambiguous'); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(harness.requests.filter(({ url }) => url.includes('/v7/deployments'))).toHaveLength(5); + expect(harness.requests.filter(({ url }) => url.includes('/v13/deployments/'))).toHaveLength(0); + expect(harness.sleeps).toEqual([2000, 2000]); + }); + + test.each([400, 401, 403, 422])( + 'definitive create %s sends no retry or observation', + async (status) => { + let listReads = 0; + const harness = createHarness({ + respond: ({ method, url }) => { + if (url.includes('/v7/deployments')) listReads += 1; + if (method === 'POST') return json({ message: 'definitive' }, status); + return undefined; + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow(String(status)); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + expect(listReads).toBe(2); + }, + ); + + test('controller deadline stops before the next request or sleep', async () => { + let currentTime = 0; + const harness = createHarness({ + now: () => currentTime, + respond: ({ url }) => { + if (url.endsWith('/pulls/341')) currentTime = 23 * 60 * 1000; + return undefined; + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('controller-timeout'); + expect(harness.requests).toHaveLength(1); + expect(harness.sleeps).toHaveLength(0); + }); + + test('errors and logs redact both tokens', async () => { + const harness = createHarness({ + respond: ({ url }) => { + if (url.endsWith('/pulls/341')) throw new Error(`${GITHUB_TOKEN} ${VERCEL_TOKEN}`); + return undefined; + }, + }); + + let message = ''; + try { + await reconcileVercelPreviews(harness.runtime); + } catch (error) { + message = String(error); + } + expect(message).not.toContain(GITHUB_TOKEN); + expect(message).not.toContain(VERCEL_TOKEN); + expect(JSON.stringify(harness.logs)).not.toContain(GITHUB_TOKEN); + expect(JSON.stringify(harness.logs)).not.toContain(VERCEL_TOKEN); + }); +}); + +describe('identity invariants and bounded edge cases', () => { + test.each([ + ['project ID', { id: 'prj_other' }, 'project identity'], + ['project name', { name: 'other' }, 'project identity'], + ['team ID', { accountId: 'team_other' }, 'project identity'], + ['response schema', { accountId: undefined }, 'schema'], + ])( + 'a mismatched Vercel %s blocks create before mutation', + async (_name, projectOverrides, message) => { + const harness = createHarness({ + respond: ({ url }) => + url.includes('/v9/projects/') ? json(vercelProject(projectOverrides)) : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow(message); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(0); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }, + ); + + test('a mismatched Vercel project blocks cancellation before mutation', async () => { + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ url }) => + url.includes('/v9/projects/') + ? json(vercelProject({ accountId: 'team_other' })) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('project identity'); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + }); + + test.each([ + ['wrong project', { projectId: 'prj_other' }], + ['non-null target', { target: 'production' }], + ['wrong metadata', { meta: metadata({ orbitGithubWorkflowRunId: 'wrong' }) }], + ])('created response rejects %s', async (_name, responseOverrides) => { + const harness = createHarness({ + respond: ({ method }) => + method === 'POST' + ? json(mutationDeployment('dpl_created', 'QUEUED', responseOverrides)) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('ambiguous create'); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength(1); + }); + + test.each([ + ['wrong project', 'dpl_active', { projectId: 'prj_other' }], + ['non-null target', 'dpl_active', { target: 'production' }], + ['wrong ID', 'dpl_other', {}], + ['wrong metadata', 'dpl_active', { meta: metadata({ orbitDeploymentReason: 'wrong' }) }], + ])('detail response rejects %s', async (_name, responseId, responseOverrides) => { + const harness = createHarness({ + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ method, url }) => + method === 'GET' && url.includes('/v13/deployments/dpl_active') + ? json(mutationDeployment(responseId, 'READY', responseOverrides)) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('identity drift'); + }); + + test.each([ + ['wrong project', 'dpl_active', { projectId: 'prj_other' }], + ['non-null target', 'dpl_active', { target: 'production' }], + ['wrong ID', 'dpl_other', {}], + ['wrong metadata', 'dpl_active', { meta: metadata({ orbitGithubWorkflowRunId: 'wrong' }) }], + ])('cancel response rejects %s', async (_name, responseId, responseOverrides) => { + const harness = createHarness({ + pullRequest: pullRequest({ draft: true }), + deployments: [deployment('BUILDING', { uid: 'dpl_active' })], + respond: ({ method }) => + method === 'PATCH' + ? json(mutationDeployment(responseId, 'CANCELED', responseOverrides)) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('identity drift'); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength(1); + }); + + test('a deployment URL containing either token is rejected before serialization', async () => { + const harness = createHarness({ + deployments: [ + deployment('READY', { uid: 'dpl_ready', url: `${GITHUB_TOKEN}-${VERCEL_TOKEN}` }), + ], + }); + + let message = ''; + try { + await reconcileVercelPreviews(harness.runtime); + } catch (error) { + message = String(error); + } + expect(message).toContain('unsafe'); + expect(message).not.toContain(GITHUB_TOKEN); + expect(message).not.toContain(VERCEL_TOKEN); + }); + + test.each(['list', 'create', 'detail', 'cancel'] as const)( + '%s deployment IDs containing either token are rejected before serialization', + async (surface) => { + for (const secret of [GITHUB_TOKEN, VERCEL_TOKEN]) { + let deployments: readonly Record[] = []; + if (surface === 'list') deployments = [deployment('READY', { uid: secret })]; + if (surface === 'detail' || surface === 'cancel') { + deployments = [deployment('BUILDING', { uid: 'dpl_active' })]; + } + const harness = createHarness({ + pullRequest: pullRequest({ draft: surface === 'cancel' }), + deployments, + respond: ({ method, url }) => { + if (surface === 'create' && method === 'POST') { + return json(mutationDeployment(secret, 'QUEUED')); + } + if ( + surface === 'detail' && + method === 'GET' && + url.includes('/v13/deployments/dpl_active') + ) { + return json(mutationDeployment(secret, 'READY')); + } + if (surface === 'cancel' && method === 'PATCH') { + return json(mutationDeployment(secret, 'CANCELED')); + } + return undefined; + }, + }); + + const message = await rejectionMessage(reconcileVercelPreviews(harness.runtime)); + + expect(message).toContain('unsafe'); + expect(message).not.toContain(GITHUB_TOKEN); + expect(message).not.toContain(VERCEL_TOKEN); + expect(JSON.stringify(harness.logs)).not.toContain(secret); + expect(harness.requests.filter(({ method }) => method === 'POST')).toHaveLength( + surface === 'create' ? 1 : 0, + ); + expect(harness.requests.filter(({ method }) => method === 'PATCH')).toHaveLength( + surface === 'cancel' ? 1 : 0, + ); + } + }, + ); + + test('Vercel pagination rejects a repeated zero cursor', async () => { + const harness = createHarness({ + respond: ({ url }) => + url.includes('/v7/deployments') + ? json({ deployments: [], pagination: { count: 0, next: 0, prev: null } }) + : undefined, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('repeated'); + expect(harness.requests.filter(({ url }) => url.includes('/v7/deployments'))).toHaveLength(2); + }); + + test('Vercel pagination fails with a cursor remaining at its finite cap', async () => { + let cursor = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/v7/deployments')) return undefined; + cursor += 1; + return json({ deployments: [], pagination: { count: 0, next: cursor, prev: null } }); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('incomplete'); + expect(harness.requests.filter(({ url }) => url.includes('/v7/deployments'))).toHaveLength(20); + }); + + test('equal workflow creation times choose the larger run ID', async () => { + const harness = createHarness({ + workflowRuns: [ + workflowRun({ id: 10 }), + workflowRun({ id: 11, status: 'completed', conclusion: 'failure' }), + ], + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'ci-not-green' }]); + }); + + test('final CI proof blocks a run that turns non-green before create', async () => { + let workflowReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.includes('/actions/workflows/456/runs')) return undefined; + workflowReads += 1; + const run = + workflowReads === 1 + ? workflowRun() + : workflowRun({ id: 987654322, status: 'completed', conclusion: 'failure' }); + return json({ total_count: 1, workflow_runs: [run] }); + }, + }); + + const results = await reconcileVercelPreviews(harness.runtime); + + expect(results).toEqual([{ kind: 'skipped', pullRequestNumber: 341, reason: 'ci-not-green' }]); + expect(harness.requests.some(({ method }) => method === 'POST')).toBe(false); + }); + + test('an HTTP-date Retry-After uses injected monotonic time', async () => { + let pullReads = 0; + const now = Date.parse('2026-08-21T00:00:00Z'); + const harness = createHarness({ + now: () => now, + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return pullReads === 1 + ? json({ message: 'limited' }, 429, { + 'Retry-After': new Date(now + 3000).toUTCString(), + }) + : json(pullRequest()); + }, + }); + + await reconcileVercelPreviews(harness.runtime); + + expect(harness.sleeps[0]).toBe(3000); + }); + + test('an excessive Retry-After fails without sleeping or retrying', async () => { + let pullReads = 0; + const harness = createHarness({ + respond: ({ url }) => { + if (!url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + return json({ message: 'limited' }, 429, { 'Retry-After': '31' }); + }, + }); + + await expect(reconcileVercelPreviews(harness.runtime)).rejects.toThrow('excessive'); + expect(pullReads).toBe(1); + expect(harness.sleeps).toHaveLength(0); + }); + + test('each retry attempt uses a fresh abort signal', async () => { + const signals: AbortSignal[] = []; + let pullReads = 0; + const harness = createHarness({ + respond: (request) => { + if (!request.url.endsWith('/pulls/341')) return undefined; + pullReads += 1; + expect(request.headers.get('authorization')).toBe(`Bearer ${GITHUB_TOKEN}`); + return pullReads === 1 ? json({ message: 'temporary' }, 500) : json(pullRequest()); + }, + }); + const originalFetch = harness.runtime.fetch; + const runtime: PreviewRuntime = { + ...harness.runtime, + fetch: async (input, init) => { + if (String(input).endsWith('/pulls/341') && init?.signal) signals.push(init.signal); + return await originalFetch(input, init); + }, + }; + + await reconcileVercelPreviews(runtime); + + expect(signals).toHaveLength(4); + expect(new Set(signals).size).toBe(4); + }); + + test('missing configuration fails before reading the event', async () => { + const harness = createHarness(); + const runtime: PreviewRuntime = { + ...harness.runtime, + env: { ...harness.runtime.env, VERCEL_PROJECT_ID: undefined }, + }; + + await expect(reconcileVercelPreviews(runtime)).rejects.toThrow(); + expect(harness.requests).toHaveLength(0); + }); +}); diff --git a/scripts/vercel-preview-deploy.ts b/scripts/vercel-preview-deploy.ts new file mode 100644 index 000000000..2bf8b1c7d --- /dev/null +++ b/scripts/vercel-preview-deploy.ts @@ -0,0 +1,1386 @@ +import { + type GithubPreviewPullRequest, + type GithubPreviewRepository, + type GithubPreviewWorkflowRun, + githubPreviewFilesSchema, + githubPreviewPullRequestSchema, + githubPreviewPullRequestTargetEventSchema, + githubPreviewRefSchema, + githubPreviewRepositoryDispatchEventSchema, + githubPreviewWorkflowRunEventSchema, + githubPreviewWorkflowRunsSchema, + githubPreviewWorkflowSchema, + gitShaSchema, + type VercelCanceledDeployment, + type VercelCreatedDeployment, + type VercelDeployment, + type VercelDeploymentDetail, + type VercelPreviewEnvironment, + vercelCanceledDeploymentSchema, + vercelCreatedDeploymentSchema, + vercelDeploymentDetailSchema, + vercelDeploymentsPageSchema, + vercelPreviewEnvironmentSchema, + vercelProjectSchema, +} from '../packages/shared/src/validators/index.ts'; +import { + isActiveVercelDeployment, + isPreviewEligible, + isReadyVercelDeployment, + isSameRepositoryPullRequest, + isWebPreviewFile, + matchesVercelPullRequest, +} from './vercel-preview-policy.ts'; + +const GITHUB_API = 'https://api.github.com'; +const VERCEL_API = 'https://api.vercel.com'; +const REQUEST_TIMEOUT_MS = 15_000; +const CONTROLLER_TIMEOUT_MS = 23 * 60 * 1000; +const MAX_READ_ATTEMPTS = 3; +const MAX_RETRY_SLEEP_MS = 30_000; +const MAX_VERCEL_PAGES = 20; +const USER_AGENT = 'orbit-vercel-preview-reconciler'; +const ORBIT_METADATA_KEYS = [ + 'orbitDeploymentReason', + 'orbitGithubHeadRef', + 'orbitGithubHeadSha', + 'orbitGithubPrNumber', + 'orbitGithubRepositoryId', + 'orbitGithubWorkflowRunId', +] as const; + +type PreviewReason = + | 'event-not-actionable' + | 'workflow-run-unassociated' + | 'stale-event' + | 'repository-mismatch' + | 'fork-pull-request' + | 'base-mismatch' + | 'preview-eligible' + | 'no-active-deployment' + | 'web-unaffected' + | 'ci-unavailable' + | 'ci-not-current' + | 'ci-not-green' + | 'ready-deployment-reused' + | 'active-deployment-reused' + | 'created-ready' + | 'canceled-active'; + +type SkippedReason = Exclude< + PreviewReason, + 'ready-deployment-reused' | 'active-deployment-reused' | 'created-ready' | 'canceled-active' +>; + +export type PreviewResult = + | { + readonly kind: 'skipped'; + readonly pullRequestNumber: number; + readonly reason: SkippedReason; + } + | { + readonly kind: 'created'; + readonly pullRequestNumber: number; + readonly reason: 'ready-deployment-reused' | 'active-deployment-reused' | 'created-ready'; + readonly deploymentId: string; + readonly url: string; + } + | { + readonly kind: 'canceled'; + readonly pullRequestNumber: number; + readonly reason: 'canceled-active'; + readonly deploymentId: string; + readonly url: string; + }; + +export type PreviewRuntime = { + readonly env: Readonly>; + readonly readText: (path: string) => Promise; + readonly fetch: (input: string | URL | Request, init?: RequestInit) => Promise; + readonly sleep: (milliseconds: number) => Promise; + readonly scheduleTimeout: (callback: () => void, milliseconds: number) => () => void; + readonly now: () => number; + readonly log: (message: string) => void; +}; + +type ControllerRuntime = PreviewRuntime & { + readonly assertBudget: (duration: number) => void; +}; + +type PreviewCandidate = { + readonly number: number; + readonly expectedHeadSha: string | null; +}; + +type RequestKind = 'github' | 'vercel'; + +class RequestFailure extends Error { + readonly status: number | null; + readonly ambiguousMutation: boolean; + readonly retryDelay: number | null; + + constructor( + message: string, + status: number | null, + ambiguousMutation = false, + retryDelay: number | null = null, + ) { + super(message); + this.status = status; + this.ambiguousMutation = ambiguousMutation; + this.retryDelay = retryDelay; + } +} + +type ResponseSchema = { + readonly safeParse: ( + value: unknown, + ) => { readonly success: true; readonly data: T } | { readonly success: false }; +}; + +function safeMessage(value: unknown, environment?: VercelPreviewEnvironment): string { + const initial = value instanceof Error ? value.message : String(value); + const secrets = environment ? [environment.GITHUB_TOKEN, environment.VERCEL_TOKEN] : []; + return secrets + .reduce((message, secret) => message.split(secret).join('[redacted]'), initial) + .slice(0, 512); +} + +function fail(message: string): never { + throw new Error(message); +} + +function withControllerDeadline(runtime: PreviewRuntime): ControllerRuntime { + let previousTime = runtime.now(); + const deadline = previousTime + CONTROLLER_TIMEOUT_MS; + const assertBudget = (duration: number) => { + const currentTime = runtime.now(); + if (currentTime < previousTime || duration < 0 || currentTime > deadline - duration) { + fail('controller-timeout'); + } + previousTime = currentTime; + }; + return { + ...runtime, + assertBudget, + readText: async (path) => { + assertBudget(0); + const text = await runtime.readText(path); + assertBudget(0); + return text; + }, + fetch: async (input, init) => { + assertBudget(REQUEST_TIMEOUT_MS); + const response = await runtime.fetch(input, init); + assertBudget(0); + return response; + }, + sleep: async (milliseconds) => { + assertBudget(milliseconds); + await runtime.sleep(milliseconds); + assertBudget(0); + }, + }; +} + +function parseJsonText(text: string, label: string): unknown { + try { + return JSON.parse(text); + } catch { + return fail(`${label} returned invalid JSON`); + } +} + +function retryAfterMilliseconds(response: Response, runtime: PreviewRuntime): number | null { + const retryAfter = response.headers.get('retry-after'); + if (retryAfter !== null) { + const seconds = Number(retryAfter); + const milliseconds = Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(retryAfter) - runtime.now(); + if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > MAX_RETRY_SLEEP_MS) { + return fail('retry wait is invalid or excessive'); + } + return milliseconds; + } + const remaining = response.headers.get('x-ratelimit-remaining'); + const reset = response.headers.get('x-ratelimit-reset'); + if (remaining === '0' && reset !== null) { + const milliseconds = Number(reset) * 1000 - runtime.now(); + if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > MAX_RETRY_SLEEP_MS) { + return fail('retry wait is invalid or excessive'); + } + return milliseconds; + } + return null; +} + +function shouldRetryRead(response: Response, kind: RequestKind): boolean { + if (response.status === 429 || response.status >= 500) return true; + if (kind !== 'github' || response.status !== 403) return false; + return ( + response.headers.has('retry-after') || + (response.headers.get('x-ratelimit-remaining') === '0' && + response.headers.has('x-ratelimit-reset')) + ); +} + +function requestHeaders( + environment: VercelPreviewEnvironment, + kind: RequestKind, + includesBody: boolean, +): Headers { + const headers = new Headers({ + Authorization: `Bearer ${kind === 'github' ? environment.GITHUB_TOKEN : environment.VERCEL_TOKEN}`, + 'User-Agent': USER_AGENT, + }); + if (kind === 'github') { + headers.set('Accept', 'application/vnd.github+json'); + headers.set('X-GitHub-Api-Version', '2022-11-28'); + } + if (includesBody) headers.set('Content-Type', 'application/json'); + return headers; +} + +function parseSuccessfulResponse( + response: Response, + text: string, + schema: ResponseSchema, + safeRead: boolean, +): T { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new RequestFailure('response returned invalid JSON', response.status, !safeRead); + } + const result = schema.safeParse(parsed); + if (!result.success) { + throw new RequestFailure('response did not match its schema', response.status, !safeRead); + } + return result.data; +} + +function interpretResponse( + runtime: ControllerRuntime, + kind: RequestKind, + response: Response, + text: string, + schema: ResponseSchema, + safeRead: boolean, +): T { + if (response.status >= 300 && response.status < 400) { + throw new RequestFailure('redirect rejected', response.status); + } + if (response.ok) return parseSuccessfulResponse(response, text, schema, safeRead); + const retryDelay = + safeRead && shouldRetryRead(response, kind) + ? (retryAfterMilliseconds(response, runtime) ?? 1000) + : null; + const ambiguousMutation = + !safeRead && (response.status === 409 || response.status === 429 || response.status >= 500); + throw new RequestFailure( + `request failed with status ${response.status}`, + response.status, + ambiguousMutation, + retryDelay, + ); +} + +async function requestJsonAttempt( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + kind: RequestKind, + url: string, + schema: ResponseSchema, + method: 'GET' | 'POST' | 'PATCH', + body: unknown, +): Promise { + const safeRead = method === 'GET'; + const controller = new AbortController(); + const cancelTimeout = runtime.scheduleTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const request: RequestInit = { + method, + headers: requestHeaders(environment, kind, body !== undefined), + redirect: 'manual', + signal: controller.signal, + }; + if (body !== undefined) request.body = JSON.stringify(body); + try { + let response: Response; + try { + response = await runtime.fetch(url, request); + } catch (error) { + throw new RequestFailure( + safeMessage(error, environment), + null, + !safeRead, + safeRead ? 1000 : null, + ); + } + let text: string; + try { + text = await response.text(); + } catch (error) { + throw new RequestFailure( + safeMessage(error, environment), + response.status, + !safeRead, + safeRead ? 1000 : null, + ); + } + return interpretResponse(runtime, kind, response, text, schema, safeRead); + } finally { + cancelTimeout(); + } +} + +async function requestJson( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + kind: RequestKind, + url: string, + schema: ResponseSchema, + options: { readonly method?: 'GET' | 'POST' | 'PATCH'; readonly body?: unknown } = {}, +): Promise { + const method = options.method ?? 'GET'; + const safeRead = method === 'GET'; + const maximumAttempts = safeRead ? MAX_READ_ATTEMPTS : 1; + for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) { + try { + return await requestJsonAttempt( + runtime, + environment, + kind, + url, + schema, + method, + options.body, + ); + } catch (error) { + const retryDelay = error instanceof RequestFailure ? error.retryDelay : null; + if (attempt === maximumAttempts || retryDelay === null) throw error; + await runtime.sleep(retryDelay); + } + } + return fail('read attempts exhausted'); +} + +function githubUrl(repositorySlug: string, path: string): string { + return `${GITHUB_API}/repos/${repositorySlug}${path}`; +} + +function vercelUrl(path: string, query: Readonly>): string { + const url = new URL(path, VERCEL_API); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + return url.toString(); +} + +async function verifyVercelProjectIdentity( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, +): Promise { + const projectId = encodeURIComponent(environment.VERCEL_PROJECT_ID); + const project = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl(`/v9/projects/${projectId}`, { teamId: environment.VERCEL_TEAM_ID }), + vercelProjectSchema, + ); + if ( + project.id !== environment.VERCEL_PROJECT_ID || + project.name !== environment.VERCEL_PROJECT_NAME || + project.accountId !== environment.VERCEL_TEAM_ID + ) { + fail('Vercel project identity mismatch'); + } +} + +function repositorySlugMatches( + repository: GithubPreviewRepository, + configuredSlug: string, +): boolean { + return ( + `${repository.owner.login}/${repository.name}`.toLowerCase() === configuredSlug.toLowerCase() + ); +} + +function skipped(pullRequestNumber: number, reason: SkippedReason): PreviewResult { + return { kind: 'skipped', pullRequestNumber, reason }; +} + +function resolveCandidates( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + eventValue: unknown, +): { + readonly repository: GithubPreviewRepository; + readonly candidates: readonly PreviewCandidate[]; +} { + if (environment.GITHUB_EVENT_NAME === 'pull_request_target') { + const event = githubPreviewPullRequestTargetEventSchema.parse(eventValue); + if (event.number !== event.pull_request.number) fail('event pull request numbers disagree'); + return { + repository: event.repository, + candidates: [{ number: event.number, expectedHeadSha: event.pull_request.head.sha }], + }; + } + if (environment.GITHUB_EVENT_NAME === 'repository_dispatch') { + const event = githubPreviewRepositoryDispatchEventSchema.parse(eventValue); + return { + repository: event.repository, + candidates: [{ number: event.client_payload.pull_request, expectedHeadSha: null }], + }; + } + const event = githubPreviewWorkflowRunEventSchema.parse(eventValue); + const run = event.workflow_run; + if ( + event.action !== 'completed' || + run.name !== 'CI' || + run.event !== 'pull_request' || + run.status !== 'completed' || + run.conclusion !== 'success' + ) { + runtime.log('event-not-actionable'); + return { repository: event.repository, candidates: [] }; + } + if (run.pull_requests.length === 0) { + runtime.log('workflow-run-unassociated'); + return { repository: event.repository, candidates: [] }; + } + const numbers = [...new Set(run.pull_requests.map(({ number }) => number))].sort( + (left, right) => left - right, + ); + if (numbers.length > 1) { + runtime.log('workflow-run-ambiguous-associations'); + return { repository: event.repository, candidates: [] }; + } + return { + repository: event.repository, + candidates: numbers.map((number) => ({ number, expectedHeadSha: run.head_sha })), + }; +} + +function fetchPullRequest( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + number: number, +) { + return requestJson( + runtime, + environment, + 'github', + githubUrl(environment.GITHUB_REPOSITORY, `/pulls/${number}`), + githubPreviewPullRequestSchema, + ); +} + +type CiProof = + | { readonly ok: true; readonly workflowRunId: number; readonly mainSha: string } + | { readonly ok: false; readonly reason: 'ci-unavailable' | 'ci-not-current' | 'ci-not-green' }; + +function newestWorkflowRun( + runs: readonly GithubPreviewWorkflowRun[], +): GithubPreviewWorkflowRun | null { + let newest: GithubPreviewWorkflowRun | null = null; + for (const run of runs) { + if (newest === null) { + newest = run; + continue; + } + const timeDifference = Date.parse(run.created_at) - Date.parse(newest.created_at); + if (timeDifference > 0 || (timeDifference === 0 && run.id > newest.id)) newest = run; + } + return newest; +} + +function runAssociationMatches( + run: GithubPreviewWorkflowRun, + pullRequest: GithubPreviewPullRequest, + mainSha: string, +): boolean { + return run.pull_requests.some( + (association) => + association.number === pullRequest.number && + association.head.sha === pullRequest.head.sha && + association.head.ref === pullRequest.head.ref && + association.head.repo.id === pullRequest.head.repo.id && + association.base.sha === mainSha && + association.base.ref === pullRequest.base.ref && + association.base.repo.id === pullRequest.base.repo.id, + ); +} + +async function fetchWorkflowRuns( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + workflowId: number, + headSha: string, +): Promise { + const runsUrl = new URL( + githubUrl(environment.GITHUB_REPOSITORY, `/actions/workflows/${workflowId}/runs`), + ); + runsUrl.searchParams.set('event', 'pull_request'); + runsUrl.searchParams.set('head_sha', headSha); + runsUrl.searchParams.set('per_page', '100'); + const runs: GithubPreviewWorkflowRun[] = []; + const seenRunIds = new Set(); + let expectedTotal: number | null = null; + for (let pageNumber = 1; pageNumber <= 10; pageNumber += 1) { + runsUrl.searchParams.set('page', String(pageNumber)); + const page = await requestJson( + runtime, + environment, + 'github', + runsUrl.toString(), + githubPreviewWorkflowRunsSchema, + ); + if (expectedTotal === null) expectedTotal = page.total_count; + if (page.total_count !== expectedTotal) fail('workflow run total_count changed between pages'); + for (const run of page.workflow_runs) { + if (seenRunIds.has(run.id)) fail('workflow run pagination returned a duplicate ID'); + seenRunIds.add(run.id); + runs.push(run); + } + if (runs.length > expectedTotal) fail('workflow run total_count is inconsistent'); + if (runs.length === expectedTotal) return runs; + } + return fail('workflow run pagination is incomplete'); +} + +async function proveCurrentCi( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, +): Promise { + const workflow = await requestJson( + runtime, + environment, + 'github', + githubUrl(environment.GITHUB_REPOSITORY, '/actions/workflows/ci.yml'), + githubPreviewWorkflowSchema, + ); + if ( + workflow.name !== 'CI' || + workflow.path !== '.github/workflows/ci.yml' || + workflow.state !== 'active' + ) { + return { ok: false, reason: 'ci-not-current' }; + } + const main = await requestJson( + runtime, + environment, + 'github', + githubUrl(environment.GITHUB_REPOSITORY, '/git/ref/heads/main'), + githubPreviewRefSchema, + ); + const runs = await fetchWorkflowRuns(runtime, environment, workflow.id, pullRequest.head.sha); + const newest = newestWorkflowRun(runs); + if (newest === null) return { ok: false, reason: 'ci-unavailable' }; + if ( + newest.workflow_id !== workflow.id || + newest.event !== 'pull_request' || + newest.head_sha !== pullRequest.head.sha || + !runAssociationMatches(newest, pullRequest, main.object.sha) + ) { + return { ok: false, reason: 'ci-not-current' }; + } + if (newest.status !== 'completed' || newest.conclusion !== 'success') { + return { ok: false, reason: 'ci-not-green' }; + } + return { ok: true, workflowRunId: newest.id, mainSha: main.object.sha }; +} + +async function affectsWeb( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequestNumber: number, +): Promise { + const url = new URL( + githubUrl(environment.GITHUB_REPOSITORY, `/pulls/${pullRequestNumber}/files`), + ); + url.searchParams.set('per_page', '100'); + for (let pageNumber = 1; pageNumber <= 30; pageNumber += 1) { + url.searchParams.set('page', String(pageNumber)); + const files = await requestJson( + runtime, + environment, + 'github', + url.toString(), + githubPreviewFilesSchema, + ); + if (files.some(isWebPreviewFile)) return true; + if (files.length < 100) return false; + } + return fail('pull request files pagination is incomplete'); +} + +function deploymentMetadata(pullRequest: GithubPreviewPullRequest, workflowRunId: number) { + return { + orbitDeploymentReason: 'ci-green-pr-preview', + orbitGithubHeadRef: pullRequest.head.ref, + orbitGithubHeadSha: pullRequest.head.sha, + orbitGithubPrNumber: String(pullRequest.number), + orbitGithubRepositoryId: String(pullRequest.base.repo.id), + orbitGithubWorkflowRunId: String(workflowRunId), + }; +} + +async function listDeployments( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + includeSha: boolean, +): Promise { + const query: Record = { + teamId: environment.VERCEL_TEAM_ID, + projectId: environment.VERCEL_PROJECT_ID, + branch: pullRequest.head.ref, + limit: '100', + }; + if (includeSha) query['sha'] = pullRequest.head.sha; + const deployments: VercelDeployment[] = []; + const seenCursors = new Set(); + let cursor: number | null = null; + for (let pageNumber = 1; pageNumber <= MAX_VERCEL_PAGES; pageNumber += 1) { + const pageQuery = { ...query }; + if (cursor !== null) pageQuery['until'] = String(cursor); + const page = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl('/v7/deployments', pageQuery), + vercelDeploymentsPageSchema, + ); + for (const deployment of page.deployments) { + assertSafeDeploymentId(deployment.uid, environment); + } + deployments.push(...page.deployments); + const next = page.pagination.next; + if (next === null) return deployments; + if (seenCursors.has(next)) fail('Vercel deployment pagination repeated a cursor'); + seenCursors.add(next); + cursor = next; + } + return fail('Vercel deployment pagination is incomplete'); +} + +function detailIdentityMatches( + detail: VercelDeploymentDetail, + id: string, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + expectedMetadata: Readonly>, + headSha?: string, +): boolean { + const comparable = comparableDeployment(detail); + return ( + detail.id === id && + ORBIT_METADATA_KEYS.every((key) => { + const expected = expectedMetadata[key]; + const actual = detail.meta[key]; + return expected !== undefined && expected !== null && String(actual) === String(expected); + }) && + matchesVercelPullRequest(comparable, pullRequest, environment.VERCEL_PROJECT_ID, headSha) + ); +} + +function assertSafeDeploymentUrl(url: string, environment: VercelPreviewEnvironment): string { + if (url.includes(environment.GITHUB_TOKEN) || url.includes(environment.VERCEL_TOKEN)) { + fail('deployment URL is unsafe'); + } + return url; +} + +function assertSafeDeploymentId(id: string, environment: VercelPreviewEnvironment): string { + if (id.includes(environment.GITHUB_TOKEN) || id.includes(environment.VERCEL_TOKEN)) { + fail('deployment ID is unsafe'); + } + return id; +} + +function deploymentPathSegment(id: string, environment: VercelPreviewEnvironment): string { + return encodeURIComponent(assertSafeDeploymentId(id, environment)); +} + +function assertSafePreviewResults( + results: readonly PreviewResult[], + environment: VercelPreviewEnvironment, +): readonly PreviewResult[] { + const serialized = JSON.stringify(results); + if ( + serialized.includes(environment.GITHUB_TOKEN) || + serialized.includes(environment.VERCEL_TOKEN) + ) { + fail('preview result is unsafe'); + } + return results; +} + +type DeploymentPollResult = + | { readonly kind: 'ready'; readonly deployment: VercelDeploymentDetail } + | { readonly kind: 'interrupted'; readonly result: PreviewResult }; + +function comparableDeployment(detail: VercelDeploymentDetail): VercelDeployment { + return { + uid: detail.id, + projectId: detail.projectId, + url: detail.url, + target: detail.target, + readyState: detail.readyState, + meta: detail.meta, + }; +} + +function trustedCurrentPullRequest( + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, +): boolean { + return ( + repositorySlugMatches(pullRequest.base.repo, environment.GITHUB_REPOSITORY) && + isSameRepositoryPullRequest(pullRequest) && + pullRequest.base.ref === 'main' + ); +} + +async function cancelPolledDeployment( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + deployment: VercelDeployment, + intent: CancellationIntent, +): Promise { + return ( + (await cancelOneActiveDeployment(runtime, environment, pullRequest, deployment, intent)) ?? + skipped(pullRequest.number, 'no-active-deployment') + ); +} + +async function pollingInterruption( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + originalPullRequest: GithubPreviewPullRequest, + currentPullRequest: GithubPreviewPullRequest, + deployment: VercelDeployment, +): Promise { + if (!trustedCurrentPullRequest(environment, currentPullRequest)) { + return skipped(originalPullRequest.number, 'stale-event'); + } + if (pullRequestIdentityMatches(originalPullRequest, currentPullRequest)) { + if (currentPullRequest.state === 'open' && isPreviewEligible(currentPullRequest)) return null; + return await cancelPolledDeployment( + runtime, + environment, + currentPullRequest, + deployment, + 'ineligible-current', + ); + } + if (!supersedingHeadMatches(originalPullRequest, currentPullRequest)) { + return skipped(originalPullRequest.number, 'stale-event'); + } + return await cancelPolledDeployment( + runtime, + environment, + originalPullRequest, + deployment, + 'superseded-head', + ); +} + +async function pollDeployment( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + deploymentId: string, + expectedMetadata: Readonly>, +): Promise { + for (let detailRequest = 0; detailRequest <= 240; detailRequest += 1) { + const encodedDeploymentId = deploymentPathSegment(deploymentId, environment); + const detail = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl(`/v13/deployments/${encodedDeploymentId}`, { + teamId: environment.VERCEL_TEAM_ID, + }), + vercelDeploymentDetailSchema, + ); + assertSafeDeploymentId(detail.id, environment); + assertSafeDeploymentUrl(detail.url, environment); + if ( + !detailIdentityMatches( + detail, + deploymentId, + environment, + pullRequest, + expectedMetadata, + pullRequest.head.sha, + ) + ) { + return fail('deployment detail identity drift'); + } + if (detail.readyState === 'READY') return { kind: 'ready', deployment: detail }; + const comparable = comparableDeployment(detail); + if (!isActiveVercelDeployment(comparable)) + return fail(`deployment ended in ${detail.readyState}`); + const currentPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + const interruption = await pollingInterruption( + runtime, + environment, + pullRequest, + currentPullRequest, + comparable, + ); + if (interruption) return { kind: 'interrupted', result: interruption }; + if (detailRequest === 240) return fail('deployment polling timed out'); + await runtime.sleep(5000); + } + return fail('deployment polling timed out'); +} + +function repositoryIdentityMatches( + first: GithubPreviewRepository, + current: GithubPreviewRepository, +): boolean { + return ( + first.id === current.id && + first.name.toLowerCase() === current.name.toLowerCase() && + first.owner.login.toLowerCase() === current.owner.login.toLowerCase() + ); +} + +function pullRequestIdentityMatches( + first: GithubPreviewPullRequest, + current: GithubPreviewPullRequest, +): boolean { + return ( + first.number === current.number && + first.head.sha === current.head.sha && + first.head.ref === current.head.ref && + repositoryIdentityMatches(first.head.repo, current.head.repo) && + first.base.ref === current.base.ref && + repositoryIdentityMatches(first.base.repo, current.base.repo) + ); +} + +function supersedingHeadMatches( + first: GithubPreviewPullRequest, + current: GithubPreviewPullRequest, +): boolean { + return ( + first.number === current.number && + first.head.sha !== current.head.sha && + first.head.ref === current.head.ref && + repositoryIdentityMatches(first.head.repo, current.head.repo) && + first.base.ref === current.base.ref && + repositoryIdentityMatches(first.base.repo, current.base.repo) + ); +} + +function currentStateMatches( + first: GithubPreviewPullRequest, + current: GithubPreviewPullRequest, +): boolean { + const firstLabels = first.labels + .map(({ name }) => name) + .sort() + .join('\n'); + const currentLabels = current.labels + .map(({ name }) => name) + .sort() + .join('\n'); + return ( + pullRequestIdentityMatches(first, current) && + first.state === current.state && + first.draft === current.draft && + firstLabels === currentLabels + ); +} + +type CancellationIntent = 'eligible-current' | 'ineligible-current' | 'superseded-head'; + +function cancellationPreflightFailure( + environment: VercelPreviewEnvironment, + expectedPullRequest: GithubPreviewPullRequest, + currentPullRequest: GithubPreviewPullRequest, + intent: CancellationIntent, +): SkippedReason | null { + if (!trustedCurrentPullRequest(environment, currentPullRequest)) return 'stale-event'; + if (intent === 'superseded-head') { + return supersedingHeadMatches(expectedPullRequest, currentPullRequest) ? null : 'stale-event'; + } + if (!pullRequestIdentityMatches(expectedPullRequest, currentPullRequest)) return 'stale-event'; + const eligible = currentPullRequest.state === 'open' && isPreviewEligible(currentPullRequest); + if (intent === 'eligible-current') return eligible ? null : 'stale-event'; + return eligible ? 'preview-eligible' : null; +} + +async function cancelOneActiveDeployment( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + item: VercelDeployment, + intent: CancellationIntent, +): Promise { + let canceled: VercelCanceledDeployment; + const encodedDeploymentId = deploymentPathSegment(item.uid, environment); + await verifyVercelProjectIdentity(runtime, environment); + const currentPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + const preflightFailure = cancellationPreflightFailure( + environment, + pullRequest, + currentPullRequest, + intent, + ); + if (preflightFailure !== null) return skipped(pullRequest.number, preflightFailure); + try { + canceled = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl(`/v12/deployments/${encodedDeploymentId}/cancel`, { + teamId: environment.VERCEL_TEAM_ID, + }), + vercelCanceledDeploymentSchema, + { method: 'PATCH' }, + ); + } catch (error) { + if (!(error instanceof RequestFailure)) throw error; + if (error.status !== 400 && !error.ambiguousMutation) throw error; + const detail = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl(`/v13/deployments/${encodedDeploymentId}`, { + teamId: environment.VERCEL_TEAM_ID, + }), + vercelDeploymentDetailSchema, + ); + assertSafeDeploymentId(detail.id, environment); + assertSafeDeploymentUrl(detail.url, environment); + if (!detailIdentityMatches(detail, item.uid, environment, pullRequest, item.meta)) { + fail('canceled deployment identity drift'); + } + if (isActiveVercelDeployment({ ...item, readyState: detail.readyState })) { + fail('deployment remains active after cancel race'); + } + return null; + } + assertSafeDeploymentId(canceled.id, environment); + if ( + canceled.readyState !== 'CANCELED' || + !detailIdentityMatches(canceled, item.uid, environment, pullRequest, item.meta) + ) { + fail('canceled deployment identity drift'); + } + return { + kind: 'canceled', + pullRequestNumber: pullRequest.number, + reason: 'canceled-active', + deploymentId: canceled.id, + url: assertSafeDeploymentUrl(canceled.url, environment), + }; +} + +type PriorHeadCancellation = { + readonly current: boolean; + readonly results: readonly PreviewResult[]; +}; + +function isActivePriorHeadDeployment( + deployment: VercelDeployment, + pullRequest: GithubPreviewPullRequest, + projectId: string, +): boolean { + const headSha = deployment.meta['orbitGithubHeadSha']; + const parsedHeadSha = gitShaSchema.safeParse(headSha); + return ( + isActiveVercelDeployment(deployment) && + matchesVercelPullRequest(deployment, pullRequest, projectId) && + parsedHeadSha.success && + parsedHeadSha.data !== pullRequest.head.sha + ); +} + +async function cancelPriorHeadDeployments( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, +): Promise { + const listed = await listDeployments(runtime, environment, pullRequest, false); + const activePriorHeads = listed + .filter((deployment) => + isActivePriorHeadDeployment(deployment, pullRequest, environment.VERCEL_PROJECT_ID), + ) + .sort((left, right) => left.uid.localeCompare(right.uid)); + const results: PreviewResult[] = []; + for (const deployment of activePriorHeads) { + const currentPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + const stateIsCurrent = + currentStateMatches(pullRequest, currentPullRequest) && + repositorySlugMatches(currentPullRequest.base.repo, environment.GITHUB_REPOSITORY) && + isSameRepositoryPullRequest(currentPullRequest) && + currentPullRequest.base.ref === 'main' && + currentPullRequest.state === 'open' && + isPreviewEligible(currentPullRequest); + if (!stateIsCurrent) { + return { + current: false, + results: results.length > 0 ? results : [skipped(pullRequest.number, 'stale-event')], + }; + } + const result = await cancelOneActiveDeployment( + runtime, + environment, + currentPullRequest, + deployment, + 'eligible-current', + ); + if (result?.kind === 'skipped') { + return { + current: false, + results: results.length > 0 ? results : [result], + }; + } + if (result) results.push(result); + } + return { current: true, results }; +} + +function activeCancellationFailure( + environment: VercelPreviewEnvironment, + expectedPullRequest: GithubPreviewPullRequest, + currentPullRequest: GithubPreviewPullRequest, +): SkippedReason | null { + const identityIsCurrent = + pullRequestIdentityMatches(expectedPullRequest, currentPullRequest) && + repositorySlugMatches(currentPullRequest.base.repo, environment.GITHUB_REPOSITORY) && + isSameRepositoryPullRequest(currentPullRequest); + if (!identityIsCurrent) return 'stale-event'; + return currentPullRequest.state === 'open' && isPreviewEligible(currentPullRequest) + ? 'preview-eligible' + : null; +} + +async function cancelActiveDeployments( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, +): Promise { + const listed = await listDeployments(runtime, environment, pullRequest, false); + const active = listed + .filter( + (item) => + isActiveVercelDeployment(item) && + matchesVercelPullRequest(item, pullRequest, environment.VERCEL_PROJECT_ID), + ) + .sort((left, right) => left.uid.localeCompare(right.uid)); + if (active.length === 0) { + return [skipped(pullRequest.number, 'no-active-deployment')]; + } + const results: PreviewResult[] = []; + for (const item of active) { + const finalPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + const failure = activeCancellationFailure(environment, pullRequest, finalPullRequest); + if (failure !== null) { + return results.length > 0 ? results : [skipped(pullRequest.number, failure)]; + } + const result = await cancelOneActiveDeployment( + runtime, + environment, + finalPullRequest, + item, + 'ineligible-current', + ); + if (result?.kind === 'skipped') return results.length > 0 ? results : [result]; + if (result) results.push(result); + } + return results.length > 0 ? results : [skipped(pullRequest.number, 'no-active-deployment')]; +} + +async function reusedDeploymentResult( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + deployments: readonly VercelDeployment[], +): Promise { + const ready = deployments.find(isReadyVercelDeployment); + if (ready) { + let final: { readonly id: string; readonly url: string }; + if (ready.url) { + final = { id: ready.uid, url: assertSafeDeploymentUrl(ready.url, environment) }; + } else { + const polled = await pollDeployment(runtime, environment, pullRequest, ready.uid, ready.meta); + if (polled.kind === 'interrupted') return polled.result; + final = polled.deployment; + } + return { + kind: 'created', + pullRequestNumber: pullRequest.number, + reason: 'ready-deployment-reused', + deploymentId: final.id, + url: final.url, + }; + } + const active = deployments.find(isActiveVercelDeployment); + if (!active) return null; + const polled = await pollDeployment(runtime, environment, pullRequest, active.uid, active.meta); + if (polled.kind === 'interrupted') return polled.result; + const final = polled.deployment; + return { + kind: 'created', + pullRequestNumber: pullRequest.number, + reason: 'active-deployment-reused', + deploymentId: final.id, + url: final.url, + }; +} + +async function observeAmbiguousCreate( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + preCreateDeploymentIds: ReadonlySet, +): Promise { + for (let observation = 1; observation <= 3; observation += 1) { + if (observation > 1) await runtime.sleep(2000); + const observed = await listDeployments(runtime, environment, pullRequest, true); + const newlyVisible = observed.filter( + (item) => + !preCreateDeploymentIds.has(item.uid) && + matchesVercelPullRequest( + item, + pullRequest, + environment.VERCEL_PROJECT_ID, + pullRequest.head.sha, + ), + ); + const result = await reusedDeploymentResult(runtime, environment, pullRequest, newlyVisible); + if (result) return result; + if (newlyVisible.length > 0) fail('ambiguous create observed a terminal deployment'); + } + return fail('ambiguous create produced no new deployment'); +} + +async function createDeployment( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + pullRequest: GithubPreviewPullRequest, + forceNew: boolean, + preCreateDeploymentIds: ReadonlySet, +): Promise { + await verifyVercelProjectIdentity(runtime, environment); + const ci = await proveCurrentCi(runtime, environment, pullRequest); + if (!ci.ok) return skipped(pullRequest.number, ci.reason); + const mutationPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + const mutationStateIsCurrent = + currentStateMatches(pullRequest, mutationPullRequest) && + repositorySlugMatches(mutationPullRequest.base.repo, environment.GITHUB_REPOSITORY) && + isSameRepositoryPullRequest(mutationPullRequest) && + mutationPullRequest.base.ref === 'main' && + mutationPullRequest.state === 'open' && + isPreviewEligible(mutationPullRequest); + if (!mutationStateIsCurrent) return skipped(pullRequest.number, 'stale-event'); + const currentMain = await requestJson( + runtime, + environment, + 'github', + githubUrl(environment.GITHUB_REPOSITORY, '/git/ref/heads/main'), + githubPreviewRefSchema, + ); + if (currentMain.object.sha !== ci.mainSha) return skipped(pullRequest.number, 'ci-not-current'); + const metadata = deploymentMetadata(mutationPullRequest, ci.workflowRunId); + let created: VercelCreatedDeployment; + try { + created = await requestJson( + runtime, + environment, + 'vercel', + vercelUrl('/v13/deployments', { + teamId: environment.VERCEL_TEAM_ID, + ...(forceNew ? { forceNew: '1' } : {}), + }), + vercelCreatedDeploymentSchema, + { + method: 'POST', + body: { + name: environment.VERCEL_PROJECT_NAME, + project: environment.VERCEL_PROJECT_ID, + gitSource: { + type: 'github', + repoId: mutationPullRequest.base.repo.id, + ref: mutationPullRequest.head.ref, + sha: mutationPullRequest.head.sha, + }, + meta: metadata, + }, + }, + ); + assertSafeDeploymentId(created.id, environment); + assertSafeDeploymentUrl(created.url, environment); + if ( + !detailIdentityMatches( + created, + created.id, + environment, + mutationPullRequest, + metadata, + mutationPullRequest.head.sha, + ) + ) { + throw new RequestFailure('created deployment identity drift', 200, true); + } + } catch (error) { + if (!(error instanceof RequestFailure && error.ambiguousMutation)) throw error; + return observeAmbiguousCreate( + runtime, + environment, + mutationPullRequest, + preCreateDeploymentIds, + ); + } + const polled = await pollDeployment( + runtime, + environment, + mutationPullRequest, + created.id, + created.meta, + ); + if (polled.kind === 'interrupted') return polled.result; + const ready = polled.deployment; + return { + kind: 'created', + pullRequestNumber: mutationPullRequest.number, + reason: 'created-ready', + deploymentId: ready.id, + url: ready.url, + }; +} + +function candidateIdentityFailure( + environment: VercelPreviewEnvironment, + eventRepository: GithubPreviewRepository, + candidate: PreviewCandidate, + pullRequest: GithubPreviewPullRequest, +): SkippedReason | null { + if ( + !repositorySlugMatches(eventRepository, environment.GITHUB_REPOSITORY) || + eventRepository.id !== pullRequest.base.repo.id || + !repositorySlugMatches(pullRequest.base.repo, environment.GITHUB_REPOSITORY) + ) { + return 'repository-mismatch'; + } + if (candidate.expectedHeadSha !== null && candidate.expectedHeadSha !== pullRequest.head.sha) { + return 'stale-event'; + } + if ( + !isSameRepositoryPullRequest(pullRequest) || + pullRequest.user.login.toLowerCase() === 'dependabot[bot]' + ) { + return 'fork-pull-request'; + } + if (pullRequest.base.ref !== 'main') return 'base-mismatch'; + return null; +} + +async function reconcileCandidate( + runtime: ControllerRuntime, + environment: VercelPreviewEnvironment, + eventRepository: GithubPreviewRepository, + candidate: PreviewCandidate, +): Promise { + const pullRequest = await fetchPullRequest(runtime, environment, candidate.number); + const identityFailure = candidateIdentityFailure( + environment, + eventRepository, + candidate, + pullRequest, + ); + if (identityFailure) return [skipped(candidate.number, identityFailure)]; + if (pullRequest.state !== 'open' || !isPreviewEligible(pullRequest)) { + return cancelActiveDeployments(runtime, environment, pullRequest); + } + const priorHeadCancellation = await cancelPriorHeadDeployments(runtime, environment, pullRequest); + if (!priorHeadCancellation.current) return priorHeadCancellation.results; + const priorHeadResults = priorHeadCancellation.results; + const ci = await proveCurrentCi(runtime, environment, pullRequest); + if (!ci.ok) return [...priorHeadResults, skipped(candidate.number, ci.reason)]; + if (!(await affectsWeb(runtime, environment, pullRequest.number))) { + return [...priorHeadResults, skipped(candidate.number, 'web-unaffected')]; + } + const listed = await listDeployments(runtime, environment, pullRequest, true); + const exact = listed.filter((item) => + matchesVercelPullRequest( + item, + pullRequest, + environment.VERCEL_PROJECT_ID, + pullRequest.head.sha, + ), + ); + const reused = await reusedDeploymentResult(runtime, environment, pullRequest, exact); + if (reused) return [...priorHeadResults, reused]; + const finalPullRequest = await fetchPullRequest(runtime, environment, pullRequest.number); + if (!currentStateMatches(pullRequest, finalPullRequest)) { + return [...priorHeadResults, skipped(candidate.number, 'stale-event')]; + } + return [ + ...priorHeadResults, + await createDeployment( + runtime, + environment, + finalPullRequest, + exact.length > 0, + new Set(listed.map(({ uid }) => uid)), + ), + ]; +} + +export async function reconcileVercelPreviews( + runtime: PreviewRuntime, +): Promise { + const environment = vercelPreviewEnvironmentSchema.parse(runtime.env); + const controllerRuntime = withControllerDeadline(runtime); + try { + const eventText = await controllerRuntime.readText(environment.GITHUB_EVENT_PATH); + const eventValue = parseJsonText(eventText, 'event'); + const resolved = await resolveCandidates(controllerRuntime, environment, eventValue); + const results: PreviewResult[] = []; + for (const candidate of resolved.candidates) { + results.push( + ...(await reconcileCandidate( + controllerRuntime, + environment, + resolved.repository, + candidate, + )), + ); + } + return assertSafePreviewResults(results, environment); + } catch (error) { + throw new Error(safeMessage(error, environment)); + } +} + +if (import.meta.main) { + const runtime: PreviewRuntime = { + env: process.env, + readText: (path) => Bun.file(path).text(), + fetch: globalThis.fetch, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + scheduleTimeout: (callback, milliseconds) => { + const timeout = setTimeout(callback, milliseconds); + return () => clearTimeout(timeout); + }, + now: () => Date.now(), + log: (message) => console.log(message), + }; + try { + const results = await reconcileVercelPreviews(runtime); + for (const result of results) runtime.log(JSON.stringify(result)); + } catch (error) { + console.error(safeMessage(error)); + process.exitCode = 1; + } +} diff --git a/scripts/vercel-preview-policy.test.ts b/scripts/vercel-preview-policy.test.ts new file mode 100644 index 000000000..cc3c5814b --- /dev/null +++ b/scripts/vercel-preview-policy.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from 'bun:test'; +import type { + GithubPreviewFile, + GithubPreviewPullRequest, + VercelDeployment, +} from '../packages/shared/src/validators/index.ts'; +import { vercelDeploymentSchema } from '../packages/shared/src/validators/index.ts'; +import { + isActiveVercelDeployment, + isPreviewEligible, + isReadyVercelDeployment, + isSameRepositoryPullRequest, + isWebPreviewFile, + matchesVercelPullRequest, +} from './vercel-preview-policy.ts'; + +const SHA = 'a'.repeat(40); + +const repository = { + id: 123, + name: 'orbit', + owner: { login: 'Noveum' }, +}; + +const readyPullRequest: GithubPreviewPullRequest = { + number: 341, + state: 'open', + draft: false, + labels: [], + user: { login: 'maintainer' }, + head: { sha: SHA, ref: 'feature/preview', repo: repository }, + base: { ref: 'main', repo: repository }, +}; + +const draftPullRequest: GithubPreviewPullRequest = { ...readyPullRequest, draft: true }; +const forkPullRequest: GithubPreviewPullRequest = { + ...readyPullRequest, + head: { + ...readyPullRequest.head, + repo: { ...repository, id: 456, owner: { login: 'contributor' } }, + }, +}; + +const deployment: VercelDeployment = { + uid: 'dpl_preview', + projectId: 'prj_orbit', + url: null, + target: null, + readyState: 'READY', + meta: { + orbitGithubRepositoryId: '123', + orbitGithubPrNumber: 341, + orbitGithubHeadRef: 'feature/preview', + orbitGithubHeadSha: SHA, + orbitGithubWorkflowRunId: 987, + orbitDeploymentReason: 'ci-green', + }, +}; + +function withLabels( + pullRequest: GithubPreviewPullRequest, + labels: readonly string[], +): GithubPreviewPullRequest { + return { ...pullRequest, labels: labels.map((name) => ({ name })) }; +} + +describe('Preview eligibility', () => { + test('uses the preview labels and draft state as the eligibility truth table', () => { + expect(isPreviewEligible(readyPullRequest)).toBe(true); + expect(isPreviewEligible(draftPullRequest)).toBe(false); + expect(isPreviewEligible(withLabels(draftPullRequest, ['preview']))).toBe(true); + expect(isPreviewEligible(withLabels(readyPullRequest, ['preview', 'no-preview']))).toBe(false); + }); + + test('requires a pull request head from the base repository', () => { + expect(isSameRepositoryPullRequest(readyPullRequest)).toBe(true); + expect(isSameRepositoryPullRequest(forkPullRequest)).toBe(false); + }); +}); + +describe('Web Preview path policy', () => { + test('includes the application, packages, and deployment configuration', () => { + for (const filename of [ + 'apps/web/src/app/page.tsx', + 'packages/shared/src/index.ts', + 'package.json', + 'bun.lock', + 'tsconfig.base.json', + ]) { + expect(isWebPreviewFile({ filename, status: 'modified' })).toBe(true); + } + }); + + test('excludes unrelated applications and documentation', () => { + expect(isWebPreviewFile({ filename: 'apps/realtime/src/index.ts', status: 'modified' })).toBe( + false, + ); + expect(isWebPreviewFile({ filename: 'docs/README.md', status: 'modified' })).toBe(false); + }); + + test.each([ + [ + 'into the web application', + { + filename: 'apps/web/src/feature.ts', + status: 'renamed', + previous_filename: 'docs/feature.ts', + }, + ], + [ + 'out of the web application', + { + filename: 'docs/feature.ts', + status: 'renamed', + previous_filename: 'apps/web/src/feature.ts', + }, + ], + [ + 'out of a shared package', + { + filename: 'docs/shared.ts', + status: 'renamed', + previous_filename: 'packages/shared/src/shared.ts', + }, + ], + ] satisfies readonly (readonly [string, GithubPreviewFile])[])( + 'treats a rename %s as web-impacting', + (_name, file) => { + expect(isWebPreviewFile(file)).toBe(true); + }, + ); + + test('ignores an unrelated rename', () => { + expect( + isWebPreviewFile({ + filename: 'docs/new-name.md', + status: 'renamed', + previous_filename: 'docs/old-name.md', + }), + ).toBe(false); + }); +}); + +describe('Vercel deployment policy', () => { + test('matches Preview metadata after normalizing Vercel metadata values', () => { + expect(matchesVercelPullRequest(deployment, readyPullRequest, 'prj_orbit', SHA)).toBe(true); + }); + + test('requires repository, pull request, ref, and supplied SHA to match', () => { + expect( + matchesVercelPullRequest( + { ...deployment, meta: { ...deployment.meta, orbitGithubRepositoryId: '456' } }, + readyPullRequest, + 'prj_orbit', + SHA, + ), + ).toBe(false); + expect( + matchesVercelPullRequest( + { ...deployment, meta: { ...deployment.meta, orbitGithubPrNumber: '342' } }, + readyPullRequest, + 'prj_orbit', + SHA, + ), + ).toBe(false); + expect( + matchesVercelPullRequest( + { ...deployment, meta: { ...deployment.meta, orbitGithubHeadRef: 'other-ref' } }, + readyPullRequest, + 'prj_orbit', + SHA, + ), + ).toBe(false); + expect( + matchesVercelPullRequest(deployment, readyPullRequest, 'prj_orbit', 'b'.repeat(40)), + ).toBe(false); + }); + + test('requires the configured project Preview environment', () => { + expect(matchesVercelPullRequest(deployment, readyPullRequest, 'prj_other', SHA)).toBe(false); + expect( + matchesVercelPullRequest( + { ...deployment, target: 'staging' }, + readyPullRequest, + 'prj_orbit', + SHA, + ), + ).toBe(false); + expect( + matchesVercelPullRequest( + { ...deployment, target: 'production' }, + readyPullRequest, + 'prj_orbit', + SHA, + ), + ).toBe(false); + const { target: _target, ...withoutTarget } = deployment; + expect(matchesVercelPullRequest(withoutTarget, readyPullRequest, 'prj_orbit', SHA)).toBe(false); + }); + + test('does not match an unrelated deployment without metadata', () => { + const withoutMetadata = vercelDeploymentSchema.parse({ + uid: 'dpl_unrelated', + projectId: 'prj_orbit', + url: null, + target: null, + readyState: 'READY', + }); + expect(matchesVercelPullRequest(withoutMetadata, readyPullRequest, 'prj_orbit', SHA)).toBe( + false, + ); + }); + + test('recognizes only queued, initializing, and building deployments as active', () => { + for (const readyState of ['QUEUED', 'INITIALIZING', 'BUILDING'] as const) { + expect(isActiveVercelDeployment({ ...deployment, readyState })).toBe(true); + } + expect(isActiveVercelDeployment(deployment)).toBe(false); + }); + + test('recognizes only READY deployments as ready', () => { + expect(isReadyVercelDeployment(deployment)).toBe(true); + expect(isReadyVercelDeployment({ ...deployment, readyState: 'BLOCKED' })).toBe(false); + }); +}); diff --git a/scripts/vercel-preview-policy.ts b/scripts/vercel-preview-policy.ts new file mode 100644 index 000000000..66f36342b --- /dev/null +++ b/scripts/vercel-preview-policy.ts @@ -0,0 +1,65 @@ +import type { + GithubPreviewFile, + GithubPreviewPullRequest, + VercelDeployment, +} from '../packages/shared/src/validators/index.ts'; + +export const PREVIEW_LABEL = 'preview'; +export const NO_PREVIEW_LABEL = 'no-preview'; + +export function isPreviewEligible(pullRequest: GithubPreviewPullRequest): boolean { + const labels = new Set(pullRequest.labels.map(({ name }) => name.toLowerCase())); + if (labels.has(NO_PREVIEW_LABEL)) return false; + return !pullRequest.draft || labels.has(PREVIEW_LABEL); +} + +export function isSameRepositoryPullRequest(pullRequest: GithubPreviewPullRequest): boolean { + return pullRequest.head.repo.id === pullRequest.base.repo.id; +} + +function isWebPreviewPath(filename: string): boolean { + return ( + filename.startsWith('apps/web/') || + filename.startsWith('packages/') || + filename === 'package.json' || + filename === 'bun.lock' || + filename === 'tsconfig.base.json' + ); +} + +export function isWebPreviewFile(file: GithubPreviewFile): boolean { + return ( + isWebPreviewPath(file.filename) || + (file.status === 'renamed' && isWebPreviewPath(file.previous_filename)) + ); +} + +export function isActiveVercelDeployment(deployment: VercelDeployment): boolean { + return ['QUEUED', 'INITIALIZING', 'BUILDING'].includes(deployment.readyState); +} + +export function isReadyVercelDeployment(deployment: VercelDeployment): boolean { + return deployment.readyState === 'READY'; +} + +function metadataValue(deployment: VercelDeployment, key: string): string | null { + const value = deployment.meta[key]; + return value === undefined || value === null ? null : String(value); +} + +export function matchesVercelPullRequest( + deployment: VercelDeployment, + pullRequest: GithubPreviewPullRequest, + projectId: string, + headSha?: string, +): boolean { + if (deployment.projectId !== projectId || deployment.target !== null) return false; + + const metadataMatches = + metadataValue(deployment, 'orbitGithubRepositoryId') === String(pullRequest.base.repo.id) && + metadataValue(deployment, 'orbitGithubPrNumber') === String(pullRequest.number) && + metadataValue(deployment, 'orbitGithubHeadRef') === pullRequest.head.ref; + + if (!metadataMatches) return false; + return headSha === undefined || metadataValue(deployment, 'orbitGithubHeadSha') === headSha; +}