From f6d3bfc02bb461461168416010eeabea9c099b2e Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 16 Aug 2026 00:47:24 -0400 Subject: [PATCH 1/4] feat(preview): add the PR-preview deploy workflow (SEA-2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy a compass PR's full stack to the isolated `preview` env on mattfw, selected by a single-holder `preview` GitHub label, served tailnet-gated, with a GitHub Deployment as the PR-visible state surface (Record B-preview P1, orion docs/designs/platform/compass-pr-preview/design.md). - .github/workflows/compass-preview-deploy.yml: a SEPARATE least-privilege workflow (the publish-agent-image.yml precedent), never a required check. Triggers on pull_request [labeled, synchronize, unlabeled, closed]; pull-requests:write + deployments:write + contents:read confined here; concurrency group compass-preview-deploy, cancel-in-progress:false. The fork guard is two-layer: on:pull_request (not _target) so no secret ever reaches fork code, plus a same-repo head.repo==github.repository gate on the deploy job; a fork claim routes to a strip+comment reject job that never displaces or deploys. Thin body — all logic in the bun/TS tool. - tools/compass-preview-deploy/: the bun/TS deploy tool (construction/ execution split). Pure decide() label-lifecycle state machine (claim/displace+sticky/release+inactive, fork-claim rejection, displaced- loser release race-safety); runOnce() dispatch driving the Deployment record (in_progress -> success/failure, environment_url, inactive-on- release) and the SEA-2014 preview-link payload; the mattfw deploy sequence (checkout, compass-preview.service restart, SEA-2011 compass-ui build against preview's :50161 door with a reviewer bearer minted via IssueToken, tailscale serve at one stable root URL — never funnel). - Deploy reach: the GH-hosted runner joins the tailnet ephemerally as a tagged ACL-scoped node, then ssh's mattfw; the OAuth secret is fork-guarded. Needs an orion-side tailscale ACL tag + ssh grant (flagged, not built here). Co-authored-by: Matt Wilkinson --- .github/workflows/compass-preview-deploy.yml | 153 +++++ .moon/workspace.yml | 7 + bun.lock | 12 + tools/compass-preview-deploy/biome.json | 3 + tools/compass-preview-deploy/index.test.ts | 354 +++++++++++ tools/compass-preview-deploy/index.ts | 637 +++++++++++++++++++ tools/compass-preview-deploy/moon.yml | 35 + tools/compass-preview-deploy/package.json | 14 + tools/compass-preview-deploy/tsconfig.json | 19 + 9 files changed, 1234 insertions(+) create mode 100644 .github/workflows/compass-preview-deploy.yml create mode 100644 tools/compass-preview-deploy/biome.json create mode 100644 tools/compass-preview-deploy/index.test.ts create mode 100644 tools/compass-preview-deploy/index.ts create mode 100644 tools/compass-preview-deploy/moon.yml create mode 100644 tools/compass-preview-deploy/package.json create mode 100644 tools/compass-preview-deploy/tsconfig.json diff --git a/.github/workflows/compass-preview-deploy.yml b/.github/workflows/compass-preview-deploy.yml new file mode 100644 index 00000000..a5eff937 --- /dev/null +++ b/.github/workflows/compass-preview-deploy.yml @@ -0,0 +1,153 @@ +# Deploys a Compass PR's FULL stack to the isolated `preview` env on mattfw, +# selected by a single-holder `preview` GitHub label, served tailnet-gated, +# with a GitHub Deployment as the PR-visible state surface (SEA-2027; orion +# docs/designs/platform/compass-pr-preview/design.md P1). +# +# WHY A SEPARATE WORKFLOW, NOT A STEP IN THE CI GATE — the publish-agent-image.yml +# precedent, and the design record's ONE-JOB-doctrine carve-out (§Global +# Constraints "One-job CI doctrine"): +# +# - Least privilege. The label lifecycle needs `pull-requests: write` and the +# Deployment record needs `deployments: write`; the CI gate job runs +# `contents: read` only. These permissions are declared HERE and never reach +# the gate job — a per-workflow token, scoped to this lane alone. +# - THE FORK GUARD — the load-bearing security invariant. This triggers on +# `on: pull_request` (NOT `pull_request_target`), so a fork PR's job gets a +# read-only GITHUB_TOKEN and NO secrets are EVER exposed to fork-controlled +# code — the exact guarantee publish-agent-image.yml relies on ("no token or +# secret is ever exposed to a fork PR"). On top of that, every step that +# could deploy or reach the tailnet is gated on +# `head.repo.full_name == github.repository`, and the fork-guard job below +# runs FIRST for a fork claim: strip the label + post a same-repo-only +# comment, WITHOUT displacing the incumbent or deploying. A fork reaching the +# deploy path is the worst-case failure; two layers (event type + same-repo +# conditional) prevent it. +# - Its own concurrency. Claim → label-strip → checkout → restart against the +# ONE shared ~/compass-envs/preview tree must SERIALIZE +# (`cancel-in-progress: false`) — two near-simultaneous `preview` events +# would otherwise race the shared checkout and strip each other's label +# (split-brain). A superseded deploy must FINISH cleanly, not be half-torn. +# - Off the hot path, not a required check. A preview flake must never red the +# required merge gate — this workflow is not in the branch-protection set. +# +# THE THIN BODY. All real logic — the label-lifecycle state machine, the deploy +# sequence, and the Deployments API calls — lives in the bun/TS tool +# (tools/compass-preview-deploy), per the repo's no-bash-gate posture. This YAML +# only orchestrates events → tool invocation, plus the tailnet reach. +# +# THE DEPLOY-REACH MECHANISM. Compass CI runs on GitHub-hosted ubuntu-latest (no +# self-hosted runners), so the runner must reach mattfw's tailnet to deploy. It +# joins the tailnet EPHEMERALLY as a tagged, ACL-scoped node +# (tailscale/github-action with an OAuth client secret), then the tool ssh's the +# deploy to mattfw's `compass-preview` user. The OAuth secret is gated behind the +# same-repo fork guard (the `deploy` job's `if:` below), so it never reaches a +# fork PR. See the summary's flagged orion-side dependency: the fleet ACL needs a +# tag for this ephemeral runner + an ssh grant to reach mattfw's preview user. + +name: Compass preview deploy + +on: + pull_request: + types: [labeled, synchronize, unlabeled, closed] + +# Claim → label-strip → checkout → restart against the ONE shared preview tree +# runs strictly one-at-a-time. cancel-in-progress:false so a superseded deploy +# finishes cleanly rather than tearing the shared checkout mid-flight. +concurrency: + group: compass-preview-deploy + cancel-in-progress: false + +# Least privilege, confined to THIS workflow: the label lifecycle + sticky +# comment (pull-requests), the Deployment record (deployments), and reading the +# tree (contents). Nothing else; these never reach the CI gate job. +permissions: + pull-requests: write + deployments: write + contents: read + +jobs: + # A fork PR that adds the `preview` label is rejected up front — strip the + # label + post a same-repo-only comment, WITHOUT displacing the current holder + # or deploying. No tailnet, no secret; the read-only fork token suffices for + # the best-effort label strip (and if it 403s, the deploy simply never runs). + reject-fork: + name: Reject fork claim + runs-on: ubuntu-latest + if: >- + github.event.action == 'labeled' && + github.event.label.name == 'preview' && + github.event.pull_request.head.repo.full_name != github.repository + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3" + - name: Reject the fork preview claim + env: + EVENT_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + BASE_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CHANGED_LABEL: ${{ github.event.label.name }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: bun run tools/compass-preview-deploy/index.ts + + # SAME-REPO ONLY. Every deploy/label-mutating path (claim/displace/redeploy/ + # release) runs here, gated on head.repo == github.repository, so the OAuth + # secret + tailnet reach are never exposed to fork-controlled code. A + # synchronize on an unlabeled PR reaches the tool and no-ops there. + deploy: + name: Deploy preview + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3" + + # Join the tailnet EPHEMERALLY as a tagged, ACL-scoped node so the tool can + # ssh the deploy to mattfw. OAuth client secret (fork-guarded by this job's + # same-repo `if:` above) → an ephemeral node that Tailscale reaps on job + # end. tag:compass-preview-ci is the ACL tag the fleet grant scopes to + # mattfw:22 (the flagged orion-side dependency). + - name: Join the tailnet (ephemeral) + uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3 + with: + oauth-client-id: ${{ secrets.TS_PREVIEW_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_PREVIEW_OAUTH_SECRET }} + tags: tag:compass-preview-ci + + - name: Configure the deploy ssh key + env: + PREVIEW_DEPLOY_SSH_KEY: ${{ secrets.PREVIEW_DEPLOY_SSH_KEY }} + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "$PREVIEW_DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Deploy / redeploy / release the preview env + env: + EVENT_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + BASE_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CHANGED_LABEL: ${{ github.event.label.name }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + # The preview env contract (Record A): the checkout, the TLS network + # door (:50161), the preview user, and the one stable tailnet URL. + PREVIEW_SSH_HOST: mattfw + PREVIEW_SSH_USER: compass-preview + PREVIEW_CHECKOUT: /home/compass-preview/compass-envs/preview + PREVIEW_DOOR_URL: https://mattfw:50161 + PREVIEW_URL: ${{ vars.COMPASS_PREVIEW_URL }} + PREVIEW_ADMIN_ACCOUNT: ${{ vars.COMPASS_PREVIEW_ADMIN_ACCOUNT }} + run: bun run tools/compass-preview-deploy/index.ts diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 765c6a18..ca63cbcb 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -51,6 +51,13 @@ projects: # file's --rigel-purple is the one narrow allowlist. WARN until the adoption # step-5 flip, then ERROR. cx-token-gate: 'tools/cx-token-gate' + # The PR-preview deploy tool: the single-holder `preview`-label lifecycle + # (claim/displace/release + fork-claim rejection), the full-stack deploy to + # the isolated preview env on mattfw served tailnet-gated, and the GitHub + # Deployment record surfacing live state on the PR (SEA-2027). Driven by the + # separate least-privilege .github/workflows/compass-preview-deploy.yml, not + # the required CI gate; its `ci` aggregate is typecheck + unit test only. + compass-preview-deploy: 'tools/compass-preview-deploy' # The one-shot engineering-docs sanitization migration (SEA-1766, T4). Encodes # the four-class rewrite policy; no standing run task, deleted after T5. docs-migrate: 'tools/docs-migrate' diff --git a/bun.lock b/bun.lock index 90e4c482..e36cadea 100644 --- a/bun.lock +++ b/bun.lock @@ -90,6 +90,16 @@ "typescript": "catalog:", }, }, + "tools/compass-preview-deploy": { + "name": "@compass/compass-preview-deploy", + "bin": { + "compass-preview-deploy": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/cx-token-gate": { "name": "@compass/cx-token-gate", "bin": { @@ -274,6 +284,8 @@ "@compass/client": ["@compass/client@workspace:packages/compass-client"], + "@compass/compass-preview-deploy": ["@compass/compass-preview-deploy@workspace:tools/compass-preview-deploy"], + "@compass/cx-token-gate": ["@compass/cx-token-gate@workspace:tools/cx-token-gate"], "@compass/design-ledger-gate": ["@compass/design-ledger-gate@workspace:tools/design-ledger-gate"], diff --git a/tools/compass-preview-deploy/biome.json b/tools/compass-preview-deploy/biome.json new file mode 100644 index 00000000..99b4ab8f --- /dev/null +++ b/tools/compass-preview-deploy/biome.json @@ -0,0 +1,3 @@ +{ + "extends": "//" +} diff --git a/tools/compass-preview-deploy/index.test.ts b/tools/compass-preview-deploy/index.test.ts new file mode 100644 index 00000000..80e60cba --- /dev/null +++ b/tools/compass-preview-deploy/index.test.ts @@ -0,0 +1,354 @@ +// Unit tests for compass-preview-deploy's pure state machine + dispatch shell +// (index.ts). +// +// This tool is a deploy oracle: `decide()` defines who owns the shared preview +// env and — the load-bearing bit — that a fork NEVER deploys or displaces, and +// a stale displacement event never tears down the live incumbent. This suite +// defends that contract exhaustively, plus the Deployment-record status +// sequencing (in_progress → success/failure, inactive-on-release) that surfaces +// state on the PR. +// +// Conventions (mirroring tools/cx-token-gate/index.test.ts and +// tools/design-ledger-gate/index.test.ts): +// - Literal label/environment strings, NOT values derived from the module +// constants (PREVIEW_LABEL / PREVIEW_ENVIRONMENT): those constants ARE the +// thing under test, so deriving inputs from them would let a drifted constant +// pass silently. +// - `ctx()` yields a valid same-repo baseline so each test perturbs one axis. + +import { describe, expect, test } from "bun:test"; +import { + type ActiveDeployment, + type Deployer, + type DeploymentState, + type Deps, + decide, + type EventContext, + type GitHubApi, + isEventAction, + parseLabels, + runOnce, +} from "./index.ts"; + +const BASE = "RigelBuild/compass"; +const FORK = "attacker/compass"; + +/** A valid same-repo `labeled` baseline; perturb one axis per test. */ +function ctx(over: Partial = {}): EventContext { + return { + action: "labeled", + prNumber: 10, + headRepo: BASE, + baseRepo: BASE, + headSha: "abc123", + changedLabel: "preview", + prLabels: ["preview"], + currentHolders: [10], + activePreviewPr: null, + ...over, + }; +} + +// --------------------------------------------------------------------------- +// decide — the label lifecycle state machine. +// --------------------------------------------------------------------------- + +describe("decide — labeled", () => { + test("a same-repo claim with no other holder deploys, displacing none", () => { + const d = decide(ctx({ currentHolders: [10] })); + expect(d).toEqual({ kind: "claim", displaced: [] }); + }); + + test("a same-repo claim displaces every OTHER current holder", () => { + const d = decide(ctx({ prNumber: 10, currentHolders: [7, 10, 12] })); + expect(d).toEqual({ kind: "claim", displaced: [7, 12] }); + }); + + test("a FORK claim is rejected up front — never claim, never displace", () => { + const d = decide(ctx({ headRepo: FORK, currentHolders: [3, 10] })); + expect(d).toEqual({ kind: "reject-fork" }); + }); + + test("a non-preview label is a no-op", () => { + const d = decide(ctx({ changedLabel: "bug" })); + expect(d.kind).toBe("noop"); + }); +}); + +describe("decide — synchronize", () => { + test("the current holder redeploys", () => { + const d = decide( + ctx({ action: "synchronize", changedLabel: null, prLabels: ["preview"] }), + ); + expect(d).toEqual({ kind: "redeploy" }); + }); + + test("a synchronize on an UNLABELED PR is a no-op", () => { + const d = decide( + ctx({ action: "synchronize", changedLabel: null, prLabels: ["bug"] }), + ); + expect(d.kind).toBe("noop"); + }); + + test("a synchronize on a FORK PR never deploys", () => { + const d = decide( + ctx({ + action: "synchronize", + headRepo: FORK, + changedLabel: null, + prLabels: ["preview"], + }), + ); + expect(d.kind).toBe("noop"); + }); +}); + +describe("decide — unlabeled / closed release", () => { + test("unlabeling the ACTIVE holder releases the env", () => { + const d = decide( + ctx({ action: "unlabeled", prNumber: 10, activePreviewPr: 10 }), + ); + expect(d).toEqual({ kind: "release" }); + }); + + test("unlabeling a NON-active PR is a no-op (displaced-loser race safety)", () => { + // The winner (#12) is active; stripping the loser's (#10) label fires + // `unlabeled` on #10, which must NOT tear down #12's live env. + const d = decide( + ctx({ action: "unlabeled", prNumber: 10, activePreviewPr: 12 }), + ); + expect(d.kind).toBe("noop"); + }); + + test("unlabeling a non-preview label is a no-op", () => { + const d = decide( + ctx({ + action: "unlabeled", + changedLabel: "bug", + prNumber: 10, + activePreviewPr: 10, + }), + ); + expect(d.kind).toBe("noop"); + }); + + test("closing the ACTIVE holder releases the env", () => { + const d = decide( + ctx({ + action: "closed", + changedLabel: null, + prNumber: 10, + activePreviewPr: 10, + }), + ); + expect(d).toEqual({ kind: "release" }); + }); + + test("closing a NON-active PR is a no-op", () => { + const d = decide( + ctx({ + action: "closed", + changedLabel: null, + prNumber: 10, + activePreviewPr: 12, + }), + ); + expect(d.kind).toBe("noop"); + }); +}); + +// --------------------------------------------------------------------------- +// isEventAction / parseLabels — the env-parse boundary. +// --------------------------------------------------------------------------- + +describe("isEventAction", () => { + test("accepts the four handled actions", () => { + for (const a of ["labeled", "synchronize", "unlabeled", "closed"]) { + expect(isEventAction(a)).toBe(true); + } + }); + test("rejects an unhandled action", () => { + expect(isEventAction("opened")).toBe(false); + expect(isEventAction("")).toBe(false); + }); +}); + +describe("parseLabels", () => { + test("splits, trims, and drops empties", () => { + expect(parseLabels("preview, bug ,")).toEqual(["preview", "bug"]); + }); + test("undefined / empty → no labels", () => { + expect(parseLabels(undefined)).toEqual([]); + expect(parseLabels("")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// runOnce — dispatch, the Deployment status sequence, and the fork posture. +// --------------------------------------------------------------------------- + +/** A recording fake pair for the injected side effects. */ +function fakes(over?: { + deployThrows?: boolean; + releaseThrows?: boolean; + active?: ActiveDeployment | null; +}) { + const calls: string[] = []; + const outputs: Record = {}; + let nextDeploymentId = 100; + + const gh: GitHubApi = { + removeLabel: async (pr, label) => { + calls.push(`removeLabel(${pr},${label})`); + }, + postComment: async (pr) => { + calls.push(`postComment(${pr})`); + }, + createDeployment: async (pr, ref) => { + calls.push(`createDeployment(${pr},${ref})`); + return nextDeploymentId++; + }, + setDeploymentStatus: async ( + id: number, + state: DeploymentState, + url?: string, + ) => { + calls.push(`status(${id},${state}${url ? `,${url}` : ""})`); + }, + findActiveDeployment: async () => over?.active ?? null, + }; + + const deployer: Deployer = { + deploy: async () => { + calls.push("deploy"); + if (over?.deployThrows) throw new Error("boom"); + return { environmentUrl: "https://mattfw/" }; + }, + release: async () => { + calls.push("release"); + if (over?.releaseThrows) throw new Error("boom"); + }, + }; + + const deps = (c: EventContext): Deps => ({ + ctx: c, + gh, + deployer, + emitOutput: (n, v) => { + outputs[n] = v; + }, + log: () => {}, + err: () => {}, + }); + + return { calls, outputs, deps }; +} + +describe("runOnce — claim", () => { + test("a clean claim: no displace, create → in_progress → success, link emitted", async () => { + const f = fakes(); + const code = await runOnce(f.deps(ctx({ currentHolders: [10] }))); + expect(code).toBe(0); + expect(f.calls).toEqual([ + "createDeployment(10,abc123)", + "status(100,in_progress)", + "deploy", + "status(100,success,https://mattfw/)", + ]); + expect(f.outputs.preview_url).toBe("https://mattfw/"); + expect(f.outputs.preview_pr).toBe("10"); + }); + + test("a claim displacing another holder strips its label + comments FIRST", async () => { + const f = fakes(); + const code = await runOnce( + f.deps(ctx({ prNumber: 10, currentHolders: [7, 10] })), + ); + expect(code).toBe(0); + expect(f.calls.slice(0, 2)).toEqual([ + "removeLabel(7,preview)", + "postComment(7)", + ]); + expect(f.calls).toContain("deploy"); + }); + + test("a failed deploy marks the Deployment failure and exits 1", async () => { + const f = fakes({ deployThrows: true }); + const code = await runOnce(f.deps(ctx({ currentHolders: [10] }))); + expect(code).toBe(1); + expect(f.calls).toEqual([ + "createDeployment(10,abc123)", + "status(100,in_progress)", + "deploy", + "status(100,failure)", + ]); + expect(f.outputs.preview_url).toBeUndefined(); + }); +}); + +describe("runOnce — fork rejection", () => { + test("a fork claim strips the label + comments, and NEVER deploys or displaces", async () => { + const f = fakes({ active: { id: 55, pr: 9 } }); + const code = await runOnce( + f.deps(ctx({ headRepo: FORK, prNumber: 10, currentHolders: [9, 10] })), + ); + expect(code).toBe(0); + expect(f.calls).toEqual(["removeLabel(10,preview)", "postComment(10)"]); + // The incumbent (#9) is untouched: no displace, no deploy, no status flip. + expect(f.calls).not.toContain("deploy"); + expect(f.calls.some((c) => c.startsWith("createDeployment"))).toBe(false); + expect(f.calls).not.toContain("removeLabel(9,preview)"); + }); +}); + +describe("runOnce — release", () => { + test("releasing the active holder stops the service and marks it inactive", async () => { + const f = fakes({ active: { id: 77, pr: 10 } }); + const code = await runOnce( + f.deps(ctx({ action: "unlabeled", prNumber: 10, activePreviewPr: 10 })), + ); + expect(code).toBe(0); + expect(f.calls).toEqual(["release", "status(77,inactive)"]); + }); + + test("a release whose active deployment is a DIFFERENT PR does not flip it", async () => { + // decide already no-ops a non-active unlabel; this guards runOnce too when + // the active record races to a different PR between decide and dispatch. + const f = fakes({ active: { id: 77, pr: 12 } }); + const code = await runOnce( + f.deps( + ctx({ + action: "closed", + changedLabel: null, + prNumber: 10, + activePreviewPr: 10, + }), + ), + ); + expect(code).toBe(0); + expect(f.calls).toEqual(["release"]); + expect(f.calls.some((c) => c.startsWith("status"))).toBe(false); + }); + + test("a failed release exits 1 without marking inactive", async () => { + const f = fakes({ releaseThrows: true, active: { id: 77, pr: 10 } }); + const code = await runOnce( + f.deps(ctx({ action: "unlabeled", prNumber: 10, activePreviewPr: 10 })), + ); + expect(code).toBe(1); + expect(f.calls).toEqual(["release"]); + }); +}); + +describe("runOnce — no-op", () => { + test("a synchronize on a non-holder does nothing", async () => { + const f = fakes(); + const code = await runOnce( + f.deps( + ctx({ action: "synchronize", changedLabel: null, prLabels: ["bug"] }), + ), + ); + expect(code).toBe(0); + expect(f.calls).toEqual([]); + }); +}); diff --git a/tools/compass-preview-deploy/index.ts b/tools/compass-preview-deploy/index.ts new file mode 100644 index 00000000..bea9d4b9 --- /dev/null +++ b/tools/compass-preview-deploy/index.ts @@ -0,0 +1,637 @@ +// compass-preview-deploy — the Compass PR-preview deploy tool (SEA-2027). +// +// Record B-preview P1 (orion +// docs/designs/platform/compass-pr-preview/design.md): deploy a compass PR's +// FULL stack to the isolated `preview` env on mattfw, selected by a +// single-holder `preview` GitHub label, served tailnet-gated, with a GitHub +// Deployment as the PR-visible state surface. The workflow +// (.github/workflows/compass-preview-deploy.yml) is a THIN orchestrator; ALL +// real logic lives here (the repo's no-bash-gate posture — design record +// §Global Constraints "Scripts over bash"). +// +// CONSTRUCTION/EXECUTION SPLIT (mirrors tools/cx-token-gate, +// tools/design-ledger-gate): +// * `decide()` — the pure label-lifecycle state machine. No I/O, no exit; +// it maps an event context to a `Decision`. This is the tested oracle. +// * `runOnce()` — the execution shell. Dispatches a `Decision` to injected +// side effects (the GitHub label/comment/Deployments API + the ssh deploy +// to mattfw), so tests drive it with fakes. +// +// THE SECURITY INVARIANT (why this is the load-bearing bit). The deploy +// credential + tailnet reach must NEVER be exposed to fork-PR-controlled code. +// Two layers enforce it: +// 1. The workflow triggers on `on: pull_request` (NOT `pull_request_target`), +// so a fork PR's job gets a read-only GITHUB_TOKEN and NO secrets ever — +// the same guarantee publish-agent-image.yml relies on ("no token or +// secret is ever exposed to a fork PR"). +// 2. Every deploy/label-mutating step is gated on +// `head.repo.full_name == github.repository` at the YAML layer, AND this +// tool refuses to deploy a fork (decide() returns `reject-fork`, never +// `claim`/`redeploy`, for a fork PR). A fork claim is rejected up front: +// strip the label + post a same-repo-only comment, WITHOUT displacing the +// incumbent or deploying (design record §P1 label lifecycle). +// +// Inputs (env, set by the workflow): +// EVENT_ACTION - labeled | synchronize | unlabeled | closed +// PR_NUMBER - the PR this event is about +// HEAD_REPO - github.event.pull_request.head.repo.full_name +// BASE_REPO - github.repository (the canonical repo) +// HEAD_SHA - github.event.pull_request.head.sha (the deploy ref) +// CHANGED_LABEL - github.event.label.name (labeled/unlabeled only) +// PR_LABELS - comma-joined label names currently on the PR +// REPO, GH_TOKEN - for the gh CLI (GitHub API) +// Deploy-reach (see the workflow header): the runner joins the tailnet as an +// ephemeral tagged node, then this tool ssh's the deploy to mattfw. The +// deploy target is configured via PREVIEW_SSH_HOST / PREVIEW_SSH_USER / +// PREVIEW_CHECKOUT / PREVIEW_DOOR_URL / PREVIEW_URL / PREVIEW_ADMIN_ACCOUNT. +// Exit codes: +// 0 - the decision was applied cleanly (incl. a no-op) +// 1 - a deploy/release failed (the Deployment is marked failure) +// 2 - usage / internal error (bad event context, API unreachable) + +import { appendFileSync } from "node:fs"; +import { $ } from "bun"; + +/** The single-holder selection label. The env has ONE occupant at a time. */ +export const PREVIEW_LABEL = "preview"; +/** The GitHub Deployment environment name — the PR-visible state surface. */ +export const PREVIEW_ENVIRONMENT = "preview"; +/** + * The marker line opening the fork-rejection + displacement + preview-link + * payloads. Stable so the result-surfacing lane (SEA-2014) and re-runs can + * find and update the right comment rather than stacking duplicates. + */ +export const PREVIEW_MARKER = ""; + +/** The `pull_request` event actions this tool acts on. */ +export type EventAction = "labeled" | "synchronize" | "unlabeled" | "closed"; + +/** Whether a string is one of the handled event actions. */ +export function isEventAction(s: string): s is EventAction { + return ( + s === "labeled" || + s === "synchronize" || + s === "unlabeled" || + s === "closed" + ); +} + +/** + * The event context the decision is made against. Everything the pure state + * machine needs — the workflow + gh queries populate it; `decide` reads only + * this, never the environment. + */ +export interface EventContext { + /** The `pull_request` action that fired the workflow. */ + action: EventAction; + /** The PR this event is about. */ + prNumber: number; + /** `github.event.pull_request.head.repo.full_name` (a fork differs). */ + headRepo: string; + /** `github.repository` — the canonical repo. */ + baseRepo: string; + /** The deploy ref: the PR head sha. */ + headSha: string; + /** `github.event.label.name` for labeled/unlabeled; null otherwise. */ + changedLabel: string | null; + /** Label names currently on THIS PR (from the event payload). */ + prLabels: string[]; + /** Open PR numbers currently carrying the `preview` label (gh query). */ + currentHolders: number[]; + /** + * The PR the current ACTIVE preview-environment Deployment tracks, or null + * if none is active. This is what makes release race-safe: a displacement + * strips the loser's label (firing `unlabeled` on it), but by then the + * active deployment already points at the WINNER, so the loser's release is + * a no-op and never tears down the incumbent's live env. + */ + activePreviewPr: number | null; +} + +/** The action the state machine resolves an event to. */ +export type Decision = + /** Nothing to do; `reason` is logged for observability. */ + | { kind: "noop"; reason: string } + /** A fork claimed the label: strip it + comment, never displace or deploy. */ + | { kind: "reject-fork" } + /** A same-repo PR claimed the env: displace holders, then deploy. */ + | { kind: "claim"; displaced: number[] } + /** The current holder pushed: redeploy in place. */ + | { kind: "redeploy" } + /** The active holder released the env: stop the service, mark inactive. */ + | { kind: "release" }; + +/** + * The pure label-lifecycle state machine (same-repo claims only). Given the + * event context, resolve the action. No I/O; total over EventAction. + * + * The invariant it preserves: exactly one PR owns the shared `preview` env at + * a time, a fork NEVER deploys or displaces, and a stale displacement event + * never tears down the live incumbent (the `activePreviewPr` guard on release). + */ +export function decide(ctx: EventContext): Decision { + const sameRepo = ctx.headRepo === ctx.baseRepo; + switch (ctx.action) { + case "labeled": { + // Only the preview label selects; any other label is not ours. + if (ctx.changedLabel !== PREVIEW_LABEL) { + return { kind: "noop", reason: `not the ${PREVIEW_LABEL} label` }; + } + // FORK GUARD: a fork's claim is rejected up front — strip + comment, + // never displace, never deploy. The incumbent is untouched. + if (!sameRepo) return { kind: "reject-fork" }; + // Single-holder: displace every OTHER PR currently holding the label. + const displaced = ctx.currentHolders.filter((n) => n !== ctx.prNumber); + return { kind: "claim", displaced }; + } + case "synchronize": { + // A push only redeploys the CURRENT holder; a synchronize on a + // non-holder (or any fork) is a no-op — never a silent deploy. + if (!sameRepo) return { kind: "noop", reason: "fork PR never deploys" }; + if (!ctx.prLabels.includes(PREVIEW_LABEL)) { + return { + kind: "noop", + reason: `PR does not hold the ${PREVIEW_LABEL} label`, + }; + } + return { kind: "redeploy" }; + } + case "unlabeled": { + if (ctx.changedLabel !== PREVIEW_LABEL) { + return { kind: "noop", reason: `not the ${PREVIEW_LABEL} label` }; + } + // Release ONLY if this PR is the active deployment. A displacement + // strip fires `unlabeled` on the loser AFTER the winner is already + // active, so the loser's release is a no-op (race-safe). + if (ctx.activePreviewPr !== ctx.prNumber) { + return { + kind: "noop", + reason: "not the active preview holder (stale/displaced)", + }; + } + return { kind: "release" }; + } + case "closed": { + // Closing releases only if this PR owned the live env. + if (ctx.activePreviewPr !== ctx.prNumber) { + return { kind: "noop", reason: "not the active preview holder" }; + } + return { kind: "release" }; + } + } +} + +// --------------------------------------------------------------------------- +// Pure comment/payload/state builders (exported for unit tests). +// --------------------------------------------------------------------------- + +/** The comment posted when a fork PR is refused the env. */ +export function forkRejectionComment(): string { + return ( + `${PREVIEW_MARKER}\n` + + `### Compass preview — same-repo only\n\n` + + "The `preview` label deploys a PR's full stack to a shared tailnet-gated " + + "environment, so it is restricted to branches in this repository. A fork " + + "PR can never claim the preview env or reach its deploy credentials.\n\n" + + "The label has been removed. A maintainer can re-push this branch to the " + + "canonical repo to preview it." + ); +} + +/** The sticky comment posted on a PR displaced from the env by a new claimant. */ +export function displacementComment(claimantPr: number): string { + return ( + `${PREVIEW_MARKER}\n` + + `### Compass preview — env released\n\n` + + `This PR no longer holds the \`${PREVIEW_LABEL}\` env: PR #${claimantPr} ` + + "claimed the single shared preview environment. Re-add the " + + `\`${PREVIEW_LABEL}\` label to reclaim it (which will displace #${claimantPr}).` + ); +} + +/** + * The stable preview-link payload the result-surfacing lane (SEA-2014) + * consumes. This lane OWNS the Deployment record (the canonical state) and + * emits this payload as a workflow output; it does NOT render the sticky + * preview comment (SEA-2014 owns that surface — design record §Review surface). + */ +export interface PreviewLinkPayload { + prNumber: number; + environmentUrl: string; + marker: string; +} + +/** + * The `preview_url` + `preview_pr` outputs below ARE the emitted payload + * (marker `PREVIEW_MARKER`); the interface names the shape SEA-2014 consumes. + */ + +/** The GitHub deployment_status states this tool drives. */ +export type DeploymentState = + | "in_progress" + | "success" + | "failure" + | "inactive"; + +// --------------------------------------------------------------------------- +// Execution wiring. +// --------------------------------------------------------------------------- + +/** An active preview Deployment: its API id and the PR it tracks. */ +export interface ActiveDeployment { + id: number; + pr: number; +} + +/** + * The GitHub side effects (label lifecycle + Deployments API). Injected so the + * dispatch is tested with fakes. Real impls use the `gh` CLI (see main). + */ +export interface GitHubApi { + /** Remove a label from a PR (best-effort on a fork: read-only token). */ + removeLabel(pr: number, label: string): Promise; + /** Post a comment on a PR. */ + postComment(pr: number, body: string): Promise; + /** Create a `preview`-environment Deployment for a PR; returns its id. */ + createDeployment(pr: number, ref: string): Promise; + /** Set a Deployment's status (+ environment_url on success). */ + setDeploymentStatus( + deploymentId: number, + state: DeploymentState, + environmentUrl?: string, + ): Promise; + /** The current active preview Deployment, or null. */ + findActiveDeployment(): Promise; +} + +/** The inputs a deploy needs: which PR + ref go onto the preview env. */ +export interface DeployInput { + prNumber: number; + headSha: string; +} + +/** The result of a successful deploy: where the preview is reachable. */ +export interface DeployResult { + environmentUrl: string; +} + +/** + * The mattfw-side deploy (checkout → service restart → UI build → serve) and + * its inverse (release). Injected so the dispatch is tested without ssh. The + * real impl ssh's a command sequence to mattfw (see main). + */ +export interface Deployer { + deploy(input: DeployInput): Promise; + release(): Promise; +} + +/** Everything `runOnce` needs. */ +export interface Deps { + ctx: EventContext; + gh: GitHubApi; + deployer: Deployer; + /** Emit a stable workflow output (name=value) for downstream lanes. */ + emitOutput: (name: string, value: string) => void; + log: (msg: string) => void; + err: (msg: string) => void; +} + +/** + * Apply the decision. Drives the Deployment record through + * in_progress → success/failure (create-on-claim) and inactive-on-release, and + * emits the preview-link payload on success. Returns the process exit code. + */ +export async function runOnce(deps: Deps): Promise { + const { ctx, gh, deployer, log, err } = deps; + const decision = decide(ctx); + + switch (decision.kind) { + case "noop": + log(`compass-preview-deploy: no-op (${decision.reason}).`); + return 0; + + case "reject-fork": { + log( + `compass-preview-deploy: fork PR #${ctx.prNumber} (${ctx.headRepo}) ` + + "claimed the preview label — rejecting (no displace, no deploy).", + ); + // Best-effort on a fork: the `on: pull_request` token is read-only for + // a fork head, so these may 403. The security invariant does NOT depend + // on them — the deploy simply never runs for a fork. + await gh.removeLabel(ctx.prNumber, PREVIEW_LABEL); + await gh.postComment(ctx.prNumber, forkRejectionComment()); + return 0; + } + + case "claim": { + // Displace every other holder FIRST, so exactly one PR owns the env. + for (const loser of decision.displaced) { + log(`compass-preview-deploy: displacing PR #${loser}.`); + await gh.removeLabel(loser, PREVIEW_LABEL); + await gh.postComment(loser, displacementComment(ctx.prNumber)); + } + return deployAndRecord(deps, "claim"); + } + + case "redeploy": + return deployAndRecord(deps, "redeploy"); + + case "release": { + log(`compass-preview-deploy: releasing the env (PR #${ctx.prNumber}).`); + try { + await deployer.release(); + } catch (e) { + err( + `compass-preview-deploy: release failed: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + return 1; + } + const active = await gh.findActiveDeployment(); + if (active && active.pr === ctx.prNumber) { + await gh.setDeploymentStatus(active.id, "inactive"); + } + return 0; + } + } +} + +/** + * The deploy half shared by `claim` and `redeploy`: create the Deployment, + * drive its status, run the mattfw deploy, and emit the link payload. Marks the + * Deployment `failure` and returns 1 on any deploy error. + */ +async function deployAndRecord( + deps: Deps, + origin: "claim" | "redeploy", +): Promise { + const { ctx, gh, deployer, emitOutput, log, err } = deps; + log( + `compass-preview-deploy: ${origin} — deploying PR #${ctx.prNumber} ` + + `@ ${ctx.headSha} to the preview env.`, + ); + const deploymentId = await gh.createDeployment(ctx.prNumber, ctx.headSha); + await gh.setDeploymentStatus(deploymentId, "in_progress"); + try { + const { environmentUrl } = await deployer.deploy({ + prNumber: ctx.prNumber, + headSha: ctx.headSha, + }); + await gh.setDeploymentStatus(deploymentId, "success", environmentUrl); + // Emit the stable preview-link payload (PreviewLinkPayload shape) that the + // SEA-2014 result-surfacing lane consumes. + emitOutput("preview_url", environmentUrl); + emitOutput("preview_pr", String(ctx.prNumber)); + log(`compass-preview-deploy: preview up at ${environmentUrl}`); + return 0; + } catch (e) { + await gh.setDeploymentStatus(deploymentId, "failure"); + err( + `compass-preview-deploy: deploy failed: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + return 1; + } +} + +// --------------------------------------------------------------------------- +// Real I/O impls (gh CLI + ssh to mattfw), wired only under `import.meta.main`. +// --------------------------------------------------------------------------- + +/** Read a required env var, or throw a usage error naming it. */ +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is required`); + return v; +} + +/** Split a comma-joined label list into names (empty string → no labels). */ +export function parseLabels(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** The gh-CLI-backed GitHubApi. Every call names the repo explicitly. */ +function realGitHubApi(repo: string): GitHubApi { + return { + removeLabel: async (pr, label) => { + // -X DELETE the specific label; a 404 (already absent) is not fatal. + await $`gh api --method DELETE repos/${repo}/issues/${pr}/labels/${label}` + .nothrow() + .quiet(); + }, + postComment: async (pr, body) => { + await $`gh api --method POST repos/${repo}/issues/${pr}/comments -f body=${body}` + .nothrow() + .quiet(); + }, + createDeployment: async (pr, ref) => { + // required_contexts:[] so the deployment is not left pending on checks; + // the PR number rides in payload so findActiveDeployment can recover it. + const out = + await $`gh api --method POST repos/${repo}/deployments -f ref=${ref} -f environment=${PREVIEW_ENVIRONMENT} -F auto_merge=false -f required_contexts[]= -f payload={"pr":${pr}} --jq .id`.text(); + const id = Number.parseInt(out.trim(), 10); + if (!Number.isFinite(id)) { + throw new Error(`could not parse deployment id from: ${out}`); + } + return id; + }, + setDeploymentStatus: async (deploymentId, state, environmentUrl) => { + const args = [ + "--method", + "POST", + `repos/${repo}/deployments/${deploymentId}/statuses`, + "-f", + `state=${state}`, + "-f", + `environment=${PREVIEW_ENVIRONMENT}`, + ]; + if (environmentUrl) args.push("-f", `environment_url=${environmentUrl}`); + await $`gh api ${args}`.nothrow().quiet(); + }, + findActiveDeployment: async () => { + // Newest-first preview deployments; the first whose latest status is + // success or in_progress is the live one. Its payload carries the PR. + const raw = + await $`gh api repos/${repo}/deployments?environment=${PREVIEW_ENVIRONMENT}&per_page=30`.json(); + const deployments = raw as Array<{ + id: number; + payload?: { pr?: number } | string; + }>; + for (const d of deployments) { + const statuses = + await $`gh api repos/${repo}/deployments/${d.id}/statuses?per_page=1`.json(); + const latest = (statuses as Array<{ state: string }>)[0]; + if (!latest) continue; + if (latest.state === "success" || latest.state === "in_progress") { + const pr = + typeof d.payload === "object" && d.payload + ? d.payload.pr + : undefined; + if (typeof pr === "number") return { id: d.id, pr }; + } + } + return null; + }, + }; +} + +/** + * The ssh-to-mattfw Deployer. The runner has already joined the tailnet as an + * ephemeral tagged node (the workflow's tailscale step); this ssh's the deploy + * sequence to mattfw's `compass-preview` user, whose checkout + service + + * doors Record A provisioned. The command is composed here (TS), not as shell + * logic in YAML (the no-bash-gate); values ride in env, never interpolated + * into the remote command line. + */ +function realDeployer(cfg: { + sshHost: string; + sshUser: string; + checkout: string; + doorUrl: string; + previewUrl: string; + adminAccount: string; +}): Deployer { + // The remote deploy sequence, run in one ssh session as the preview user. + // It: pins the PR ref, restarts the service (Record A: the unit wraps + // `devenv up`; "may be torn down/redeployed freely"), waits for readiness, + // mints a reviewer bearer ON preview via IssueToken against the preview + // admin account, builds the PR's UI against preview's TLS door, and serves + // the dist at the root over `tailscale serve` (tailnet HTTPS; NEVER funnel). + const deployScript = [ + "set -euo pipefail", + 'cd "$PREVIEW_CHECKOUT"', + 'git fetch --force origin "$HEAD_SHA"', + 'git checkout --force --detach "$HEAD_SHA"', + "systemctl --user restart compass-preview.service", + // Readiness: poll the preview TLS door's GetServerInfo before minting. + 'for i in $(seq 1 60); do if curl -fsS --max-time 3 "$PREVIEW_DOOR_URL" >/dev/null 2>&1; then break; fi; sleep 2; done', + // Mint the reviewer bearer ON preview (IssueToken against the admin + // account). Admin-scoped is acceptable — the env is disposable and holds + // no `main` authority (design record §"Preview mints its own creds"). + 'TOKEN="$(compass token issue --account-id "$PREVIEW_ADMIN_ACCOUNT")"', + 'VITE_COMPASS_BASE_URL="$PREVIEW_DOOR_URL" VITE_COMPASS_TOKEN="$TOKEN" moon run compass-ui:build', + // Serve the built dist at the root over tailnet HTTPS. NEVER funnel. + 'tailscale serve --bg --https=443 "$PREVIEW_CHECKOUT/apps/ui/dist"', + ].join("\n"); + + const releaseScript = [ + "set -euo pipefail", + // Take the served path down and stop the service (cheapest release; a + // claim redeploys from scratch — design record §P1 label lifecycle). + "tailscale serve --https=443 off || true", + "systemctl --user stop compass-preview.service || true", + ].join("\n"); + + const target = `${cfg.sshUser}@${cfg.sshHost}`; + return { + deploy: async ({ headSha }) => { + // stdin is fed via `< ${Buffer}` redirection (Bun's $.stdin is a + // stream property, not a setter); the deploy config rides as remote + // `env VAR=…` args, each interpolation a single escaped argument. + const res = + await $`ssh -o StrictHostKeyChecking=accept-new ${target} env PREVIEW_CHECKOUT=${cfg.checkout} PREVIEW_DOOR_URL=${cfg.doorUrl} PREVIEW_ADMIN_ACCOUNT=${cfg.adminAccount} HEAD_SHA=${headSha} bash -s < ${Buffer.from(deployScript)}`.nothrow(); + if (res.exitCode !== 0) { + throw new Error( + `remote deploy exited ${res.exitCode}: ${res.stderr.toString()}`, + ); + } + return { environmentUrl: cfg.previewUrl }; + }, + release: async () => { + const res = + await $`ssh -o StrictHostKeyChecking=accept-new ${target} bash -s < ${Buffer.from(releaseScript)}`.nothrow(); + if (res.exitCode !== 0) { + throw new Error( + `remote release exited ${res.exitCode}: ${res.stderr.toString()}`, + ); + } + }, + }; +} + +/** Build the event context from the environment + gh queries. */ +async function contextFromEnv(gh: GitHubApi): Promise { + const actionRaw = requireEnv("EVENT_ACTION"); + if (!isEventAction(actionRaw)) { + throw new Error(`unsupported EVENT_ACTION: ${actionRaw}`); + } + const prNumber = Number.parseInt(requireEnv("PR_NUMBER"), 10); + if (!Number.isFinite(prNumber)) throw new Error("PR_NUMBER is not a number"); + const baseRepo = requireEnv("BASE_REPO"); + const repo = process.env.REPO ?? baseRepo; + + // Current holders (for a claim's displacement set): open PRs with the label. + let currentHolders: number[] = []; + if (actionRaw === "labeled") { + const raw = + await $`gh pr list --repo ${repo} --label ${PREVIEW_LABEL} --state open --json number --jq [.[].number]` + .nothrow() + .text(); + try { + currentHolders = (JSON.parse(raw.trim() || "[]") as number[]) ?? []; + } catch { + currentHolders = []; + } + } + + const active = await gh.findActiveDeployment(); + + return { + action: actionRaw, + prNumber, + headRepo: process.env.HEAD_REPO ?? "", + baseRepo, + headSha: process.env.HEAD_SHA ?? "", + changedLabel: process.env.CHANGED_LABEL || null, + prLabels: parseLabels(process.env.PR_LABELS), + currentHolders, + activePreviewPr: active ? active.pr : null, + }; +} + +if (import.meta.main) { + const repo = requireEnv("BASE_REPO"); + const gh = realGitHubApi(process.env.REPO ?? repo); + const deployer = realDeployer({ + sshHost: process.env.PREVIEW_SSH_HOST ?? "mattfw", + sshUser: process.env.PREVIEW_SSH_USER ?? "compass-preview", + checkout: process.env.PREVIEW_CHECKOUT ?? "~/compass-envs/preview", + doorUrl: process.env.PREVIEW_DOOR_URL ?? "https://mattfw:50161", + previewUrl: process.env.PREVIEW_URL ?? "https://mattfw/", + adminAccount: process.env.PREVIEW_ADMIN_ACCOUNT ?? "preview-admin", + }); + + let ctx: EventContext; + try { + ctx = await contextFromEnv(gh); + } catch (e) { + console.error( + `compass-preview-deploy: ${e instanceof Error ? e.message : String(e)}`, + ); + process.exit(2); + } + + const outputFile = process.env.GITHUB_OUTPUT; + process.exit( + await runOnce({ + ctx, + gh, + deployer, + emitOutput: (name, value) => { + // GITHUB_OUTPUT is append-only (multiple outputs across calls); a + // truncating write would clobber the earlier one. Append via the + // node fs binding — Bun.write has no append mode. + if (outputFile) appendFileSync(outputFile, `${name}=${value}\n`); + console.log(`::notice::${name}=${value}`); + }, + log: (msg) => console.log(msg), + err: (msg) => console.error(msg), + }), + ); +} diff --git a/tools/compass-preview-deploy/moon.yml b/tools/compass-preview-deploy/moon.yml new file mode 100644 index 00000000..07c12454 --- /dev/null +++ b/tools/compass-preview-deploy/moon.yml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# compass-preview-deploy (SEA-2027) — the Compass PR-preview deploy tool: the +# single-holder `preview`-label lifecycle (claim/displace/release + fork-claim +# rejection), the full-stack deploy to the isolated `preview` env on mattfw +# served tailnet-gated, and the GitHub Deployment record surfacing live state +# on the PR. A bun/TypeScript CLI; a hoisted root-workspace member (`bun` tag): +# install is inherited via .moon/tasks/tag-bun.yml (the shared root install) +# and lint/format are whole-repo tasks on the root project (/moon.yml), so this +# leaf has no own bun.lock and never runs its own install. +# +# NO `check` task, unlike the gate tools (cx-token-gate, design-ledger-gate): +# those observe the live tree on every CI run. This tool is a DEPLOY driver +# invoked by .github/workflows/compass-preview-deploy.yml on `preview`-label PR +# events — a SEPARATE least-privilege workflow, never the required CI gate (a +# preview flake must not red the merge gate). So the `ci` aggregate below is +# typecheck + test only: the pure state machine is unit-gated on every PR; the +# side-effecting deploy runs only from its own workflow, never in `moon run :ci`. +layer: 'tool' +language: 'typescript' +tags: ['bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + test: + command: 'bun test' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/compass-preview-deploy/package.json b/tools/compass-preview-deploy/package.json new file mode 100644 index 00000000..b9dcffd0 --- /dev/null +++ b/tools/compass-preview-deploy/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/compass-preview-deploy", + "private": true, + "type": "module", + "description": "PR-preview deploy tool (SEA-2027): the single-holder `preview`-label lifecycle (claim/displace/release + fork-claim rejection), the full-stack deploy to the isolated preview env on mattfw served tailnet-gated, and the GitHub Deployment record that surfaces live state on the PR.", + "module": "index.ts", + "bin": { + "compass-preview-deploy": "./index.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/compass-preview-deploy/tsconfig.json b/tools/compass-preview-deploy/tsconfig.json new file mode 100644 index 00000000..47d3248b --- /dev/null +++ b/tools/compass-preview-deploy/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "types": ["bun"] + } +} From f011c1f3253e15a3e4589662166fef5f706bbd09 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 16 Aug 2026 00:58:41 -0400 Subject: [PATCH 2/4] fix(preview): correct the GitHub Deployment payload write + read seam (SEA-2027) The single-holder release lifecycle was dead in the untested real-I/O seam: - createDeployment built the payload as a bun-shell string literal `-f payload={"pr":N}`; $ strips the quotes, sending invalid JSON `{pr:N}`. Build the JSON in JS and interpolate the variable so $ passes one properly-quoted arg. - findActiveDeployment only recovered the PR when payload was an object, but the Deployments API returns payload as a JSON STRING \u2014 so it always returned null, ctx.activePreviewPr was always null, and decide() never reached `release` for unlabeled/closed (env never torn down; race guard dead). Extract a pure exported deploymentPr(payload) that recovers the pr from a string OR object payload, never throwing, and use it. Adds deploymentPr unit tests (string/object/malformed/missing/non-numeric/ non-object). 30 tests green. Co-authored-by: Matt Wilkinson --- tools/compass-preview-deploy/index.test.ts | 32 ++++++++++++++++++ tools/compass-preview-deploy/index.ts | 39 ++++++++++++++++------ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/tools/compass-preview-deploy/index.test.ts b/tools/compass-preview-deploy/index.test.ts index 80e60cba..35c6a042 100644 --- a/tools/compass-preview-deploy/index.test.ts +++ b/tools/compass-preview-deploy/index.test.ts @@ -23,6 +23,7 @@ import { type DeploymentState, type Deps, decide, + deploymentPr, type EventContext, type GitHubApi, isEventAction, @@ -352,3 +353,34 @@ describe("runOnce — no-op", () => { expect(f.calls).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// deploymentPr — the payload PR recovery, robust to string OR object payload. +// --------------------------------------------------------------------------- + +describe("deploymentPr", () => { + test("a JSON STRING payload recovers the pr (the regression case)", () => { + // The Deployments API returns payload as a string; the read side MUST + // parse it, else activePreviewPr is always null and release never fires. + expect(deploymentPr('{"pr":42}')).toBe(42); + }); + test("an already-parsed object payload recovers the pr", () => { + expect(deploymentPr({ pr: 42 })).toBe(42); + }); + test("a malformed JSON string is undefined (never throws)", () => { + expect(deploymentPr("{pr:42}")).toBeUndefined(); + expect(deploymentPr("not json")).toBeUndefined(); + }); + test("a missing pr is undefined", () => { + expect(deploymentPr("{}")).toBeUndefined(); + expect(deploymentPr({})).toBeUndefined(); + }); + test("a non-numeric pr is undefined", () => { + expect(deploymentPr('{"pr":"42"}')).toBeUndefined(); + }); + test("a non-object payload is undefined", () => { + expect(deploymentPr(null)).toBeUndefined(); + expect(deploymentPr(undefined)).toBeUndefined(); + expect(deploymentPr(42)).toBeUndefined(); + }); +}); diff --git a/tools/compass-preview-deploy/index.ts b/tools/compass-preview-deploy/index.ts index bea9d4b9..841ca78c 100644 --- a/tools/compass-preview-deploy/index.ts +++ b/tools/compass-preview-deploy/index.ts @@ -416,6 +416,27 @@ export function parseLabels(raw: string | undefined): string[] { .filter((s) => s.length > 0); } +/** + * Recover the PR a preview Deployment tracks from its `payload`, robust to + * BOTH shapes GitHub can return: the Deployments API returns `payload` as a + * JSON STRING (what `gh api -f payload=…` sends), but a caller may hold it + * already parsed. Returns the numeric `pr`, or undefined for a non-object, + * malformed JSON, or a missing/non-numeric `pr` — never throws. + */ +export function deploymentPr(payload: unknown): number | undefined { + let obj: unknown = payload; + if (typeof payload === "string") { + try { + obj = JSON.parse(payload); + } catch { + return undefined; + } + } + if (typeof obj !== "object" || obj === null) return undefined; + const pr = (obj as { pr?: unknown }).pr; + return typeof pr === "number" ? pr : undefined; +} + /** The gh-CLI-backed GitHubApi. Every call names the repo explicitly. */ function realGitHubApi(repo: string): GitHubApi { return { @@ -433,8 +454,12 @@ function realGitHubApi(repo: string): GitHubApi { createDeployment: async (pr, ref) => { // required_contexts:[] so the deployment is not left pending on checks; // the PR number rides in payload so findActiveDeployment can recover it. + // Build the JSON in JS and interpolate the variable: a bun-shell string + // literal `{"pr":123}` gets its quotes STRIPPED (yielding invalid JSON + // `{pr:123}`), so the payload must be a single interpolated value. + const payload = JSON.stringify({ pr }); const out = - await $`gh api --method POST repos/${repo}/deployments -f ref=${ref} -f environment=${PREVIEW_ENVIRONMENT} -F auto_merge=false -f required_contexts[]= -f payload={"pr":${pr}} --jq .id`.text(); + await $`gh api --method POST repos/${repo}/deployments -f ref=${ref} -f environment=${PREVIEW_ENVIRONMENT} -F auto_merge=false -f required_contexts[]= -f payload=${payload} --jq .id`.text(); const id = Number.parseInt(out.trim(), 10); if (!Number.isFinite(id)) { throw new Error(`could not parse deployment id from: ${out}`); @@ -459,21 +484,15 @@ function realGitHubApi(repo: string): GitHubApi { // success or in_progress is the live one. Its payload carries the PR. const raw = await $`gh api repos/${repo}/deployments?environment=${PREVIEW_ENVIRONMENT}&per_page=30`.json(); - const deployments = raw as Array<{ - id: number; - payload?: { pr?: number } | string; - }>; + const deployments = raw as Array<{ id: number; payload?: unknown }>; for (const d of deployments) { const statuses = await $`gh api repos/${repo}/deployments/${d.id}/statuses?per_page=1`.json(); const latest = (statuses as Array<{ state: string }>)[0]; if (!latest) continue; if (latest.state === "success" || latest.state === "in_progress") { - const pr = - typeof d.payload === "object" && d.payload - ? d.payload.pr - : undefined; - if (typeof pr === "number") return { id: d.id, pr }; + const pr = deploymentPr(d.payload); + if (pr !== undefined) return { id: d.id, pr }; } } return null; From 5f68a357bd3bcd6b40f638e4e3edc80f8b6aaa27 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 16 Aug 2026 11:38:24 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(preview):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20empty=20required=5Fcontexts,=20payload=20test,=20be?= =?UTF-8?q?st-effort=20fork-reject=20(SEA-2027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (ReviewP1) — 0 high, 3 medium, 3 low fixed (the 4th low, ssh host-key pinning, deferred to SEA-2110: it needs mattfw's host key published as a CI secret, an infra action): - createDeployment sent required_contexts as -f required_contexts[]= which gh serializes to ['' ] (a context literally named ''), not []. Send the raw-JSON empty array -F required_contexts:=[] (via the args-array form, dodging bun's quote-stripping) so the deployment is not left pending on a bogus context. - extracted the write-side payload build into an exported pure deploymentPayload(pr) and pinned it to the read side (deploymentPr) with a round-trip test — this is the seam that regressed under bun quote-stripping and had no write-side test. - corrected the output-contract comments: the cross-workflow interface SEA-2014 consumes is the GitHub Deployment record (environment_url), which this tool already drives; GITHUB_OUTPUT does not cross workflows, so the emitted outputs are a same-run breadcrumb, not that interface. - documented the displace path's reliance on GitHub auto_inactive to flip a displaced holder's Deployment inactive; noted a superseded release is not exactly-once (reconciled by the next claim). - made the fork-reject label/comment best-effort explicit (try/catch) and added a test that a throwing label API still exits 0. Spec-impact: none Co-authored-by: Matt Wilkinson --- .github/workflows/compass-preview-deploy.yml | 3 + tools/compass-preview-deploy/index.test.ts | 31 +++++++++ tools/compass-preview-deploy/index.ts | 67 ++++++++++++++++---- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/.github/workflows/compass-preview-deploy.yml b/.github/workflows/compass-preview-deploy.yml index a5eff937..8afd5c48 100644 --- a/.github/workflows/compass-preview-deploy.yml +++ b/.github/workflows/compass-preview-deploy.yml @@ -53,6 +53,9 @@ on: # Claim → label-strip → checkout → restart against the ONE shared preview tree # runs strictly one-at-a-time. cancel-in-progress:false so a superseded deploy # finishes cleanly rather than tearing the shared checkout mid-flight. +# NOTE: a queued `release` event superseded by a newer preview event is NOT +# guaranteed to run — release is not exactly-once. The next claim reconciles the +# env by stateless re-derivation, so a dropped release self-heals. concurrency: group: compass-preview-deploy cancel-in-progress: false diff --git a/tools/compass-preview-deploy/index.test.ts b/tools/compass-preview-deploy/index.test.ts index 35c6a042..f6a5b70d 100644 --- a/tools/compass-preview-deploy/index.test.ts +++ b/tools/compass-preview-deploy/index.test.ts @@ -23,6 +23,7 @@ import { type DeploymentState, type Deps, decide, + deploymentPayload, deploymentPr, type EventContext, type GitHubApi, @@ -192,6 +193,7 @@ describe("parseLabels", () => { function fakes(over?: { deployThrows?: boolean; releaseThrows?: boolean; + labelApiThrows?: boolean; active?: ActiveDeployment | null; }) { const calls: string[] = []; @@ -201,9 +203,11 @@ function fakes(over?: { const gh: GitHubApi = { removeLabel: async (pr, label) => { calls.push(`removeLabel(${pr},${label})`); + if (over?.labelApiThrows) throw new Error("403 read-only fork token"); }, postComment: async (pr) => { calls.push(`postComment(${pr})`); + if (over?.labelApiThrows) throw new Error("403 read-only fork token"); }, createDeployment: async (pr, ref) => { calls.push(`createDeployment(${pr},${ref})`); @@ -300,6 +304,19 @@ describe("runOnce — fork rejection", () => { expect(f.calls.some((c) => c.startsWith("createDeployment"))).toBe(false); expect(f.calls).not.toContain("removeLabel(9,preview)"); }); + + test("a fork claim tolerates a throwing label API (best-effort) and still returns 0", async () => { + // On a real fork the `on: pull_request` token is read-only, so removeLabel + // + postComment 403. realGitHubApi swallows via .nothrow(); this pins that + // runOnce's fork-reject path also tolerates an injected API that throws. + const f = fakes({ labelApiThrows: true }); + const code = await runOnce( + f.deps(ctx({ headRepo: FORK, prNumber: 10, currentHolders: [10] })), + ); + expect(code).toBe(0); + expect(f.calls).not.toContain("deploy"); + expect(f.calls.some((c) => c.startsWith("createDeployment"))).toBe(false); + }); }); describe("runOnce — release", () => { @@ -384,3 +401,17 @@ describe("deploymentPr", () => { expect(deploymentPr(42)).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// deploymentPayload — the WRITE side of the payload seam (pinned with the read +// side, since this is the seam that regressed under bun's quote-stripping). +// --------------------------------------------------------------------------- + +describe("deploymentPayload", () => { + test("emits valid JSON carrying the pr", () => { + expect(JSON.parse(deploymentPayload(42))).toEqual({ pr: 42 }); + }); + test("round-trips through deploymentPr (write + read pinned together)", () => { + expect(deploymentPr(deploymentPayload(42))).toBe(42); + }); +}); diff --git a/tools/compass-preview-deploy/index.ts b/tools/compass-preview-deploy/index.ts index 841ca78c..22bc7be3 100644 --- a/tools/compass-preview-deploy/index.ts +++ b/tools/compass-preview-deploy/index.ts @@ -222,8 +222,11 @@ export interface PreviewLinkPayload { } /** - * The `preview_url` + `preview_pr` outputs below ARE the emitted payload - * (marker `PREVIEW_MARKER`); the interface names the shape SEA-2014 consumes. + * The cross-workflow interface SEA-2014 consumes is the GitHub Deployment + * record (its `environment_url`, set on the success status); GITHUB_OUTPUT does + * not cross workflows. The `preview_url` + `preview_pr` outputs below (marker + * `PREVIEW_MARKER`) are a same-run/`::notice::` breadcrumb for local + * visibility, not the cross-workflow payload. */ /** The GitHub deployment_status states this tool drives. */ @@ -316,15 +319,29 @@ export async function runOnce(deps: Deps): Promise { "claimed the preview label — rejecting (no displace, no deploy).", ); // Best-effort on a fork: the `on: pull_request` token is read-only for - // a fork head, so these may 403. The security invariant does NOT depend - // on them — the deploy simply never runs for a fork. - await gh.removeLabel(ctx.prNumber, PREVIEW_LABEL); - await gh.postComment(ctx.prNumber, forkRejectionComment()); + // a fork head, so these WILL 403. The security invariant does NOT depend + // on them — the deploy simply never runs for a fork — so tolerate a + // failing label API here (realGitHubApi swallows via .nothrow(); this + // guard also holds for an injected API that throws) and still exit 0. + try { + await gh.removeLabel(ctx.prNumber, PREVIEW_LABEL); + await gh.postComment(ctx.prNumber, forkRejectionComment()); + } catch (e) { + err( + `compass-preview-deploy: fork-reject label/comment failed (ignored): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } return 0; } case "claim": { // Displace every other holder FIRST, so exactly one PR owns the env. + // We strip the loser's label + comment but do NOT explicitly mark its + // Deployment inactive: GitHub's auto_inactive default flips prior + // deployments on this environment inactive when the winner's success + // status posts below (via deployAndRecord). for (const loser of decision.displaced) { log(`compass-preview-deploy: displacing PR #${loser}.`); await gh.removeLabel(loser, PREVIEW_LABEL); @@ -379,8 +396,10 @@ async function deployAndRecord( headSha: ctx.headSha, }); await gh.setDeploymentStatus(deploymentId, "success", environmentUrl); - // Emit the stable preview-link payload (PreviewLinkPayload shape) that the - // SEA-2014 result-surfacing lane consumes. + // The CROSS-workflow interface SEA-2014 consumes is the GitHub Deployment + // record (its environment_url, set on success above); these emitted outputs + // are a same-run/`::notice::` breadcrumb for local visibility, not the + // cross-workflow payload (GITHUB_OUTPUT does not cross workflows). emitOutput("preview_url", environmentUrl); emitOutput("preview_pr", String(ctx.prNumber)); log(`compass-preview-deploy: preview up at ${environmentUrl}`); @@ -416,6 +435,11 @@ export function parseLabels(raw: string | undefined): string[] { .filter((s) => s.length > 0); } +/** The `payload` field for a preview Deployment: JSON so findActiveDeployment can recover the PR. Pure. */ +export function deploymentPayload(pr: number): string { + return JSON.stringify({ pr }); +} + /** * Recover the PR a preview Deployment tracks from its `payload`, robust to * BOTH shapes GitHub can return: the Deployments API returns `payload` as a @@ -452,14 +476,31 @@ function realGitHubApi(repo: string): GitHubApi { .quiet(); }, createDeployment: async (pr, ref) => { - // required_contexts:[] so the deployment is not left pending on checks; + // required_contexts is sent as a raw-JSON empty array `[]` (NOT `[""]`) + // so the deployment is not left pending on a context literally named ""; // the PR number rides in payload so findActiveDeployment can recover it. // Build the JSON in JS and interpolate the variable: a bun-shell string // literal `{"pr":123}` gets its quotes STRIPPED (yielding invalid JSON - // `{pr:123}`), so the payload must be a single interpolated value. - const payload = JSON.stringify({ pr }); - const out = - await $`gh api --method POST repos/${repo}/deployments -f ref=${ref} -f environment=${PREVIEW_ENVIRONMENT} -F auto_merge=false -f required_contexts[]= -f payload=${payload} --jq .id`.text(); + // `{pr:123}`), so the payload must be a single interpolated value. The + // raw-JSON `required_contexts:=[]` likewise rides as an array element to + // dodge quote-stripping (mirrors setDeploymentStatus's args array below). + const payload = deploymentPayload(pr); + const args = [ + "--method", + "POST", + `repos/${repo}/deployments`, + "-f", + `ref=${ref}`, + "-f", + `environment=${PREVIEW_ENVIRONMENT}`, + "-F", + "auto_merge=false", + "-F", + "required_contexts:=[]", + "-f", + `payload=${payload}`, + ]; + const out = await $`gh api ${args} --jq .id`.text(); const id = Number.parseInt(out.trim(), 10); if (!Number.isFinite(id)) { throw new Error(`could not parse deployment id from: ${out}`); From 97f8be785e470498cb96b5a966252ce558ce0b50 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 16 Aug 2026 11:55:58 -0400 Subject: [PATCH 4/4] fix(preview): send required_contexts as a true empty array via gh -f key[] (SEA-2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 (ReviewP1r2) — HIGH: the round-1 required_contexts fix used `-F required_contexts:=[]`, which is httpie/curl raw-JSON syntax gh does NOT support. Verified against gh 2.96.0 with a capture server: it sends a bogus field `{"required_contexts:":"[]"}` with the real field ABSENT, so the Deployments API defaults to requiring all commit-status contexts and 409s while the CI gate runs concurrently — createDeployment throws unhandled and the claim crashes. This is the same seam that regressed in round 1 (`-f key[]=` sent [""]), now in a third form. Root cause of the repeat: no test covered realGitHubApi's arg construction (the fakes bypass it). Fix both the encoding and the gap: - extract the gh-api argv into a pure exported createDeploymentArgs(repo,ref,pr) using the ONLY gh form that yields an empty array: `-f required_contexts[]` (key with [] and NO =/value). Verified end-to-end: gh sends {"required_contexts":[]}. - unit-test the builder's encoding (asserts `-f required_contexts[]`, rejects the two wrong forms, pins the payload + endpoint), so the write side is regression-pinned, not just the payload round-trip. Spec-impact: none Co-authored-by: Matt Wilkinson --- tools/compass-preview-deploy/index.test.ts | 30 ++++++++++ tools/compass-preview-deploy/index.ts | 66 ++++++++++++++-------- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/tools/compass-preview-deploy/index.test.ts b/tools/compass-preview-deploy/index.test.ts index f6a5b70d..b7d85131 100644 --- a/tools/compass-preview-deploy/index.test.ts +++ b/tools/compass-preview-deploy/index.test.ts @@ -19,6 +19,7 @@ import { describe, expect, test } from "bun:test"; import { type ActiveDeployment, + createDeploymentArgs, type Deployer, type DeploymentState, type Deps, @@ -415,3 +416,32 @@ describe("deploymentPayload", () => { expect(deploymentPr(deploymentPayload(42))).toBe(42); }); }); + +// --------------------------------------------------------------------------- +// createDeploymentArgs — the gh-api WRITE encoding. This seam regressed twice +// on the required_contexts empty-array form (`-f key[]=` → [""], `-F key:=[]` → +// a bogus field), because the fakes bypass realGitHubApi's arg construction. +// Pin the exact encoding here. +// --------------------------------------------------------------------------- + +describe("createDeploymentArgs", () => { + test("encodes required_contexts as an empty array via `-f key[]` (no `=`)", () => { + const args = createDeploymentArgs("o/r", "abc123", 42); + // gh sends {"required_contexts":[]} ONLY for `-f required_contexts[]` with + // no value; the two wrong forms carry an `=` or use `:=` raw-JSON syntax. + const i = args.indexOf("required_contexts[]"); + expect(i).toBeGreaterThan(0); + expect(args[i - 1]).toBe("-f"); + expect(args).not.toContain("required_contexts[]="); + expect(args).not.toContain("required_contexts:=[]"); + }); + test("carries the pr payload as a single interpolated JSON string", () => { + const args = createDeploymentArgs("o/r", "abc123", 42); + expect(args).toContain(`payload=${deploymentPayload(42)}`); + }); + test("targets the repo's deployments endpoint with the given ref", () => { + const args = createDeploymentArgs("o/r", "abc123", 42); + expect(args).toContain("repos/o/r/deployments"); + expect(args).toContain("ref=abc123"); + }); +}); diff --git a/tools/compass-preview-deploy/index.ts b/tools/compass-preview-deploy/index.ts index 22bc7be3..85e7faa3 100644 --- a/tools/compass-preview-deploy/index.ts +++ b/tools/compass-preview-deploy/index.ts @@ -440,6 +440,43 @@ export function deploymentPayload(pr: number): string { return JSON.stringify({ pr }); } +/** + * The `gh api` argv (after `gh api`) that creates a preview Deployment. Pure so + * the write-side encoding is regression-pinned in a unit test — this seam has + * bitten twice on the `required_contexts` empty-array encoding. + * + * `required_contexts` MUST be an empty JSON array `[]` so the deployment does + * not sit pending on checks. gh's ONLY encoding for that is `-f key[]` with NO + * `=` and no value (verified against gh 2.96.0): it sends `{"required_contexts":[]}`. + * The two wrong forms both broke the claim path: `-f key[]=` sends `[""]` (a + * context named ""), and `-F key:=[]` (httpie raw-JSON syntax gh does NOT + * support) sends a bogus field `"required_contexts:":"[]"` with the real field + * absent — which makes the API default to all-contexts and 409 while CI runs. + * `payload` rides as a single interpolated JSON string (a bun-shell `$` string + * literal would get its quotes stripped). + */ +export function createDeploymentArgs( + repo: string, + ref: string, + pr: number, +): string[] { + return [ + "--method", + "POST", + `repos/${repo}/deployments`, + "-f", + `ref=${ref}`, + "-f", + `environment=${PREVIEW_ENVIRONMENT}`, + "-F", + "auto_merge=false", + "-f", + "required_contexts[]", + "-f", + `payload=${deploymentPayload(pr)}`, + ]; +} + /** * Recover the PR a preview Deployment tracks from its `payload`, robust to * BOTH shapes GitHub can return: the Deployments API returns `payload` as a @@ -476,31 +513,10 @@ function realGitHubApi(repo: string): GitHubApi { .quiet(); }, createDeployment: async (pr, ref) => { - // required_contexts is sent as a raw-JSON empty array `[]` (NOT `[""]`) - // so the deployment is not left pending on a context literally named ""; - // the PR number rides in payload so findActiveDeployment can recover it. - // Build the JSON in JS and interpolate the variable: a bun-shell string - // literal `{"pr":123}` gets its quotes STRIPPED (yielding invalid JSON - // `{pr:123}`), so the payload must be a single interpolated value. The - // raw-JSON `required_contexts:=[]` likewise rides as an array element to - // dodge quote-stripping (mirrors setDeploymentStatus's args array below). - const payload = deploymentPayload(pr); - const args = [ - "--method", - "POST", - `repos/${repo}/deployments`, - "-f", - `ref=${ref}`, - "-f", - `environment=${PREVIEW_ENVIRONMENT}`, - "-F", - "auto_merge=false", - "-F", - "required_contexts:=[]", - "-f", - `payload=${payload}`, - ]; - const out = await $`gh api ${args} --jq .id`.text(); + // The gh-api arg encoding (incl. the required_contexts empty-array form) + // lives in the pure createDeploymentArgs so it is regression-pinned. + const out = + await $`gh api ${createDeploymentArgs(repo, ref, pr)} --jq .id`.text(); const id = Number.parseInt(out.trim(), 10); if (!Number.isFinite(id)) { throw new Error(`could not parse deployment id from: ${out}`);