diff --git a/apps/marketing/vercel.test.ts b/apps/marketing/vercel.test.ts new file mode 100644 index 0000000000..cd63178a5c --- /dev/null +++ b/apps/marketing/vercel.test.ts @@ -0,0 +1,102 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +import { config } from "./vercel.ts"; + +/** + * The ignore command decides whether Vercel spends a build. It is a shell + * string, so nothing type-checks it, and the previous version silently leaked: + * a push to a new branch has no previously deployed commit and fell back to + * comparing one commit, which built whenever that commit moved a lockfile. + * Exercise it against a real repository rather than asserting on its text. + */ +describe("marketing ignore command", () => { + let dir: string; + let siteChanged: string; + + const run = (ref: string, previousSha: string): "skip" | "build" => { + try { + NodeChildProcess.execFileSync("bash", ["-c", config.ignoreCommand ?? ""], { + cwd: NodePath.join(dir, "apps/marketing"), + env: { + ...process.env, + VERCEL_GIT_COMMIT_REF: ref, + ...(previousSha ? { VERCEL_GIT_PREVIOUS_SHA: previousSha } : {}), + }, + stdio: "ignore", + }); + return "skip"; + } catch { + return "build"; + } + }; + + const commit = (message: string): string => { + NodeChildProcess.execFileSync("git", ["add", "-A"], { cwd: dir, stdio: "ignore" }); + NodeChildProcess.execFileSync("git", ["commit", "-m", message], { cwd: dir, stdio: "ignore" }); + return NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD"], { + cwd: dir, + encoding: "utf8", + }).trim(); + }; + + beforeAll(() => { + dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "pylon-marketing-ignore-")); + NodeFS.mkdirSync(NodePath.join(dir, "apps/marketing/src"), { recursive: true }); + NodeFS.mkdirSync(NodePath.join(dir, "packages/shared"), { recursive: true }); + NodeChildProcess.execFileSync("git", ["init", "-q", "-b", "pylon"], { + cwd: dir, + stdio: "ignore", + }); + NodeChildProcess.execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: dir, + stdio: "ignore", + }); + NodeChildProcess.execFileSync("git", ["config", "user.name", "Test"], { + cwd: dir, + stdio: "ignore", + }); + NodeFS.writeFileSync(NodePath.join(dir, "pnpm-lock.yaml"), "lockfile: 1\n"); + NodeFS.writeFileSync(NodePath.join(dir, "pnpm-workspace.yaml"), "packages: []\n"); + NodeFS.writeFileSync(NodePath.join(dir, "apps/marketing/src/index.astro"), "

one

\n"); + commit("base"); + + NodeFS.writeFileSync(NodePath.join(dir, "apps/marketing/src/index.astro"), "

two

\n"); + siteChanged = commit("touch the site"); + }); + + afterAll(() => { + NodeFS.rmSync(dir, { recursive: true, force: true }); + }); + + it("spends a build only for a product-branch push that moved the site", () => { + const base = NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD^"], { + cwd: dir, + encoding: "utf8", + }).trim(); + expect(run("pylon", base)).toBe("build"); + }); + + it("skips a product-branch push that left the site alone", () => { + expect(run("pylon", siteChanged)).toBe("skip"); + }); + + it("skips every other branch, even one that did move the site", () => { + const base = NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD^"], { + cwd: dir, + encoding: "utf8", + }).trim(); + expect(run("fix/unrelated", base)).toBe("skip"); + }); + + it("skips a brand new branch, which has no previously deployed commit", () => { + // The regression: with no VERCEL_GIT_PREVIOUS_SHA the diff falls back to + // HEAD^, so any commit touching a lockfile used to buy a build. + NodeFS.writeFileSync(NodePath.join(dir, "pnpm-lock.yaml"), "lockfile: 2\n"); + commit("bump the lockfile"); + expect(run("feat/brand-new", "")).toBe("skip"); + }); +}); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index 0a1f265189..aca0ea09a8 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -8,10 +8,21 @@ export const config: VercelConfig = { // those pushes touch the site: on a busy day that is over a hundred builds and // the account's build rate limit, which then fails unrelated pull requests and // stalls the hosted app's release deploy. Exit 0 skips the build, anything - // else builds. The diff is against the branch's last deployed commit; when - // that commit is not in Vercel's shallow clone git fails, which builds. + // else builds. + // + // Only the product branch can publish this site, so nothing else is worth a + // build. Diffing alone did not hold: a push to a NEW branch has no previously + // deployed commit, so the fallback compares just the last commit, and both + // lockfiles move on nearly every pull request in this repository. Gate on the + // branch first, then still skip product-branch pushes that leave the site + // untouched. The diff is against the branch's last deployed commit; when that + // commit is not in Vercel's shallow clone git fails, which builds. + // + // Releases do not need a build at all: src/lib/releases.ts resolves the latest + // stable and nightly release in the browser from the GitHub API, so cutting a + // release never requires redeploying this site. ignoreCommand: - 'git diff --quiet "${VERCEL_GIT_PREVIOUS_SHA:-HEAD^}" HEAD -- . ../../packages/shared ../../pnpm-lock.yaml ../../pnpm-workspace.yaml', + '[ "$VERCEL_GIT_COMMIT_REF" != "pylon" ] || git diff --quiet "${VERCEL_GIT_PREVIOUS_SHA:-HEAD^}" HEAD -- . ../../packages/shared ../../pnpm-lock.yaml ../../pnpm-workspace.yaml', redirects: [{ source: "/app", destination: "https://app.pylon-code.com", permanent: true }], // Deployments were held off while this site still carried T3's product copy // and legal pages. Those pages are gone — Pylon publishes no terms, privacy, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 469a527122..bc14351da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,7 +281,7 @@ importers: version: 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97) + version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -13427,7 +13427,7 @@ snapshots: '@expo/spawn-async': 1.8.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 browserslist: 4.28.2 chalk: 4.1.2 debug: 4.4.3 @@ -13958,7 +13958,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -13980,7 +13980,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@js-temporal/polyfill@0.5.1': dependencies: @@ -14344,17 +14344,17 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.3 optional: true @@ -15238,7 +15238,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97)': + '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53)': dependencies: '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -15366,7 +15366,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': @@ -16563,7 +16563,7 @@ snapshots: hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.7.0 - js-yaml: 4.2.0 + js-yaml: 4.3.2 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 @@ -16953,7 +16953,7 @@ snapshots: fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.2.0 + js-yaml: 4.3.2 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -17437,7 +17437,7 @@ snapshots: app-builder-lib: 26.15.6(patch_hash=0fc9a327982b3fdd5d9f4946e17bd8bf8d3d5c9b3753f77f0b18f7c0f0bdb4db)(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) builder-util: 26.15.3 fs-extra: 10.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.2 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color