From 3832372f5f28a5171a6f38f3dd32823e107392d9 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 9 Aug 2026 16:28:13 -0400 Subject: [PATCH 1/3] feat(eng-docs): SEA-1765 deploy.ts on GitHub Actions + eng-docs-deploy.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the sealed docsite deploy to the compass eng-docs app, adapting the CI plane from Woodpecker to GitHub Actions. deploy.ts keeps the pure construction/execution split verbatim (wrangler argv, changed-page mapping, comment body, escape/encode helpers) and swaps the Woodpecker CI_* env for the GITHUB_*/PR_* set. The recorded commit SHA is PR_HEAD_SHA (github.event.pull_request.head.sha) on a PR and GITHUB_SHA on push — never GITHUB_SHA on a PR, which is the ephemeral refs/pull/N/merge commit rather than the branch head. Adds the standalone eng-docs-deploy.yml CD workflow (not part of moon ci :ci) with a same-repo fork guard on the deploy job, reusing ci.yml's .prototools pin-reading shape. Refs SEA-1765 Co-authored-by: Matt Wilkinson --- .github/workflows/eng-docs-deploy.yml | 107 ++++ apps/eng-docs/scripts/deploy.test.ts | 699 ++++++++++++++++++++++++++ apps/eng-docs/scripts/deploy.ts | 506 +++++++++++++++++++ 3 files changed, 1312 insertions(+) create mode 100644 .github/workflows/eng-docs-deploy.yml create mode 100644 apps/eng-docs/scripts/deploy.test.ts create mode 100644 apps/eng-docs/scripts/deploy.ts diff --git a/.github/workflows/eng-docs-deploy.yml b/.github/workflows/eng-docs-deploy.yml new file mode 100644 index 00000000..26da91a4 --- /dev/null +++ b/.github/workflows/eng-docs-deploy.yml @@ -0,0 +1,107 @@ +# Continuous deployment for the Compass engineering docsite (apps/eng-docs). +# +# A push to main deploys production; a pull_request deploys a per-PR Cloudflare +# Pages preview and upserts the preview-URL comment (apps/eng-docs/scripts/ +# deploy.ts, SEA-1765). This is a STANDALONE CD workflow — it is deliberately +# NOT part of `moon ci :ci` (the CI gate in ci.yml), so a deploy failure never +# gates a merge and a green CI never waits on Cloudflare. +name: eng-docs-deploy + +on: + push: + branches: [main] + pull_request: {} + +permissions: + contents: read + pull-requests: write + +concurrency: + group: eng-docs-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + # Fork guard: a push always runs; a PR only runs when it comes from a branch + # in THIS repo. Fork PRs get no secrets (CLOUDFLARE_*/GH_TOKEN), so a deploy + # would fail anyway — and running one with an attacker's code near secret + # plumbing is the risk this closes. Same-repo PRs are trusted. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read the toolchain pins + id: pins + # .prototools is the single source for the language/runtime versions. + # Reading it here rather than hardcoding into the `setup-*` inputs below + # is what keeps that true — a pin bump is one file. Mirrors ci.yml's + # "Read the toolchain pins" step (ci.yml:154-196). + # + # `/^\[/q` stops at the first table header, so only top-level pins are + # read and a future [settings] section is never mistaken for one. + # + # Every value is shape-checked before it is written. A pin is always a + # version string; anything else means the file is malformed or crafted, + # and refusing is the same posture the parity gate takes. This also + # closes GITHUB_OUTPUT injection, since a rejected value never reaches + # the file. + run: | + pins=$(sed -n '/^\[/q; s/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=[[:space:]]*"\([^"]\{1,\}\)".*/\1=\2/p' \ + .prototools) + while IFS='=' read -r name value; do + [ -n "$name" ] || continue + case $value in + *[!0-9A-Za-z.+-]*|'') + echo "::error::.prototools pin '$name' has value '$value', which is not a version string" + exit 1 + ;; + esac + done < { + expect(isPullRequest({ GITHUB_EVENT_NAME: "pull_request" })).toBe(true); +}); + +test("isPullRequest is false for a push event", () => { + expect(isPullRequest({ GITHUB_EVENT_NAME: "push" })).toBe(false); +}); + +test("isPullRequest is false when the event is unset", () => { + expect(isPullRequest({})).toBe(false); +}); + +// ── deployBranch ───────────────────────────────────────────────────────────── + +test("deployBranch is the production 'main' alias on a push", () => { + expect(deployBranch({ GITHUB_EVENT_NAME: "push" })).toBe("main"); +}); + +test("deployBranch uses the PR source branch on a pull_request", () => { + expect( + deployBranch({ + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: "feat/x", + }), + ).toBe("feat/x"); +}); + +test("deployBranch trims surrounding whitespace off the PR source branch", () => { + expect( + deployBranch({ + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: " feat/x ", + }), + ).toBe("feat/x"); +}); + +test("deployBranch throws on a PR with an unset source branch (never overwrite production)", () => { + expect(() => deployBranch({ GITHUB_EVENT_NAME: "pull_request" })).toThrow( + /GITHUB_HEAD_REF is not set/, + ); +}); + +test("deployBranch throws on a PR with an empty source branch", () => { + expect(() => + deployBranch({ + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: "", + }), + ).toThrow(/refusing to deploy a preview/); +}); + +test("deployBranch throws on a PR with a whitespace-only source branch", () => { + expect(() => + deployBranch({ + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: " ", + }), + ).toThrow(); +}); + +// ── recordedCommitSha ──────────────────────────────────────────────────────── + +test("recordedCommitSha is GITHUB_SHA on a push (the branch head)", () => { + expect( + recordedCommitSha({ GITHUB_EVENT_NAME: "push", GITHUB_SHA: "pushsha" }), + ).toBe("pushsha"); +}); + +test("recordedCommitSha is PR_HEAD_SHA on a PR (the branch head, NOT the merge commit)", () => { + // The load-bearing invariant: on a pull_request event GITHUB_SHA is the + // ephemeral refs/pull/N/merge commit, so the recorded SHA must be PR_HEAD_SHA + // (github.event.pull_request.head.sha) — never GITHUB_SHA. + expect( + recordedCommitSha({ + GITHUB_EVENT_NAME: "pull_request", + PR_HEAD_SHA: "prheadsha", + GITHUB_SHA: "mergesha", + }), + ).toBe("prheadsha"); +}); + +test("recordedCommitSha never returns GITHUB_SHA (the merge commit) on a PR", () => { + expect( + recordedCommitSha({ + GITHUB_EVENT_NAME: "pull_request", + PR_HEAD_SHA: "prheadsha", + GITHUB_SHA: "mergesha", + }), + ).not.toBe("mergesha"); +}); + +test("recordedCommitSha is undefined on a PR with no PR_HEAD_SHA", () => { + expect( + recordedCommitSha({ + GITHUB_EVENT_NAME: "pull_request", + GITHUB_SHA: "mergesha", + }), + ).toBeUndefined(); +}); + +// ── wranglerArgs ───────────────────────────────────────────────────────────── + +test("wranglerArgs builds the exact production argv for 'main'", () => { + expect(wranglerArgs("main")).toEqual([ + "wrangler", + "pages", + "deploy", + "dist", + "--project-name=compass-eng-docs", + "--branch=main", + "--commit-dirty=true", + ]); +}); + +test("wranglerArgs builds the exact preview argv for a feature branch", () => { + expect(wranglerArgs("feat/x")).toEqual([ + "wrangler", + "pages", + "deploy", + "dist", + "--project-name=compass-eng-docs", + "--branch=feat/x", + "--commit-dirty=true", + ]); +}); + +// ── changedFilesArgs ───────────────────────────────────────────────────────── + +test("changedFilesArgs builds the exact GET argv with per_page in the query string", () => { + // The whole shape is regression-locked: per_page rides the URL query string, + // NOT a -F flag. `pulls/{n}/files` is GET-only; `gh api` switches to POST the + // moment any parameter flag (-F/-f/--field/--raw-field) is present, which + // 404s and (behind fetchChangedDocPages's fail-soft) silently drops the whole + // changed-pages section. + expect(changedFilesArgs("RigelBuild/compass", "634")).toEqual([ + "api", + "--paginate", + "repos/RigelBuild/compass/pulls/634/files?per_page=100", + "--jq", + ".[] | [.filename, .status] | @tsv", + ]); +}); + +test("changedFilesArgs passes NO parameter flag (a -F/-f would flip the GET-only endpoint to POST)", () => { + // The defect guard, invariant-level: not one argv element may be a field flag, + // so a future edit re-introducing `-F per_page` reddens here regardless of + // exact-argv churn elsewhere. + const args = changedFilesArgs("owner/repo", "1"); + for (const flag of ["-F", "-f", "--field", "--raw-field"]) { + expect(args).not.toContain(flag); + } + // per_page must instead be carried on the endpoint (the query string). + expect(args.some((a) => a.includes("?per_page=100"))).toBe(true); +}); + +// ── parsePreviewUrl ────────────────────────────────────────────────────────── + +test("parsePreviewUrl extracts the pages.dev URL from a wrangler line", () => { + const output = + "Deploying to Cloudflare Pages...\n" + + "Take a peek over at https://abc123.compass-eng-docs.pages.dev\n"; + expect(parsePreviewUrl(output)).toBe( + "https://abc123.compass-eng-docs.pages.dev", + ); +}); + +test("parsePreviewUrl returns the LAST pages.dev URL when several are present", () => { + const output = [ + "https://first.compass-eng-docs.pages.dev", + "noise in the middle", + "https://second.compass-eng-docs.pages.dev", + "Take a peek over at https://final-abc123.compass-eng-docs.pages.dev", + ].join("\n"); + expect(parsePreviewUrl(output)).toBe( + "https://final-abc123.compass-eng-docs.pages.dev", + ); +}); + +test("parsePreviewUrl returns null when no pages.dev URL is present", () => { + expect(parsePreviewUrl("Deploy complete. See https://example.com/x")).toBe( + null, + ); +}); + +test("parsePreviewUrl returns null for empty output", () => { + expect(parsePreviewUrl("")).toBe(null); +}); + +// ── changedDocPages ────────────────────────────────────────────────────────── + +// Hermetic markdownlint config literal mirroring the real .markdownlint-cli2.jsonc +// shape (no file/network read). parseExclusions() reads `ignores` and appends +// `**/outputs/**`; isExcluded() additionally hard-excludes `apps/eng-docs/**`. +const markdownlintConfig = JSON.stringify({ + globs: ["**/*.md"], + gitignore: true, + ignores: ["forks/*/**"], +}); + +test("changedDocPages maps a docs/ file to its site route", () => { + // classify("docs/designs/repo/foo.md") → destRel "designs/repo/foo.md" + // (domain "designs" ∈ DOMAINS); routeSlug drops .md + lowercases → + // "/designs/repo/foo". + expect( + changedDocPages( + [{ filename: "docs/designs/repo/foo.md", status: "modified" }], + markdownlintConfig, + ), + ).toEqual([ + { + sourcePath: "docs/designs/repo/foo.md", + route: "/designs/repo/foo", + }, + ]); +}); + +test("changedDocPages slugifies a dotted directory segment", () => { + // A synthetic docs path with a dotted directory segment. changedDocPages is + // a pure classifier (no disk read), so the fixture need not name a real file + // — it exercises routeSlug slugifying each segment, stripping the dot: + // `v1.2` → `v12`. The dotted route would 404 otherwise. + expect( + changedDocPages( + [ + { + filename: "docs/designs/product/v1.2/design.md", + status: "modified", + }, + ], + markdownlintConfig, + ), + ).toEqual([ + { + sourcePath: "docs/designs/product/v1.2/design.md", + route: "/designs/product/v12/design", + }, + ]); +}); + +test("changedDocPages maps a package doc via packages/", () => { + // "go/README.md" is not a docs/ path, not in CONTRIBUTING_FILES; + // packagePath → { id: "go", rest: "README.md" } → destRel + // "packages/go/README.md"; routeSlug lowercases → "/packages/go/readme". + expect( + changedDocPages( + [{ filename: "go/README.md", status: "added" }], + markdownlintConfig, + ), + ).toEqual([{ sourcePath: "go/README.md", route: "/packages/go/readme" }]); +}); + +test("changedDocPages drops a non-markdown file", () => { + // .png fails the /\.md$/ extension gate — dropped before classify. + expect( + changedDocPages( + [{ filename: "docs/designs/repo/diagram.png", status: "added" }], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages drops a file excluded by the markdownlint ignores", () => { + // "forks/oh-my-pi/readme.md" matches the "forks/*/**" ignore glob → + // isExcluded true. + expect( + changedDocPages( + [{ filename: "forks/oh-my-pi/readme.md", status: "added" }], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages drops the docsite's own tree (apps/eng-docs/**)", () => { + // isExcluded hard-excludes anything under apps/eng-docs/ so the gathered site + // never mirrors itself. + expect( + changedDocPages( + [{ filename: "apps/eng-docs/src/content/x.md", status: "modified" }], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages drops a deleted (removed) markdown file", () => { + // A removed file has no rendered page — the status !== "removed" gate. + expect( + changedDocPages( + [{ filename: "docs/specs/web/gone.md", status: "removed" }], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages returns [] for empty input", () => { + expect(changedDocPages([], markdownlintConfig)).toEqual([]); +}); + +test("changedDocPages returns [] when every file is dropped", () => { + expect( + changedDocPages( + [ + { filename: "docs/designs/repo/diagram.png", status: "added" }, // non-md + { filename: "docs/specs/web/gone.md", status: "removed" }, // removed + { filename: "apps/eng-docs/src/content/x.md", status: "modified" }, // docsite tree + { filename: "forks/oh-my-pi/readme.md", status: "added" }, // markdownlint-excluded + ], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages preserves input order and multiplicity, dropping in place", () => { + // Two kept files with a dropped .png and a duplicate interleaved: the output is + // the kept pages in input order, duplicates retained (no dedup, no reorder). + // docs/specs/web/api.md → destRel "specs/web/api.md" ("specs" ∈ DOMAINS) → + // "/specs/web/api". + expect( + changedDocPages( + [ + { filename: "docs/designs/repo/foo.md", status: "modified" }, + { filename: "docs/designs/repo/diagram.png", status: "added" }, // dropped + { filename: "docs/designs/repo/foo.md", status: "added" }, // duplicate + { filename: "docs/specs/web/api.md", status: "modified" }, + ], + markdownlintConfig, + ), + ).toEqual([ + { + sourcePath: "docs/designs/repo/foo.md", + route: "/designs/repo/foo", + }, + { + sourcePath: "docs/designs/repo/foo.md", + route: "/designs/repo/foo", + }, + { sourcePath: "docs/specs/web/api.md", route: "/specs/web/api" }, + ]); +}); + +test("changedDocPages drops an uppercase .MD file (case-SENSITIVE extension gate)", () => { + // The gate is /\.md$/ (NO /i): gather's Glob("**/*.md") is case-sensitive and + // routeSlug strips ".md" case-sensitively, so a `.MD` page the gather never + // produced must be dropped — linking it would 404. + expect( + changedDocPages( + [{ filename: "docs/designs/repo/FOO.MD", status: "added" }], + markdownlintConfig, + ), + ).toEqual([]); +}); + +test("changedDocPages keeps a renamed markdown file (only 'removed' is dropped)", () => { + // The status gate drops only "removed"; "renamed"/"added"/"modified" all + // render, so a renamed page is mapped like any other. + expect( + changedDocPages( + [{ filename: "docs/designs/repo/foo.md", status: "renamed" }], + markdownlintConfig, + ), + ).toEqual([ + { + sourcePath: "docs/designs/repo/foo.md", + route: "/designs/repo/foo", + }, + ]); +}); + +test("changedDocPages drops node_modules/ and /dist/ paths (gather secondary skips)", () => { + // Parity with the gather's Glob scan skips — a page there never renders; + // classify would otherwise map it to a dead route. + expect( + changedDocPages( + [ + { filename: "node_modules/p/readme.md", status: "added" }, + { filename: "packages/x/dist/gen.md", status: "modified" }, + ], + markdownlintConfig, + ), + ).toEqual([]); +}); + +// Each VCS/tooling dotdir the gather skips — one test per alt so dropping any +// single alt from the regex reddens a named case. Each path passes every other +// guard (it is .md, not removed, not node_modules/dist, not in the exclusion +// globs), so its drop is attributable to the dotdir skip alone. +for (const dir of [ + "git", + "astro", + "direnv", + "moon", + "vscode", + "idea", + "pagefind", +]) { + test(`changedDocPages drops the .${dir}/ tooling dotdir (gather secondary skip)`, () => { + expect( + changedDocPages( + [{ filename: `.${dir}/notes.md`, status: "added" }], + markdownlintConfig, + ), + ).toEqual([]); + }); +} + +// ── parseChangedFiles ──────────────────────────────────────────────────────── + +test("parseChangedFiles maps each TAB-separated line to a ChangedFile", () => { + expect( + parseChangedFiles( + "docs/a.md\tmodified\ngo/README.md\tadded\ndocs/my design.md\trenamed", + ), + ).toEqual([ + { filename: "docs/a.md", status: "modified" }, + { filename: "go/README.md", status: "added" }, + // a filename with spaces is fine — the split is on TAB, not whitespace. + { filename: "docs/my design.md", status: "renamed" }, + ]); +}); + +test("parseChangedFiles ignores a trailing newline (no empty trailing entry)", () => { + expect(parseChangedFiles("docs/a.md\tmodified\n")).toEqual([ + { filename: "docs/a.md", status: "modified" }, + ]); +}); + +test("parseChangedFiles treats CRLF line endings like LF", () => { + // gh on a Windows runner may emit \r\n; the \r must not leak into status. + expect( + parseChangedFiles("docs/a.md\tmodified\r\ngo/README.md\tadded\r\n"), + ).toEqual([ + { filename: "docs/a.md", status: "modified" }, + { filename: "go/README.md", status: "added" }, + ]); +}); + +test("parseChangedFiles drops a line with no tab", () => { + expect( + parseChangedFiles("docs/a.md\tmodified\nnotabline\ngo/README.md\tadded"), + ).toEqual([ + { filename: "docs/a.md", status: "modified" }, + { filename: "go/README.md", status: "added" }, + ]); +}); + +test("parseChangedFiles skips blank lines", () => { + expect( + parseChangedFiles("docs/a.md\tmodified\n\ngo/README.md\tadded"), + ).toEqual([ + { filename: "docs/a.md", status: "modified" }, + { filename: "go/README.md", status: "added" }, + ]); +}); + +test("parseChangedFiles splits on the FIRST tab, keeping the remainder as status", () => { + // indexOf('\t') → everything after the first tab is the status verbatim. + expect(parseChangedFiles("a\tb\tc")).toEqual([ + { filename: "a", status: "b\tc" }, + ]); +}); + +// ── escapeLinkText / encodeRoutePath ───────────────────────────────────────── + +test("escapeLinkText backslash-escapes \\ [ ] ` and HTML-entity-encodes < > &", () => { + // \ [ ] ` structure Markdown (link + code span) → backslash-escaped, inert; + // < > & are HTML-significant (GitHub renders a raw /
in comment + // Markdown) → entity-encoded, so an attacker-named .md can't inject markup or + // break the link. + expect(escapeLinkText("foo](evil).md")).toBe("foo\\](evil).md"); + expect(escapeLinkText("a[b]c\\d")).toBe("a\\[b\\]c\\\\d"); + // a backtick would open a GFM code span that swallows adjacent link lines → + // backslash-escaped so the filename char stays inert. + expect(escapeLinkText("a`b.md")).toBe("a\\`b.md"); + expect(escapeLinkText('.md')).toBe('<img src="x">.md'); + expect(escapeLinkText("a&b")).toBe("a&b"); + expect(escapeLinkText("
")).toBe("<details>"); + // A filename literally containing an entity-like sequence is escaped + // faithfully: the `&` is encoded so `<` renders as the literal text + // "<", never decoded to a `<`. This is why escapeLinkText encodes `&`. + expect(escapeLinkText("a<b")).toBe("a&lt;b"); + expect(escapeLinkText("&")).toBe("&amp;"); + // a clean filename passes through untouched. + expect(escapeLinkText("designs/repo/foo.md")).toBe("designs/repo/foo.md"); +}); + +test("encodeRoutePath encodes each segment, keeps /, and encodes ( ) that would close a Markdown link", () => { + // space + clean route: + expect(encodeRoutePath("/designs/my doc/a b")).toBe( + "/designs/my%20doc/a%20b", + ); + expect(encodeRoutePath("/designs/repo/foo")).toBe("/designs/repo/foo"); + // ( ) → %28 %29: encodeURIComponent leaves them raw, and a raw ) closes the + // Markdown (...) target early → 404. Encoded explicitly per segment. + expect(encodeRoutePath("/x/foo)bar")).toBe("/x/foo%29bar"); + expect(encodeRoutePath("/x/foo(bar)")).toBe("/x/foo%28bar%29"); + // # is already encoded by encodeURIComponent — lock it. + expect(encodeRoutePath("/x/a#b")).toBe("/x/a%23b"); +}); + +// ── commentBody ────────────────────────────────────────────────────────────── + +test("commentBody is marker-prefixed and carries the preview URL", () => { + const url = "https://abc123.compass-eng-docs.pages.dev"; + const body = commentBody({ + previewUrl: url, + branch: "feat/x", + commitSha: "abcdef1234", + }); + expect(body.startsWith("")).toBe(true); + expect(body).toContain(url); +}); + +test("commentBody names the site and records the branch + short commit", () => { + expect( + commentBody({ + previewUrl: "https://x.pages.dev", + branch: "feat/x", + commitSha: "abcdef1234567", + }), + ).toBe( + "\n**Compass engineering docs preview:** https://x.pages.dev\n\nDeployed from `feat/x` at `abcdef1`.", + ); +}); + +test("commentBody omits the commit clause when no sha is available", () => { + expect( + commentBody({ previewUrl: "https://x.pages.dev", branch: "feat/x" }), + ).toBe( + "\n**Compass engineering docs preview:** https://x.pages.dev\n\nDeployed from `feat/x`.", + ); +}); + +test("commentBody with changedPages: [] is byte-identical to no section", () => { + // Empty changedPages must not add a section — backward-compatible with the + // pre-feature comment string. + expect( + commentBody({ + previewUrl: "https://x.pages.dev", + branch: "feat/x", + commitSha: "abcdef1234567", + changedPages: [], + }), + ).toBe( + "\n**Compass engineering docs preview:** https://x.pages.dev\n\nDeployed from `feat/x` at `abcdef1`.", + ); +}); + +test("commentBody appends the Changed pages section verbatim (exact format lock)", () => { + // The section format is review-frozen: bold heading, bullet list, escaped + // label, single-slash `previewUrl+route` link target. Pin the whole body so + // the bullet form, heading, and label text all regress-lock. + expect( + commentBody({ + previewUrl: "https://abc.pages.dev", + branch: "feat/x", + commitSha: "abcdef1234567", + changedPages: [ + { + sourcePath: "docs/designs/repo/foo.md", + route: "/designs/repo/foo", + }, + { sourcePath: "go/README.md", route: "/packages/go/readme" }, + ], + }), + ).toBe( + "\n**Compass engineering docs preview:** https://abc.pages.dev\n\nDeployed from `feat/x` at `abcdef1`.\n\n**Changed pages:**\n- [docs/designs/repo/foo.md](https://abc.pages.dev/designs/repo/foo)\n- [go/README.md](https://abc.pages.dev/packages/go/readme)", + ); +}); + +test("commentBody escapes a Markdown-breaking char in an attacker-controlled sourcePath", () => { + // sourcePath is a git filename from the PR's changed-files list (attacker- + // controllable). The `]` in the label is backslash-escaped so a crafted name + // like `foo](evil).md` can't close the link early and inject a spoofed target. + const body = commentBody({ + previewUrl: "https://abc.pages.dev", + branch: "feat/x", + changedPages: [{ sourcePath: "foo](evil).md", route: "/designs/foo" }], + }); + expect(body).toContain( + "[foo\\](evil).md](https://abc.pages.dev/designs/foo)", + ); + // the unescaped early-close link never appears + expect(body).not.toContain("[foo](evil)"); +}); + +test("commentBody escapes the label AND encodes the route for one changed page (exact body)", () => { + // Combined worst case in a single entry: sourcePath needs < > entity-encoded + // (the ) stays literal — not in the escape set), and the route's ) must be + // %29 so the Markdown link target isn't truncated. Whole body byte-exact. + expect( + commentBody({ + previewUrl: "https://abc.pages.dev", + branch: "feat/x", + commitSha: "abcdef1234567", + changedPages: [{ sourcePath: "foo).md", route: "/designs/foo)bar" }], + }), + ).toBe( + "\n**Compass engineering docs preview:** https://abc.pages.dev\n\nDeployed from `feat/x` at `abcdef1`.\n\n**Changed pages:**\n- [<x>foo).md](https://abc.pages.dev/designs/foo%29bar)", + ); +}); + +// ── ghToken ────────────────────────────────────────────────────────────────── + +test("ghToken returns GH_TOKEN", () => { + expect(ghToken({ GH_TOKEN: "gh-tok" })).toBe("gh-tok"); +}); + +test("ghToken trims GH_TOKEN", () => { + expect(ghToken({ GH_TOKEN: " gh-tok " })).toBe("gh-tok"); +}); + +test("ghToken is undefined when GH_TOKEN is blank", () => { + expect(ghToken({ GH_TOKEN: " " })).toBeUndefined(); +}); + +test("ghToken is undefined when GH_TOKEN is not set", () => { + const env: DeployEnv = {}; + expect(ghToken(env)).toBeUndefined(); +}); + +// ── resolvePreviewComment ───────────────────────────────────────────────────── + +const commentEnv: DeployEnv = { + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: "feat/x", + PR_NUMBER: "42", + GITHUB_REPOSITORY: "RigelBuild/compass", + GH_TOKEN: "gh-tok", +}; + +test("resolvePreviewComment returns the resolved inputs when all are present", () => { + const result = resolvePreviewComment(commentEnv); + expect(result).toEqual({ + ok: true, + inputs: { repo: "RigelBuild/compass", pr: "42", token: "gh-tok" }, + }); +}); + +test("resolvePreviewComment trims the PR number and repo", () => { + const result = resolvePreviewComment({ + ...commentEnv, + PR_NUMBER: " 42 ", + GITHUB_REPOSITORY: " RigelBuild/compass ", + }); + expect(result).toEqual({ + ok: true, + inputs: { repo: "RigelBuild/compass", pr: "42", token: "gh-tok" }, + }); +}); + +test("resolvePreviewComment fails (not skips) when no GitHub token is set", () => { + const { GH_TOKEN: _t, ...noToken } = commentEnv; + const result = resolvePreviewComment(noToken); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("GitHub token"); +}); + +test("resolvePreviewComment fails when the PR number is missing", () => { + const { PR_NUMBER: _p, ...noPr } = commentEnv; + const result = resolvePreviewComment(noPr); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("PR number"); +}); + +test("resolvePreviewComment fails when the repo is missing", () => { + const { GITHUB_REPOSITORY: _r, ...noRepo } = commentEnv; + const result = resolvePreviewComment(noRepo); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("repo"); +}); + +test("resolvePreviewComment names every missing input at once", () => { + const result = resolvePreviewComment({ GITHUB_EVENT_NAME: "pull_request" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("GitHub token"); + expect(result.reason).toContain("PR number"); + expect(result.reason).toContain("repo"); + } +}); diff --git a/apps/eng-docs/scripts/deploy.ts b/apps/eng-docs/scripts/deploy.ts new file mode 100644 index 00000000..535f5f41 --- /dev/null +++ b/apps/eng-docs/scripts/deploy.ts @@ -0,0 +1,506 @@ +// Deploy the built docsite (dist/) to Cloudflare Pages, then on a PR upsert a +// preview-URL comment. Invoked by the compass-eng-docs:deploy / +// compass-eng-docs:deploy-preview moon tasks (CD, runInCI:false) — gated to +// push:main / pull_request by the standalone .github/workflows/eng-docs-deploy.yml +// GitHub Actions workflow (SEA-1765). The engineering docsite lives on +// Cloudflare Pages under the compass-eng-docs project. +// +// Adapted from sealed apps/docs/scripts/deploy.ts — they share the same +// Cloudflare Pages deploy + PR-preview-comment shape and the three constants +// below (PROJECT_NAME, COMMENT_MARKER, SITE_LABEL). This docsite build gathers +// the compass repo's markdown, so its preview comment also deep-links the .md +// pages a PR changed (changedDocPages + commentBody's "Changed pages" section). +// +// Construction vs execution: the pure functions below build typed arg arrays + +// parse output; a thin `$` runner at the bottom executes. The pure parts are +// unit-tested (deploy.test.ts) with no live wrangler/gh. +// +// Env (GitHub Actions injects the GITHUB_* set; PR_* are passed explicitly from +// github.event.pull_request; the CLOUDFLARE_*/GH_TOKEN are declared secrets): +// CLOUDFLARE_API_TOKEN — wrangler auth (secret). +// CLOUDFLARE_ACCOUNT_ID — Cloudflare account (secret). +// GITHUB_EVENT_NAME — "push" (production) or "pull_request" (preview). +// GITHUB_HEAD_REF — PR source branch (the preview branch name; PR only). +// GITHUB_SHA — the checked-out commit; the branch head on push, but +// the ephemeral merge commit on a PR (NOT recorded). +// PR_HEAD_SHA — github.event.pull_request.head.sha, the PR branch +// head — the commit recorded in the preview comment. +// GITHUB_REPOSITORY — owner/name, for the gh comment + changed-files lookup. +// PR_NUMBER — PR number, for the gh comment + changed-files lookup. +// GH_TOKEN — gh auth for the preview comment + the PR changed-files +// lookup (PR only). +// +// Cloudflare Pages keys deployments by --branch: "main" is the production +// alias; any other branch is a preview deployment with its own stable URL. + +import { dirname, join } from "node:path"; +import { $ } from "bun"; +import { classify, isExcluded, parseExclusions, routeSlug } from "./gather.ts"; + +const PROJECT_NAME = "compass-eng-docs"; +const COMMENT_MARKER = ""; +const SITE_LABEL = "Compass engineering docs"; + +/** The environment the deploy reads — the subset of `process.env` we depend on. */ +export interface DeployEnv { + GITHUB_EVENT_NAME?: string; + GITHUB_HEAD_REF?: string; + GITHUB_SHA?: string; + PR_HEAD_SHA?: string; + GITHUB_REPOSITORY?: string; + PR_NUMBER?: string; + GH_TOKEN?: string; + CLOUDFLARE_API_TOKEN?: string; + CLOUDFLARE_ACCOUNT_ID?: string; +} + +/** True when this run is a pull_request event (→ preview deploy + comment). */ +export function isPullRequest(env: DeployEnv): boolean { + return env.GITHUB_EVENT_NAME === "pull_request"; +} + +/** + * The Cloudflare Pages `--branch` to deploy under. Production (push:main) uses + * the "main" alias; a PR uses its SOURCE branch. Never fall back to a target + * branch on a PR — that would risk deploying a preview over production — so an + * unset source branch on a PR throws. + */ +export function deployBranch(env: DeployEnv): string { + if (isPullRequest(env)) { + const source = env.GITHUB_HEAD_REF?.trim(); + if (!source) { + throw new Error( + "GITHUB_HEAD_REF is not set; refusing to deploy a preview (would risk overwriting production)", + ); + } + return source; + } + return "main"; +} + +/** + * The commit SHA recorded in the preview comment. On a PR this is the PR branch + * HEAD (PR_HEAD_SHA = github.event.pull_request.head.sha), NEVER GITHUB_SHA — + * on a pull_request event GITHUB_SHA is the ephemeral refs/pull/N/merge commit, + * not the branch head, so recording it would make the changed-page deep links + * point at a commit the reviewer never pushed. On a push GITHUB_SHA is the + * branch head and is correct. + */ +export function recordedCommitSha(env: DeployEnv): string | undefined { + return isPullRequest(env) ? env.PR_HEAD_SHA : env.GITHUB_SHA; +} + +/** The `wrangler pages deploy` argv for a given branch. */ +export function wranglerArgs(branch: string): string[] { + return [ + "wrangler", + "pages", + "deploy", + "dist", + `--project-name=${PROJECT_NAME}`, + `--branch=${branch}`, + // The CI checkout has generated (untracked) dist/ contents; without this + // wrangler warns and skips commit metadata. + "--commit-dirty=true", + ]; +} + +/** + * Extract the preview deployment URL from wrangler's output. It prints a line + * like "Take a peek over at https://.compass-eng-docs.pages.dev"; return + * the last pages.dev URL, or null when none is present. + */ +export function parsePreviewUrl(wranglerOutput: string): string | null { + const matches = wranglerOutput.match(/https:\/\/[a-z0-9.-]+\.pages\.dev/g); + return matches && matches.length > 0 ? (matches.at(-1) ?? null) : null; +} + +/** + * The `gh api` argv that lists a PR's changed files as `filenamestatus` + * lines. `per_page=100` rides the URL query string, NOT a `-F`/`-f` flag: any + * parameter flag makes `gh api` switch the request to POST, and + * `pulls/{n}/files` is GET-only — a `-F per_page` would POST, 404, and (behind + * the caller's fail-soft) silently drop the whole changed-pages section. Kept + * pure + exported so the GET-safe shape is regression-locked in tests, the same + * construction/execution split as `wranglerArgs`. + */ +export function changedFilesArgs(repo: string, pr: string): string[] { + return [ + "api", + "--paginate", + `repos/${repo}/pulls/${pr}/files?per_page=100`, + "--jq", + ".[] | [.filename, .status] | @tsv", + ]; +} + +/** A changed file as reported by the GitHub PR files API. */ +export interface ChangedFile { + /** Repo-relative path, e.g. "docs/designs/platform/foo.md". */ + filename: string; + /** "added" | "modified" | "removed" | "renamed" | … — deleted files are dropped. */ + status: string; +} + +/** A changed docsite page: the changed source path and the site route it renders at. */ +export interface ChangedPage { + /** The changed source path as it appears in the PR, e.g. "docs/designs/platform/foo.md". */ + sourcePath: string; + /** + * The site route it renders at (no host), e.g. "/designs/platform/foo". This + * is a RAW route — NOT URL-safe. Any consumer building a link target from it + * MUST pass it through `encodeRoutePath` first (as `commentBody` does); a + * `sourcePath` is an attacker-controlled git filename, so an un-encoded route + * can carry a `)`/space that breaks or spoofs the Markdown link. + */ + route: string; +} + +/** + * The docsite pages a PR's changed files map to. Keeps only files that actually + * render on the docsite — markdown, not deleted, and not excluded from the + * gather — then maps each through the gather's own classify→routeSlug chain (the + * same mapping gather.ts uses to place a page), so every route is guaranteed to + * resolve on the deployed preview. Pure: the gh lookup + config read live in + * fetchChangedDocPages (the thin `$` runner below). + * + * @param changed the PR's changed files (filename + status) + * @param markdownlintConfig the raw .markdownlint-cli2.jsonc (source of the + * exclusion set, via parseExclusions) — the single source of truth for what + * the gather drops. + */ +export function changedDocPages( + changed: readonly ChangedFile[], + markdownlintConfig: string, +): ChangedPage[] { + const exclusions = parseExclusions(markdownlintConfig); + const pages: ChangedPage[] = []; + for (const { filename, status } of changed) { + // Deleted files no longer render — linking them would 404. + if (status === "removed") continue; + // Only files the gather actually renders become pages. Match its + // membership test exactly (gather.ts main()): a case-SENSITIVE `.md` + // (Bun's `**/*.md` glob does not match `.MD`, and routeSlug strips `.md` + // case-sensitively — a `/i` gate here would link a page the gather never + // produced), minus node_modules/dist and the VCS/tooling dotdirs the + // gather skips, minus the shared exclusion set. Keeping these in lockstep + // is what makes the docstring's "route guaranteed to resolve" true. + if (!/\.md$/.test(filename)) continue; + if (filename.includes("node_modules/") || filename.includes("/dist/")) { + continue; + } + if ( + /(^|\/)\.(git|astro|direnv|moon|vscode|idea|pagefind)\//.test(filename) + ) { + continue; + } + // A file the gather drops is not on the docsite — same exclusion set. + if (isExcluded(filename, exclusions)) continue; + // Same classify→routeSlug chain the gather uses to place the page, so + // the route is guaranteed to resolve on the deployed preview. + pages.push({ + sourcePath: filename, + route: routeSlug(classify(filename).destRel), + }); + } + return pages; +} + +/** + * Parse the `gh api …/files --jq '.[] | [.filename, .status] | @tsv'` output + * into changed files. Each non-empty line is `filenamestatus`; jq's `@tsv` + * escapes any literal tab/newline inside a field, so splitting on a real tab is + * unambiguous. Pure (text in, structs out) so it unit-tests with no live gh. + */ +export function parseChangedFiles(tsv: string): ChangedFile[] { + const files: ChangedFile[] = []; + for (const raw of tsv.split("\n")) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (line.length === 0) continue; + const tab = line.indexOf("\t"); + if (tab === -1) continue; + files.push({ + filename: line.slice(0, tab), + status: line.slice(tab + 1), + }); + } + return files; +} + +/** Preview metadata surfaced in the PR comment. */ +export interface PreviewMeta { + previewUrl: string; + branch: string; + commitSha?: string; + /** + * The docsite pages this PR added/changed (from changedDocPages). When + * present and non-empty, commentBody appends a "Changed pages" section of + * direct deep-links into the preview; empty/omitted → no section. + */ + changedPages?: readonly ChangedPage[]; +} + +/** + * Escape the characters that let an attacker-controlled label break out of the + * Markdown link text or inject markup into the bot's comment: the link-breaking + * `\`, `[`, `]` and the code-span-opening `` ` `` (backslash-escaped) and the + * HTML-significant `<`, `>`, `&` (entity-encoded). The label is a git filename + * from the PR's changed-files list — never checked out on CI, so `<`/`>`/`&`/`"` + * are all legal in it — and + * GitHub renders a raw ``/`
`/`` in comment Markdown (its + * sanitizer blocks JS/XSS and camo-proxies images, but the tags themselves are + * permitted), so an unescaped `<` is real content-spoofing into a trusted + * comment, not just a broken link. Encoding `&` too (not only `<`/`>`) keeps the + * escape faithful: a name literally containing `<` becomes `&lt;` and + * renders as literal text, so no pre-existing entity-like sequence is silently + * interpreted as the `<` it spells. + */ +export function escapeLinkText(text: string): string { + return text.replace(/[\\[\]<>&`]/g, (c) => { + switch (c) { + case "<": + return "<"; + case ">": + return ">"; + case "&": + return "&"; + default: + return `\\${c}`; + } + }); +} + +/** + * Percent-encode a site route for use inside a Markdown link target, per path + * segment (so the `/` separators survive). `encodeURIComponent` handles spaces + * (`%20`) and most punctuation but deliberately leaves `(` and `)` un-encoded, + * and a `)` in the target closes the Markdown `(...)` early — so a route from a + * filename containing a paren would truncate the link and 404. Encode those two + * explicitly after `encodeURIComponent` so every route the deploy links is a + * well-formed target. + */ +export function encodeRoutePath(route: string): string { + return route + .split("/") + .map((seg) => + encodeURIComponent(seg).replace(/[()]/g, (c) => + c === "(" ? "%28" : "%29", + ), + ) + .join("/"); +} + +/** + * The marker-prefixed PR comment body: names the site, links the preview, and + * records the branch + commit it reflects so a reviewer can see what's deployed. + */ +export function commentBody(meta: PreviewMeta): string { + const source = meta.commitSha + ? `\`${meta.branch}\` at \`${meta.commitSha.slice(0, 7)}\`` + : `\`${meta.branch}\``; + const base = `${COMMENT_MARKER}\n**${SITE_LABEL} preview:** ${meta.previewUrl}\n\nDeployed from ${source}.`; + // Additive "Changed pages" section: one deep-link per changed docsite page, + // so a reviewer clicks straight to the rendered page. Omitted/empty → the + // base body is byte-identical to before this feature. + const pages = meta.changedPages ?? []; + if (pages.length === 0) return base; + // sourcePath is an attacker-controllable git filename (it arrives from the + // PR's changed-files list). Backslash-escape the Markdown link-breaking + // chars in the label, and encode the route so a space/`)` can't terminate + // the URL — a crafted `.md` name can't inject a link or spoof the target. + const links = pages + .map( + (p) => + `- [${escapeLinkText(p.sourcePath)}](${meta.previewUrl}${encodeRoutePath(p.route)})`, + ) + .join("\n"); + return `${base}\n\n**Changed pages:**\n${links}`; +} + +/** The gh token for the preview comment. */ +export function ghToken(env: DeployEnv): string | undefined { + return env.GH_TOKEN?.trim() || undefined; +} + +/** The inputs needed to post the preview comment, once resolved from the env. */ +export interface PreviewCommentInputs { + repo: string; + pr: string; + token: string; +} + +/** + * Resolve what's needed to post the preview comment, or the reason it can't be. + * On a pull request the comment is a required outcome, so missing inputs are an + * error (a red step) rather than a silent skip — a preview that never surfaces + * its URL is a broken deploy from the PR author's view. + */ +export function resolvePreviewComment( + env: DeployEnv, +): { ok: true; inputs: PreviewCommentInputs } | { ok: false; reason: string } { + const token = ghToken(env); + const pr = env.PR_NUMBER?.trim(); + const repo = env.GITHUB_REPOSITORY?.trim(); + const missing: string[] = []; + if (!token) missing.push("GitHub token (GH_TOKEN)"); + if (!pr) missing.push("PR number (PR_NUMBER)"); + if (!repo) missing.push("repo (GITHUB_REPOSITORY)"); + if (!token || !pr || !repo) { + return { + ok: false, + reason: `cannot post preview comment: missing ${missing.join(", ")}`, + }; + } + return { ok: true, inputs: { repo, pr, token } }; +} + +// ── Execution (thin `$` runner) ───────────────────────────────────────────── + +async function main(): Promise { + const env = process.env as DeployEnv; + if (!env.CLOUDFLARE_API_TOKEN || !env.CLOUDFLARE_ACCOUNT_ID) { + throw new Error( + "CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are required", + ); + } + const branch = deployBranch(env); + console.log(`Deploying dist/ to Cloudflare Pages (branch: ${branch})...`); + + // wrangler reads CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID from the env. + // Capture combined output so we can parse the preview URL for the comment. + const args = wranglerArgs(branch); + const result = await $`bunx ${args}`.nothrow(); + const out = result.stdout.toString() + result.stderr.toString(); + console.log(out); + if (result.exitCode !== 0) { + throw new Error(`wrangler pages deploy failed (exit ${result.exitCode})`); + } + + // Non-PR (production) deploy: nothing more to do. + if (!isPullRequest(env)) return; + + const previewUrl = parsePreviewUrl(out); + if (!previewUrl) { + throw new Error( + "deploy succeeded but no preview URL was found in wrangler output", + ); + } + console.log(`Preview URL: ${previewUrl}`); + + // On a PR the preview comment is required: any failure to post it fails the + // step, so a broken comment can't hide behind a green check. + const comment = resolvePreviewComment(env); + if (!comment.ok) throw new Error(comment.reason); + const changedPages = await fetchChangedDocPages( + comment.inputs.repo, + comment.inputs.pr, + comment.inputs.token, + ); + await upsertPreviewComment({ + repo: comment.inputs.repo, + pr: comment.inputs.pr, + token: comment.inputs.token, + body: commentBody({ + previewUrl, + branch, + // The PR branch head, NEVER GITHUB_SHA (the merge commit) on a PR. + commitSha: recordedCommitSha(env), + changedPages, + }), + }); +} + +/** + * The docsite pages a PR changed, resolved live: list the PR's changed files + * via the GitHub API (not a local `git diff` — the CI agent clone is shallow, + * so local history is unreliable), then map them through the pure + * changedDocPages against the repo's canonical markdownlint exclusion set. + * Returns [] (no section) on any lookup failure — the deep-links are a + * convenience, never worth failing an otherwise-good deploy over. + */ +async function fetchChangedDocPages( + repo: string, + pr: string, + token: string, +): Promise { + const ghEnv = { ...process.env, GH_TOKEN: token }; + // `@tsv` emits one `filenamestatus` line per file, escaping any tab/ + // newline inside a field — parsed by the pure parseChangedFiles (no JSON + // shape to assert). Args built by the pure changedFilesArgs (GET-safe + // per_page in the query string, not a POST-flipping -F flag). + const files = await $`gh ${changedFilesArgs(repo, pr)}` + .env(ghEnv) + .nothrow() + .quiet(); + if (files.exitCode !== 0) { + console.log( + `could not list changed files (exit ${files.exitCode}): ${files.stderr.toString().trim()}; omitting the changed-pages section`, + ); + return []; + } + // The exclusion set's single source of truth is the repo-root markdownlint + // config (the same file gather.ts reads). This file is apps/eng-docs/scripts/ + // deploy.ts, so the repo root is three dirname hops up from its path + // (scripts → eng-docs → apps → root), matching gather.ts's own walk. + // Guarded like the gh call above: a missing/renamed config, a JSONC parse + // failure, or a classify throw must also omit the section, never fail the + // deploy — the docstring's "any lookup failure" contract covers this half too. + try { + const repoRoot = dirname( + dirname(dirname(dirname(Bun.fileURLToPath(import.meta.url)))), + ); + const markdownlintConfig = await Bun.file( + join(repoRoot, ".markdownlint-cli2.jsonc"), + ).text(); + return changedDocPages( + parseChangedFiles(files.stdout.toString()), + markdownlintConfig, + ); + } catch (err) { + console.log( + `could not resolve changed docsite pages (${err}); omitting the changed-pages section`, + ); + return []; + } +} + +/** Upsert the single marker comment on the PR via the gh CLI. */ +async function upsertPreviewComment(opts: { + repo: string; + pr: string; + token: string; + body: string; +}): Promise { + const ghEnv = { ...process.env, GH_TOKEN: opts.token }; + // Find an existing marker comment to edit; else create one. A failed + // lookup (rate-limit, transient network, auth) must not be read as "no + // comment found" — that would create a duplicate every push, breaking the + // single-marker contract. Fail loud on a non-zero exit; only an empty + // stdout on exit 0 means no existing comment. + const existing = + await $`gh api --paginate ${`repos/${opts.repo}/issues/${opts.pr}/comments`} --jq ${`.[] | select(.body | startswith("${COMMENT_MARKER}")) | .id`}` + .env(ghEnv) + .nothrow() + .quiet(); + if (existing.exitCode !== 0) { + throw new Error( + `failed to look up existing PR comments (exit ${existing.exitCode})`, + ); + } + const id = existing.stdout.toString().trim().split("\n")[0]?.trim(); + if (id) { + await $`gh api --method PATCH ${`repos/${opts.repo}/issues/comments/${id}`} -f ${`body=${opts.body}`}` + .env(ghEnv) + .quiet(); + console.log("updated existing preview comment"); + } else { + await $`gh pr comment ${opts.pr} --repo ${opts.repo} --body ${opts.body}` + .env(ghEnv) + .quiet(); + console.log("created preview comment"); + } +} + +if (import.meta.main) { + await main(); +} From 2d165a46a61327668764c2d9422d8af95be93822 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 9 Aug 2026 16:51:37 -0400 Subject: [PATCH 2/3] fix(eng-docs): run the deploy step in the project dir so wrangler finds dist/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `eng-docs-deploy.yml` Deploy step invoked `bun apps/eng-docs/scripts/deploy.ts` from the repo root, but `wranglerArgs` deploys the project-relative `dist` path (`wrangler pages deploy dist`) and the build writes to `apps/eng-docs/dist` (moon `build.outputs: ['dist']`, run in the project dir). From the repo-root cwd `dist` resolved to `/dist`, which never exists, so every production and preview deploy failed. Restore the frozen record's §T3 shape: `working-directory: apps/eng-docs` + `run: bun scripts/deploy.ts`, so the workflow's cwd matches the moon-task cwd and `dist` resolves to `apps/eng-docs/dist` where the build emits it. deploy.ts's only cwd-relative operation is the wrangler `dist` arg; `repoRoot` for the changed-pages lookup is derived from `import.meta.url` (cwd-independent), so nothing else shifts. Spec-impact: none. Refs SEA-1765 Co-authored-by: Matt Wilkinson --- .github/workflows/eng-docs-deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/eng-docs-deploy.yml b/.github/workflows/eng-docs-deploy.yml index 26da91a4..b7c5a6f1 100644 --- a/.github/workflows/eng-docs-deploy.yml +++ b/.github/workflows/eng-docs-deploy.yml @@ -104,4 +104,5 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: bun apps/eng-docs/scripts/deploy.ts + working-directory: apps/eng-docs + run: bun scripts/deploy.ts From 8730007d7dfacb61ce41385b1da100ece7dc8122 Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 9 Aug 2026 17:40:58 -0400 Subject: [PATCH 3/3] fix(eng-docs): full-history checkout so the deploy build can resolve main The `eng-docs-deploy.yml` build step (`moon run compass-eng-docs:build`) aborts on a PR with `git ... main` exit 128 ("ambiguous argument 'main': unknown revision"). `actions/checkout` defaults to a shallow, single-ref checkout, but moon's affected-graph diffing resolves its base against `main` (`.moon/workspace.yml` `defaultBranch: main`) and needs that ref present. Without it the build dies before the deploy step ever runs. Set `fetch-depth: 0` on the checkout, matching `ci.yml:130-135` (which carries the same full-history requirement for its `buf breaking` base). Full history populates `refs/remotes/origin/*`, so moon resolves the base and the build proceeds. Spec-impact: none. Refs SEA-1765 Co-authored-by: Matt Wilkinson --- .github/workflows/eng-docs-deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/eng-docs-deploy.yml b/.github/workflows/eng-docs-deploy.yml index b7c5a6f1..291f42f4 100644 --- a/.github/workflows/eng-docs-deploy.yml +++ b/.github/workflows/eng-docs-deploy.yml @@ -30,6 +30,13 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # moon's affected-graph diffing (compass-eng-docs:build depends on the + # gather inputs) resolves its base against `main`; a shallow single-ref + # PR checkout has no such ref, so the build's `git ... main` aborts + # (exit 128). Full history populates refs/remotes/origin/*, matching + # ci.yml's checkout (ci.yml:130-135). + fetch-depth: 0 - name: Read the toolchain pins id: pins