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