Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 33 additions & 30 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,34 +99,32 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Compute CMS slug (cms/<col>/<entry> → <col>-<entry>)
- name: Compute CMS slug (cms/<col>/<entry> → bounded <col>-<entry>)
id: cms_slug
# Decap-opened editorial PRs land on `cms/<collection>/<slug>`
# head refs. Translate that to a DNS-safe slug so we can publish
# an additional `preview-cms-<slug>.adamdaniel.ai` preview that
# survives Decap closing & re-opening a PR for the same entry
# (the pr-<N> URL doesn't — N changes; the cms-<slug> 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-<slug>.adamdaniel.ai` preview that survives Decap
# closing & re-opening a PR for the same entry (the pr-<N> URL
# doesn't — N changes; the cms-<slug> URL doesn't). The script
# caps the slug so `preview-cms-<slug>` 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-<slug>/` 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

Expand Down Expand Up @@ -516,20 +514,25 @@ jobs:
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.PREVIEW_CLOUDFRONT_ID }}

steps:
- name: Compute CMS slug (cms/<col>/<entry> → <col>-<entry>)
- 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-<slug>/` 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/<col>/<entry> → bounded <col>-<entry>)
id: cms_slug
# Mirror the deploy-side computation so we know which (if any)
# alias S3 prefix to clean up in addition to pr-<N>. 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-<N>. 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)
Expand Down
134 changes: 84 additions & 50 deletions e2e/deploy-preview-cms-slug.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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
// .github/workflows/deploy-preview.yml — added per the spike at
// 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-<slug>/` and registers
// a `preview-cms-<slug>` GitHub Deployment.
// 3. The teardown job removes the alias prefix.
Expand All @@ -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,
Expand All @@ -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-<slug>/` 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-<slug> S3 prefix", () => {
Expand Down Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions e2e/select-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions scripts/cms-preview-slug.sh
Original file line number Diff line number Diff line change
@@ -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/<collection>/<entry>` head refs. The preview
# pipeline (.github/workflows/deploy-preview.yml) publishes a second,
# draft-cycle-stable alias at `preview-cms-<slug>.adamdaniel.ai`, syncing the
# build to the S3 prefix `cms-<slug>/`. 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 <slug> are that `preview-cms-<slug>` be a valid DNS
# label: lowercase [a-z0-9-] and <= 63 octets. `preview-cms-` is 12 chars, so
# <slug> 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-<slug>/` prefix to publish and later clean up — a drift
# between them would orphan S3 objects at PR close.
#
# Usage: cms-preview-slug.sh <branch-ref>
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/<col>/<entry>` -> `<col>-<entry>`. 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"
Loading