diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml
index 8990e03ae..f16f038fe 100644
--- a/.github/workflows/deploy-preview.yml
+++ b/.github/workflows/deploy-preview.yml
@@ -99,34 +99,32 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- - name: Compute CMS slug (cms/
/ → -)
+ - name: Compute CMS slug (cms// → bounded -)
id: cms_slug
# Decap-opened editorial PRs land on `cms//`
- # head refs. Translate that to a DNS-safe slug so we can publish
- # an additional `preview-cms-.adamdaniel.ai` preview that
- # survives Decap closing & re-opening a PR for the same entry
- # (the pr- URL doesn't — N changes; the cms- URL
- # doesn't — the entry path is stable). The CloudFront router
- # under infrastructure/bootstrap/template.yaml maps the host to
- # the matching S3 prefix; see docs/preview-pr-ruleset-spike.md.
+ # head refs. scripts/cms-preview-slug.sh translates that to a
+ # DNS-safe, length-bounded slug so we can publish an additional
+ # `preview-cms-.adamdaniel.ai` preview that survives Decap
+ # closing & re-opening a PR for the same entry (the pr- URL
+ # doesn't — N changes; the cms- URL doesn't). The script
+ # caps the slug so `preview-cms-` stays within the 63-octet
+ # DNS-label limit; without that bound, a long entry title makes
+ # the host unresolvable and fails the per-slug deployment
+ # registration below. The CloudFront router under
+ # infrastructure/bootstrap/template.yaml maps the host to the
+ # matching S3 prefix; see docs/preview-pr-ruleset-spike.md.
#
# Output `slug=` is empty for non-cms branches; downstream steps
# gate on `slug != ''` to skip the second sync/registration on
- # regular code PRs.
+ # regular code PRs. The teardown job calls the SAME script so the
+ # two always agree on which `cms-/` prefix to clean up.
run: |
set -euo pipefail
- BRANCH='${{ github.event.pull_request.head.ref }}'
- if [[ "$BRANCH" == cms/* ]]; then
- # Replace `/` with `-`. Lowercase is enforced by Decap's
- # slug template, not by us — the CloudFront router's regex
- # only matches `[a-z0-9-]`, so any uppercase surfaces as a
- # 404 (visibly broken, by design — fail loudly rather than
- # silently route to the wrong S3 prefix).
- SLUG=$(printf '%s' "$BRANCH" | sed -E 's|^cms/||; s|/|-|g')
- echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
+ SLUG=$(./scripts/cms-preview-slug.sh '${{ github.event.pull_request.head.ref }}')
+ echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
+ if [[ -n "$SLUG" ]]; then
echo "Computed CMS slug: ${SLUG}"
else
- echo "slug=" >> "$GITHUB_OUTPUT"
echo "Not a cms/* branch — skipping per-slug preview"
fi
@@ -516,20 +514,25 @@ jobs:
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.PREVIEW_CLOUDFRONT_ID }}
steps:
- - name: Compute CMS slug (cms// → -)
+ - name: Checkout
+ # Needed so scripts/cms-preview-slug.sh is on disk. Pin to the PR
+ # head SHA — the exact script version the deploy job used — so the
+ # teardown computes the identical bounded slug and removes the right
+ # `cms-/` prefix even if this PR itself changed the script.
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-01-09)
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Compute CMS slug (cms// → bounded -)
id: cms_slug
- # Mirror the deploy-side computation so we know which (if any)
- # alias S3 prefix to clean up in addition to pr-. Both prefixes
- # were syncd at deploy time; both need rm + invalidation now.
+ # Mirror the deploy-side computation via the SAME shared script so
+ # we agree on which (if any) alias S3 prefix to clean up in addition
+ # to pr-. Both prefixes were syncd at deploy time; both need rm +
+ # invalidation now.
run: |
set -euo pipefail
- BRANCH='${{ github.event.pull_request.head.ref }}'
- if [[ "$BRANCH" == cms/* ]]; then
- SLUG=$(printf '%s' "$BRANCH" | sed -E 's|^cms/||; s|/|-|g')
- echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
- else
- echo "slug=" >> "$GITHUB_OUTPUT"
- fi
+ SLUG=$(./scripts/cms-preview-slug.sh '${{ github.event.pull_request.head.ref }}')
+ echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 (2026-05-05)
diff --git a/e2e/deploy-preview-cms-slug.test.js b/e2e/deploy-preview-cms-slug.test.js
index e4de2a40e..6cd1ab91e 100644
--- a/e2e/deploy-preview-cms-slug.test.js
+++ b/e2e/deploy-preview-cms-slug.test.js
@@ -1,5 +1,6 @@
const fs = require("node:fs");
const path = require("node:path");
+const { execFileSync } = require("node:child_process");
const { test, expect } = require("./base");
// Locks in the per-CMS-slug preview alias structure of
@@ -7,9 +8,10 @@ const { test, expect } = require("./base");
// docs/preview-pr-ruleset-spike.md. The structural invariant is:
//
// 1. Both `deploy-preview` and `teardown-preview` derive `cms_slug`
-// from `head_ref` using the SAME sed expression (otherwise a
-// cleanup mismatch would orphan S3 files when the slug shape
-// drifts).
+// from `head_ref` via the SAME shared scripts/cms-preview-slug.sh
+// (otherwise a cleanup mismatch would orphan S3 files when the slug
+// shape drifts). Because teardown has no build step of its own, it
+// gains a Checkout so the script is on disk.
// 2. The deploy job syncs the alias prefix `cms-/` and registers
// a `preview-cms-` GitHub Deployment.
// 3. The teardown job removes the alias prefix.
@@ -18,8 +20,8 @@ const { test, expect } = require("./base");
// 5. The PR-comment step surfaces the slug-derived URL as an
// additional row when applicable.
//
-// All structural invariants are pure-text greps against the workflow
-// file — same approach as visual-regression-skip-review.test.js.
+// The workflow-structure invariants are pure-text greps against the
+// workflow file; the slug-derivation invariants run the real script.
const WORKFLOW = path.join(
__dirname,
@@ -29,36 +31,42 @@ const WORKFLOW = path.join(
"deploy-preview.yml",
);
-// Canonical slug-derivation sed expression. Both deploy-preview and
-// teardown-preview must use this exact form so they agree on what
-// `cms-/` prefix needs cleanup at PR-close.
-const SLUG_SED = "'s|^cms/||; s|/|-|g'";
+const SLUG_SCRIPT = path.join(__dirname, "..", "scripts", "cms-preview-slug.sh");
function readWorkflow() {
return fs.readFileSync(WORKFLOW, "utf8");
}
+// Run the real shared script the workflow calls. Invoked via `bash` so the
+// test doesn't depend on the file's executable bit being preserved.
+function slug(branch) {
+ return execFileSync("bash", [SLUG_SCRIPT, branch], { encoding: "utf8" });
+}
+
test.describe("deploy-preview workflow: per-CMS-slug preview alias", () => {
- test("both jobs derive cms_slug with the same sed expression", () => {
+ test("both jobs derive cms_slug via the shared cms-preview-slug.sh", () => {
const yml = readWorkflow();
- // Count occurrences of the canonical sed expression. Should be
- // exactly two: one in deploy-preview, one in teardown-preview.
- const escaped = SLUG_SED.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const matches = yml.match(new RegExp(escaped, "g")) || [];
+ // Exactly two call sites — one in deploy-preview, one in
+ // teardown-preview — so they can never disagree on the slug shape.
+ const matches =
+ yml.match(/\.\/scripts\/cms-preview-slug\.sh/g) || [];
expect(
matches.length,
- `expected exactly two slug-sed expressions (deploy + teardown); found ${matches.length}`,
+ `expected exactly two cms-preview-slug.sh call sites (deploy + teardown); found ${matches.length}`,
).toBe(2);
});
- test("both jobs gate on `BRANCH == cms/*`", () => {
+ test("both jobs check out the repo so the shared script is on disk", () => {
const yml = readWorkflow();
- // The bash test for `cms/*` should appear in both jobs. Allow any
- // amount of whitespace and either single or double quotes around
- // `cms/*` to accommodate future refactors that don't change the
- // semantics.
- const matches = yml.match(/\[\[\s*"\$BRANCH"\s*==\s*cms\/\*\s*\]\]/g) || [];
- expect(matches.length, "expected `[[ \"$BRANCH\" == cms/* ]]` in both deploy + teardown").toBe(2);
+ // The deploy job always checked out; teardown now must too (it has no
+ // build step that would otherwise put scripts/ on disk). Two checkouts
+ // total guards against a future edit dropping the teardown one and
+ // breaking the slug computation at PR-close.
+ const matches = yml.match(/uses: actions\/checkout@/g) || [];
+ expect(
+ matches.length,
+ `expected a Checkout in both deploy + teardown; found ${matches.length}`,
+ ).toBe(2);
});
test("deploy syncs the cms- S3 prefix", () => {
@@ -125,44 +133,70 @@ test.describe("deploy-preview workflow: per-CMS-slug preview alias", () => {
});
});
-// ── Slug-derivation sanity (the bash logic, ported to JS for the test) ──
+// ── Slug-derivation: run the real scripts/cms-preview-slug.sh ───────────
//
-// `printf '%s' "cms/posts/foo-bar" | sed -E 's|^cms/||; s|/|-|g'` → "posts-foo-bar"
-// We re-implement the same transformation here to assert specific
-// inputs produce the expected slugs. If the workflow's sed expression
-// changes, this re-implementation needs to track it (and the
-// `SLUG_SED` constant above keeps them locked).
-function deriveSlug(branch) {
- // Strip leading `cms/` then replace `/` with `-`.
- const stripped = branch.replace(/^cms\//, "");
- return stripped.replace(/\//g, "-");
-}
-
-test.describe("deploy-preview workflow: slug-derivation cases", () => {
- test("posts/foo-bar → posts-foo-bar", () => {
- expect(deriveSlug("cms/posts/foo-bar")).toBe("posts-foo-bar");
+// `preview-cms-` (12) + slug must stay within the 63-octet DNS-label limit,
+// so slug <= 51. Short slugs pass through unchanged; over-long ones are
+// truncated and suffixed with a content hash so the alias host is always
+// valid, deterministic, and collision-resistant.
+
+const MAX_SLUG = 51;
+const MAX_HOST_LABEL = 63; // "preview-cms-" (12) + slug
+
+test.describe("cms-preview-slug.sh", () => {
+ test("non-cms branch yields an empty slug (no alias)", () => {
+ expect(slug("claude/some-feature")).toBe("");
+ expect(slug("feat/foo")).toBe("");
+ expect(slug("")).toBe("");
});
- test("date-prefixed post slug rounds-trips", () => {
- expect(deriveSlug("cms/posts/2099-01-01-foo-bar")).toBe(
+ test("short slugs pass through unchanged", () => {
+ expect(slug("cms/posts/foo-bar")).toBe("posts-foo-bar");
+ expect(slug("cms/posts/2099-01-01-foo-bar")).toBe(
"posts-2099-01-01-foo-bar",
);
+ expect(slug("cms/pages/about")).toBe("pages-about");
+ expect(slug("cms/projects/category/item")).toBe("projects-category-item");
});
- test("pages/about → pages-about", () => {
- expect(deriveSlug("cms/pages/about")).toBe("pages-about");
+ test("a 51-char slug is the boundary and stays unchanged", () => {
+ // "posts-" (6) + 45 chars = 51.
+ const branch = `cms/posts/${"a".repeat(45)}`;
+ const out = slug(branch);
+ expect(out).toBe(`posts-${"a".repeat(45)}`);
+ expect(out.length).toBe(MAX_SLUG);
});
- test("non-cms branch flattens slashes too — but the workflow's `cms/*` gate prevents this from being reached in practice", () => {
- // Documents the raw transformation: the sed pipeline doesn't
- // discriminate; only the surrounding `[[ "$BRANCH" == cms/* ]]`
- // bash test gates whether the slug is ever produced.
- expect(deriveSlug("feat/some-feature")).toBe("feat-some-feature");
+ test("a 52-char slug overflows and is bounded", () => {
+ const branch = `cms/posts/${"a".repeat(46)}`; // raw slug = 52
+ const out = slug(branch);
+ expect(out.length).toBeLessThanOrEqual(MAX_SLUG);
+ expect(out).not.toBe(`posts-${"a".repeat(46)}`);
});
- test("nested slug paths flatten to dashes", () => {
- expect(deriveSlug("cms/projects/category/item")).toBe(
- "projects-category-item",
- );
+ test("the real PR-941 branch produces a valid bounded host", () => {
+ const branch =
+ "cms/posts/2026-05-17-safely-keep-your-agent-iterating-autonomously-with-gitleaks-and-pr-comments";
+ const out = slug(branch);
+ expect(out.length).toBeLessThanOrEqual(MAX_SLUG);
+ expect(`preview-cms-${out}`.length).toBeLessThanOrEqual(MAX_HOST_LABEL);
+ // Lowercase DNS-label charset, no leading/trailing hyphen.
+ expect(out).toMatch(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/);
+ });
+
+ test("over-long slugs are deterministic (stable across draft cycles)", () => {
+ const branch =
+ "cms/posts/2026-05-17-safely-keep-your-agent-iterating-autonomously-with-gitleaks-and-pr-comments";
+ expect(slug(branch)).toBe(slug(branch));
+ });
+
+ test("over-long slugs sharing a 42-char prefix stay distinct (hash suffix)", () => {
+ // Both flatten to `posts-` + a run of 'a's long enough that their
+ // first 42 chars are identical — truncation alone would collide; the
+ // content-hash suffix keeps them apart.
+ const a = slug(`cms/posts/${"a".repeat(60)}`);
+ const b = slug(`cms/posts/${"a".repeat(59)}b`);
+ expect(a.slice(0, 42)).toBe(b.slice(0, 42));
+ expect(a).not.toBe(b);
});
});
diff --git a/e2e/select-specs.js b/e2e/select-specs.js
index 434b772ba..43b111591 100644
--- a/e2e/select-specs.js
+++ b/e2e/select-specs.js
@@ -218,6 +218,13 @@ const SPEC_RULES = {
// the direct-change rule, but listing the impl path keeps the
// mapping explicit and survives a future rename of the test).
"e2e/run-cms-loop.test.js": [/^e2e\/run-cms-loop\.js$/],
+ // Structural + slug-derivation invariants for the per-CMS-slug preview
+ // alias. Pure-node; selects when the shared slug script or the
+ // deploy-preview workflow it asserts against changes.
+ "e2e/deploy-preview-cms-slug.test.js": [
+ /^scripts\/cms-preview-slug\.sh$/,
+ /^\.github\/workflows\/deploy-preview\.yml$/,
+ ],
// Prod-mutation playground (G4). Skips itself unless CMS_E2E_PAT is
// set, so PR runs are safe — the spec just emits a skip and exits.
// Selecting it on its own infra changes here keeps the PR matrix
diff --git a/scripts/cms-preview-slug.sh b/scripts/cms-preview-slug.sh
new file mode 100755
index 000000000..a5e2f8214
--- /dev/null
+++ b/scripts/cms-preview-slug.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+#
+# Derive a DNS-safe, length-bounded preview slug from a Decap CMS head ref.
+#
+# Decap editorial PRs use `cms//` head refs. The preview
+# pipeline (.github/workflows/deploy-preview.yml) publishes a second,
+# draft-cycle-stable alias at `preview-cms-.adamdaniel.ai`, syncing the
+# build to the S3 prefix `cms-/`. The CloudFront preview-router
+# (infrastructure/bootstrap/template.yaml) maps that host back to the prefix
+# purely by string match `^preview-cms-([a-z0-9-]+)\.adamdaniel\.ai$`, so the
+# ONLY constraints on are that `preview-cms-` be a valid DNS
+# label: lowercase [a-z0-9-] and <= 63 octets. `preview-cms-` is 12 chars, so
+# must be <= 51 chars — otherwise the host's first label exceeds the
+# 63-octet DNS limit, can't resolve, and the Deployments-API registration
+# that embeds it fails the deploy-preview job (the failure this guards).
+#
+# Output: the bounded slug on stdout (no trailing newline), or empty for a
+# non-cms/* branch. Both the deploy and teardown jobs call this so they always
+# agree on which `cms-/` prefix to publish and later clean up — a drift
+# between them would orphan S3 objects at PR close.
+#
+# Usage: cms-preview-slug.sh
+set -euo pipefail
+
+BRANCH="${1-}"
+
+# Regular code PRs key their preview off the PR number alone — no alias.
+# Emit nothing; callers gate the downstream steps on `slug != ''`.
+if [[ "$BRANCH" != cms/* ]]; then
+ exit 0
+fi
+
+# `cms//` -> `-`. Lowercase is assumed (Decap's slug
+# template enforces it); the router regex only matches [a-z0-9-], so stray
+# uppercase 404s loudly rather than mis-routing — we deliberately don't
+# lowercase here so that contract stays visible.
+slug=$(printf '%s' "$BRANCH" | sed -E 's|^cms/||; s|/|-|g')
+
+# Bound to a valid DNS label. `preview-cms-` (12) + slug must be <= 63, so
+# slug <= 51. On overflow keep a readable 42-char prefix and append a short
+# content hash: deterministic (same entry -> same host across Decap's
+# close/reopen draft cycles) and collision-resistant (two long titles sharing
+# a 42-char prefix still differ). 42 + 1 separator + 8 hex = 51.
+MAX_SLUG=51
+if (( ${#slug} > MAX_SLUG )); then
+ hash=$(printf '%s' "$slug" | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-8)
+ prefix=$(printf '%s' "${slug:0:42}" | sed -E 's/-+$//')
+ slug="${prefix}-${hash}"
+fi
+
+printf '%s' "$slug"