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"