diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 467104b52..b0c1d9c37 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -59,13 +59,23 @@ permissions: contents: read pull-requests: write # to post the preview URL comment id-token: write - # GitHub Deployments registration so Decap CMS's "Deploy Preview Links" - # spinner (https://decapcms.org/docs/deploy-preview-links/) can find - # the preview URL for each PR. Without this, the editor toolbar shows - # a perpetual "looking for preview" spinner — Decap polls - # /repos/.../deployments?ref=, never finds a deployment with - # state=success + a target_url, gives up. + # GitHub Deployments registration. admin/deploy-status-pill.js polls + # /repos/.../deployments?environment=preview-pr- to flip its + # in-flight spinner; the per-PR GH Environment row also gives the + # Environments UI something to show. (NB: this is NOT what Decap's + # own "Deploy Preview Links" feature reads — see `statuses: write`.) deployments: write + # Commit Statuses. Decap CMS's github backend implements + # `getDeployPreview` as GET /repos/.../commits//status + # and surfaces the first status whose `context` matches + # `backend.preview_context` (admin/config*.yml pins this to + # `deploy/preview`). With no such status the editor's + # deploy-preview-links button is stuck on "Check for Preview" + # forever even though the preview is live (decap-cms 3.12.2; the + # docs' "polls /deployments?ref=" describes the *Netlify* backend, + # not github). The "Set Decap deploy-preview commit status" step + # publishes that status; this scope lets it. + statuses: write jobs: # ──────────────────────────────────────────────────────────── @@ -301,6 +311,54 @@ jobs: core.notice(`Registered deployment ${deployment.id} → ${url}`); + - name: Set Decap deploy-preview commit status + # THIS is the status Decap CMS's editor actually reads. Its + # github backend implements getDeployPreview as + # GET /repos/.../commits//status + # then picks the first status whose `context` matches + # `backend.preview_context` (admin/config*.yml → `deploy/preview`; + # decap-cms 3.12.2 falls back to "context includes 'deploy'" + # when unset). Without a matching commit status the editor's + # deploy-preview button is pinned to "Check for Preview" + # forever even though the preview is already serving — the + # GitHub Deployment registered above is invisible to this code + # path (that polling-/deployments?ref= behaviour is the Netlify + # backend, not github). Decap then runs the status `target_url` + # through its preview_path builder, so the site root here + # becomes https://preview-pr.adamdaniel.ai/blog// in + # the editor's "View Preview" link. + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (2026-04-09) + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const pr = process.env.PR_NUMBER; + const sha = process.env.PR_HEAD_SHA; + const apex = process.env.APEX_DOMAIN; + const cfId = process.env.CLOUDFRONT_DISTRIBUTION_ID; + const bucket = process.env.PREVIEW_BUCKET; + const region = process.env.AWS_REGION; + + // Site root (no /blog//): Decap appends the entry's + // preview_path itself. Mirrors the deployment registration + // URL above so both surfaces agree. + const url = cfId + ? `https://preview-pr${pr}.${apex}/` + : `http://${bucket}.s3-website-${region}.amazonaws.com/pr-${pr}/`; + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state: 'success', + context: 'deploy/preview', + target_url: url, + description: `Preview live at ${url}`, + }); + + core.notice(`Set deploy/preview commit status on ${sha} → ${url}`); + - name: Register GitHub Deployment for the per-CMS-slug preview if: steps.cms_slug.outputs.slug != '' # Mirror of the registration above, scoped to the cms- diff --git a/AGENTS.md b/AGENTS.md index 06ed59b88..343d4db34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +113,8 @@ Editors get a WYSIWYG preview of the page they're editing without publishing. Th Two admin affordances live alongside the preview, both loaded (deferred) from all three `admin/index*.html` shells in the order **`live-url-derive.js` → `live-url-banner.js` → `native-preview-href.js` → `posts-list-enhance.js`** (`live-url-derive.js` exposes `window.LiveURL.compute()` and MUST precede its consumer; this order is locked by `cms-posts-list-enhance.spec.js` and `cms-permalink-contract.spec.js`): -- **`admin/live-url-banner.js`** — the "View page on site:" banner above the entry form. A past change (#184) deleted it, leaving the editor with no link to the post; issue #1042 restored it byte-for-byte. It renders one anchor (`data-testid="cms-live-url-banner-link"`) at the live URL `window.LiveURL.compute()` derives, or a placeholder when unpublished / no slug. `cms-live-url-banner-link` is in `native-preview-href.js`'s `EXCLUDE_IDS` so the native-anchor hide can't swallow it (the banner is in the form pane, not the toolbar, so it wouldn't match anyway — the exclusion is the original pre-#184 contract, kept defensively). -- **`admin/posts-list-enhance.js`** — turns Decap's bare Posts list into a dashboard (issue #1042). It **augments in place** — it never replaces Decap's `` cards, so every existing e2e selector keeps resolving — adding per-row status / published-link / last-edited / preview-PR columns. Remote data (last-edited via one batched GitHub GraphQL query, the production deployment, and open editorial PRs) is fetched in **three calls total regardless of post count**, cached in sessionStorage, and refreshed both on a ↻ button and whenever the user returns to the list from an entry. Auth reuses the operator's Decap token at `localStorage["decap-cms-user"].token` (same pattern as `deploy-status-pill.js`); with no token / on any API error it degrades to the local-only columns. It also CSS-hides only the "E2E Canary" Quick-add menu item (the `_e2e` collection is `create: true` and test-locked, so it can't be dropped from config; the `#/collections/e2e/new` route is untouched and `canary-content.test.js` stays green). See the **Automated-test fixtures** note under *Content model* for the default-hide behaviour. +- **`admin/live-url-banner.js`** — the "View page on site:" banner above the entry form. A past change (#184) deleted it, leaving the editor with no link to the post; issue #1042 restored it. It renders one anchor (`data-testid="cms-live-url-banner-link"`) at the live URL `window.LiveURL.compute()` derives, or a placeholder when unpublished / no slug. **Preview-aware origin:** a post edited through Decap's editorial workflow lives on a `cms//` PR branch and is NOT on production until that PR merges, so linking the prod URL 404s for the whole draft lifecycle. When the open entry has an open editorial-workflow PR the banner swaps the host to that PR's preview env (`preview-pr.adamdaniel.ai`), exactly the URL `posts-list-enhance.js` surfaces in the list; with no open PR it stays at the current origin. The open-PR map is read from `posts-list-enhance.js`'s shared sessionStorage cache when warm, else one `pulls?state=open` REST call (operator's Decap token, same auth as `deploy-status-pill.js`); no token / API error degrades to the current origin. `cms-live-url-banner-link` is in `native-preview-href.js`'s `EXCLUDE_IDS` so the native-anchor hide can't swallow it (the banner is in the form pane, not the toolbar, so it wouldn't match anyway — the exclusion is the original pre-#184 contract, kept defensively). +- **`admin/posts-list-enhance.js`** — turns Decap's bare Posts list into a dashboard (issue #1042). It **augments in place** — it never replaces Decap's `` cards, so every existing e2e selector keeps resolving — adding per-row status / published-link / last-edited columns plus, per state: **"view published changes"** (the merged PR's GitHub `/files` diff — shown only when the post is actually live on `main`), **"preview draft ↗"** (the open editorial-workflow PR's `preview-pr.adamdaniel.ai/blog//` env), and **"view draft changes"** (that open PR's `/files` diff). "view published changes" renders before "preview draft" when both are present; an unpublished draft (no merged PR on `main`) shows neither published-changes nor a `published ↗` link. Remote data — last-edited **and the PR that last commit was merged in** (`history` + `associatedPullRequests` in one batched GitHub GraphQL query), the production deployment, and open editorial PRs — is fetched in **three calls total regardless of post count**, cached in sessionStorage, and refreshed both on a ↻ button and whenever the user returns to the list from an entry. Auth reuses the operator's Decap token at `localStorage["decap-cms-user"].token` (same pattern as `deploy-status-pill.js`); with no token / on any API error it degrades to the local-only columns. It also CSS-hides only the "E2E Canary" Quick-add menu item (the `_e2e` collection is `create: true` and test-locked, so it can't be dropped from config; the `#/collections/e2e/new` route is untouched and `canary-content.test.js` stays green). See the **Automated-test fixtures** note under *Content model* for the default-hide behaviour. ### Embedding HTML / Widgets @@ -250,6 +250,11 @@ URL shown in comment: - With `PREVIEW_CLOUDFRONT_ID`: `https://preview-pr{N}.adamdaniel.ai/` - Without: `http://adamdaniel-ai-previews.s3-website-us-east-1.amazonaws.com/pr-{N}/` (HTTP fallback — Decap CMS won't work over this) +**Two independent "preview is ready" signals, read by different consumers — keep both:** + +- **GitHub Deployment** (`environment: preview-pr-`, `state: success`, `environment_url`). Polled by `admin/deploy-status-pill.js` to flip its in-flight spinner, and surfaces the per-PR row in the Environments UI. NOT read by Decap's editor. +- **`deploy/preview` commit status** (`createCommitStatus` on the PR head SHA, `state: success`, `target_url` = preview root; needs `statuses: write`). THIS is what Decap CMS's editor reads: decap-cms 3.12.2's github backend implements `getDeployPreview` as `GET /repos/.../commits//status` and surfaces the first status whose `context` matches `backend.preview_context` (pinned to `deploy/preview` in all three `admin/config*.yml`; `patch-preview-config.sh` doesn't touch it). Without this status the editor's deploy-preview button is stuck on **"Check for Preview"** forever even though the preview is live — the GitHub Deployment is invisible to this code path (the decapcms.org "polls `/deployments?ref=`" docs describe the *Netlify* backend, not github). Decap runs the status `target_url` through its `preview_path` builder, so the editor's "View Preview" link becomes `https://preview-pr.adamdaniel.ai/blog//`. The workflow↔config contract is locked by `e2e/cms-posts-list-enhance.spec.js` (which `select-specs.js` runs on `deploy-preview.yml` changes). An existing open PR only gets the status on its next push (the step runs per preview deploy; it isn't backfilled). + #### Job: `teardown-preview` (when action == `closed`) 1. AWS OIDC auth diff --git a/admin/config-local.yml b/admin/config-local.yml index eb3193d87..fc932ba5b 100644 --- a/admin/config-local.yml +++ b/admin/config-local.yml @@ -10,6 +10,12 @@ backend: name: github repo: Adam-S-Daniel/adamdaniel.ai branch: main + # Mirrors admin/config.yml — Decap's github-backend deploy-preview + # detection matches a `deploy/preview` commit status. Inert under + # local_backend (decap-server is simple-mode, no editorial workflow), + # kept so the three configs stay in lockstep and one static spec can + # assert the contract everywhere. + preview_context: deploy/preview site_url: http://localhost:4000 display_url: http://localhost:4000 diff --git a/admin/config-test.yml b/admin/config-test.yml index beac99848..f66546df1 100644 --- a/admin/config-test.yml +++ b/admin/config-test.yml @@ -20,6 +20,10 @@ backend: name: test-repo + # Mirrors admin/config.yml — inert for the in-browser test-repo + # backend (its getDeployPreview is a stub), kept so the three configs + # stay in lockstep and one static spec asserts the contract everywhere. + preview_context: deploy/preview site_url: http://localhost:4000 display_url: http://localhost:4000 diff --git a/admin/config.yml b/admin/config.yml index 8477c41e0..8df926444 100644 --- a/admin/config.yml +++ b/admin/config.yml @@ -9,6 +9,18 @@ backend: # OAuth proxy endpoint base_url: https://sq8d4876v8.execute-api.us-east-1.amazonaws.com auth_endpoint: prod/auth + # Decap's deploy-preview-links: the github backend reads + # GET /commits//status and surfaces the first status + # whose `context` equals this. .github/workflows/deploy-preview.yml's + # "Set Decap deploy-preview commit status" step publishes exactly + # `deploy/preview` once the per-PR preview is live. Without this the + # editor's button is stuck on "Check for Preview" forever (decap-cms + # 3.12.2: unset → loose "context includes 'deploy'" match; pinning it + # is the explicit, auditable half of the contract — see the static + # lock in e2e/cms-posts-list-enhance.spec.js). patch-preview-config.sh + # only rewrites site_url/display_url/branch, so this survives the + # per-PR preview rebuild unchanged. + preview_context: deploy/preview # Editorial workflow: every Save opens a PR on its own branch instead of # committing straight to main. The PR drives the draft → review → ready → diff --git a/admin/live-url-banner.js b/admin/live-url-banner.js index 63839648c..9a88ed4cc 100644 --- a/admin/live-url-banner.js +++ b/admin/live-url-banner.js @@ -8,7 +8,30 @@ * * URL computation lives in `admin/live-url-derive.js` so the native * "View Live" toolbar override (`admin/native-preview-href.js`) can - * compute the same URL without bundling. This file owns rendering only. + * compute the same URL without bundling. This file owns rendering, plus + * the production-vs-preview ORIGIN decision described next. + * + * ── Preview-aware origin ────────────────────────────────────────── + * `window.LiveURL.compute()` builds the URL from `window.location.origin` + * — on the production admin (https://adamdaniel.ai/admin/) that is the + * production host. But a post edited through Decap's editorial workflow + * lives on a `cms//` PR branch and is NOT on the + * production site until that PR merges, so the banner used to link the + * whole draft lifecycle at https://adamdaniel.ai/blog// — a hard + * 404. While the PR is open the post IS live at the per-PR preview + * environment, so when the open entry has an editorial-workflow PR this + * script swaps the URL's host for `preview-pr.adamdaniel.ai`, exactly + * the URL admin/posts-list-enhance.js surfaces in the Posts list. With + * no open PR the post is genuinely on production and the URL is left at + * the current origin. + * + * The open-PR map is read from admin/posts-list-enhance.js's shared + * sessionStorage cache when it's warm (an editor who reached the post + * via the list pays zero extra network); otherwise one `pulls?state= + * open` REST call (operator's Decap token, same auth pattern as + * deploy-status-pill.js) is made and cached. With no token / on any API + * error the banner degrades to the current-origin URL — never worse + * than the pre-fix behaviour. * * Stateful sources (read inside `live-url-derive.js`): * - `` — title text @@ -26,6 +49,17 @@ "use strict"; var BANNER_ID = "cms-live-url"; + var REPO = "Adam-S-Daniel/adamdaniel.ai"; + var REST = "https://api.github.com/repos/" + REPO; + // admin/posts-list-enhance.js writes { at, data:{ prBySlug, … } } + // under this key whenever the operator visits the Posts list. Sharing + // it means an editor who opened a post from the list incurs zero + // extra network here. + var PLE_CACHE_KEY = "cms-ple-remote-cache-v1"; + // Our own fallback cache for the deep-linked-straight-to-an-entry + // case where the shared cache is cold. + var PR_CACHE_KEY = "cms-live-url-pr-cache-v1"; + var CACHE_TTL_MS = 5 * 60 * 1000; // window.LiveURL is provided by admin/live-url-derive.js, which MUST be // loaded before this script (see admin/index*.html ordering). @@ -33,6 +67,146 @@ return window.LiveURL ? window.LiveURL.compute() : null; } + // ── open-PR lookup (preview-vs-prod origin) ────────────────────── + function getToken() { + try { + var raw = localStorage.getItem("decap-cms-user"); + if (!raw) return null; + var p = JSON.parse(raw); + return p && p.token ? p.token : null; + } catch (e) { + return null; + } + } + + function freshCache(key) { + try { + var raw = sessionStorage.getItem(key); + if (!raw) return null; + var c = JSON.parse(raw); + if (!c || Date.now() - c.at > CACHE_TTL_MS) return null; + return c; + } catch (e) { + return null; + } + } + + // file-slug of the entry currently open in the editor, from Decap's + // hash route `#/collections//entries/`. null on the + // "new entry" route (no PR can exist yet) and the list route. + function currentEntrySlug() { + var m = /#\/collections\/[^/]+\/entries\/([^?#]+)/.exec( + window.location.hash || "" + ); + if (!m) return null; + try { + return decodeURIComponent(m[1]); + } catch (e) { + return m[1]; + } + } + + function stripDate(slug) { + return String(slug || "").replace(/^\d{4}-\d{2}-\d{2}-/, ""); + } + + // slug → open-PR number. null until a lookup resolves. + var prBySlug = null; + var fetchInFlight = false; + + function adoptCache() { + if (prBySlug) return true; + var ple = freshCache(PLE_CACHE_KEY); + if (ple && ple.data && ple.data.prBySlug) { + // posts-list-enhance stores { number, url }; we only need number. + var m = {}; + Object.keys(ple.data.prBySlug).forEach(function (k) { + var v = ple.data.prBySlug[k]; + m[k] = v && typeof v === "object" ? v.number : v; + }); + prBySlug = m; + return true; + } + var own = freshCache(PR_CACHE_KEY); + if (own && own.data) { + prBySlug = own.data; + return true; + } + return false; + } + + function fetchOpenPrs() { + if (fetchInFlight || prBySlug) return; + var token = getToken(); + if (!token) return; // not signed in → degrade to current origin + fetchInFlight = true; + fetch(REST + "/pulls?state=open&per_page=100", { + headers: { + Authorization: "token " + token, + Accept: "application/vnd.github+json", + }, + }) + .then(function (r) { + return r.ok ? r.json() : null; + }) + .then(function (prs) { + var map = {}; + if (Array.isArray(prs)) { + prs.forEach(function (pr) { + var ref = (pr.head && pr.head.ref) || ""; + // Decap editorial-workflow branches: cms//. + // Key by the trailing slug (with optional date prefix), the + // same shape posts-list-enhance.js's fetchOpenPrBySlug uses. + var mm = /(?:^|\/)((?:\d{4}-\d{2}-\d{2}-)?[a-z0-9-]+)$/i.exec(ref); + if (/^cms\//i.test(ref) && mm) { + map[mm[1]] = pr.number; + } + }); + } + prBySlug = map; + try { + sessionStorage.setItem( + PR_CACHE_KEY, + JSON.stringify({ at: Date.now(), data: map }) + ); + } catch (e) { + /* sessionStorage full/disabled — in-memory only */ + } + }) + .catch(function () { + /* network / API error — leave prBySlug null, current origin */ + }) + .then(function () { + fetchInFlight = false; + scheduleRender(); + }); + } + + // Swap the production URL's host for the per-PR preview host when the + // open entry has an editorial-workflow PR; otherwise return the URL + // unchanged (the post is genuinely on the current origin). + function previewAwareURL(prodUrl) { + if (!prodUrl) return prodUrl; + var slug = currentEntrySlug(); + if (!slug) return prodUrl; // new / unsaved entry → no PR yet + adoptCache(); + if (!prBySlug) { + fetchOpenPrs(); // one-shot; scheduleRender() fires on resolve + return prodUrl; + } + var n = prBySlug[slug]; + if (n == null) n = prBySlug[stripDate(slug)]; + if (n == null) return prodUrl; // no open PR → genuinely live on prod + try { + var u = new URL(prodUrl); + u.protocol = "https:"; + u.hostname = "preview-pr" + n + ".adamdaniel.ai"; + return u.toString(); + } catch (e) { + return prodUrl; + } + } + function ensureBanner() { var existing = document.getElementById(BANNER_ID); if (existing) return existing; @@ -107,8 +281,10 @@ // accent color + underline so it still LOOKS like a link, but the // label and any whitespace between them are part of the same // clickable surface. data-testid is the contract e2e tests assert - // on. - var safeURL = String(data.url).replace(/[<>"']/g, function (c) { + // on. When the entry is an unmerged editorial-workflow draft the + // host is the per-PR preview env (it 404s on prod until merge). + var liveURL = previewAwareURL(data.url); + var safeURL = String(liveURL).replace(/[<>"']/g, function (c) { return { "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); var urlSpanHTML = diff --git a/admin/posts-list-enhance.js b/admin/posts-list-enhance.js index dce660a60..286695846 100644 --- a/admin/posts-list-enhance.js +++ b/admin/posts-list-enhance.js @@ -17,10 +17,18 @@ * - a one-click "published ↗" link to the live post URL (computed * from the slug, mirroring admin/live-url-derive.js's math); * - "edited " — the last commit that touched the post's file - * on `main` (GitHub GraphQL, one batched query for every visible - * post); - * - "preview-pr ↗" — the open editorial-workflow PR for the post, - * if any (GitHub REST, one `pulls` call); + * on `main`, plus the PR that commit was merged in (one batched + * GitHub GraphQL query, `history` + `associatedPullRequests`, for + * every visible post); + * - "view published changes" — the GitHub diff (Files-changed tab) + * of that merged PR. Shown only when the post is actually live on + * `main` (an unpublished draft has no merged PR, so it is omitted + * there); rendered BEFORE "preview draft" when both are present; + * - "preview draft ↗" — the per-PR preview environment for the + * post's open editorial-workflow PR, if any (GitHub REST, one + * `pulls` call) — `https://preview-pr.adamdaniel.ai/blog//`; + * - "view draft changes" — the GitHub diff (Files-changed tab) of + * that same open editorial-workflow PR; * - a control bar showing when the site itself last deployed * (GitHub REST, one `deployments` call) plus a manual ↻ Refresh. * @@ -255,7 +263,8 @@ idx + ': history(first: 1, path: ' + JSON.stringify(fp) + - ") { nodes { committedDate url } }" + ") { nodes { committedDate url" + + " associatedPullRequests(first: 1) { nodes { number url } } } }" ); }); var query = @@ -286,7 +295,21 @@ files.forEach(function (fp, idx) { var node = commit["f" + idx] && commit["f" + idx].nodes && commit["f" + idx].nodes[0]; - if (node) out[fp] = { date: node.committedDate, url: node.url }; + if (!node) return; + // associatedPullRequests(first:1) on the last main commit = + // the PR whose merge published the current live version of + // this file. GraphQL `PullRequest.url` is the html URL + // (https://github.com//pull/); appending `/files` + // in decorate() yields its Files-changed diff. + var prNode = + node.associatedPullRequests && + node.associatedPullRequests.nodes && + node.associatedPullRequests.nodes[0]; + out[fp] = { + date: node.committedDate, + url: node.url, + pr: prNode ? { number: prNode.number, url: prNode.url } : null, + }; }); } } catch (e) { @@ -519,6 +542,32 @@ "" ); } + // "view published changes" — the GitHub diff (Files-changed tab) + // of the PR whose merge put the current live version of this post + // on the production site (`le.pr`, the PR associated with the last + // commit to the file on `main`; see fetchLastEdited). Visibility: + // - unpublished draft (never merged → no `main` history → no + // `le.pr`): omitted — nothing has been published yet; + // - published, no open draft: shown; + // - published + open draft changes: shown, and — because this + // block precedes the open-PR block below — it renders BEFORE + // "preview draft". + // The merged-PR-on-`main` signal is deliberately used instead of + // the frontmatter `published` flag the summary encodes: a post can + // carry `published: true` while still sitting in an unmerged + // editorial PR (not on production at all — the same mismatch the + // live-url-banner preview fix addresses), so "does a merged PR for + // this file exist on main" is the accurate "is it live" test. + var publishedPr = le && le.pr; + if (publishedPr) { + bits.push( + 'view published changes' + ); + } + var pr = remote && remote.prBySlug && (remote.prBySlug[card.slug] || @@ -528,13 +577,14 @@ 'preview-pr' + esc(pr.number) + " ↗" + 'environment for the unmerged draft (open PR #' + + esc(pr.number) + ')">preview draft ↗' ); bits.push( - 'PR #' + - esc(pr.number) + "" + 'view draft changes' ); } var next = bits.join(""); diff --git a/e2e/cms-posts-list-enhance.spec.js b/e2e/cms-posts-list-enhance.spec.js index 9f3eb92a1..d853ba140 100644 --- a/e2e/cms-posts-list-enhance.spec.js +++ b/e2e/cms-posts-list-enhance.spec.js @@ -174,3 +174,111 @@ test.describe("Issue #1042 — admin posts UI", () => { } }); }); + +// ── Preview/PR link relabel + view-published-changes + the +// "Check for Preview" root-cause fix ──────────────────────────── +// +// Locks the follow-up admin changes so a future edit can't silently +// regress them (pure-fs, same lane as the suite above): +// +// - posts-list-enhance.js renames the bare "preview-pr" / +// "PR #" links to "preview draft" / "view draft changes", +// points the change links at the GitHub Files-changed diff, and +// adds "view published changes" (the merged PR's diff) gated on a +// post actually being live on main and ordered before the draft +// links; +// - live-url-banner.js swaps the host to the per-PR preview env for +// an unmerged editorial-workflow draft (the reported prod-404); +// - deploy-preview.yml publishes a `deploy/preview` commit status +// and admin/config*.yml pin `backend.preview_context` to it — +// together the actual fix for the editor's perpetual +// "Check for Preview" (decap-cms 3.12.2's github backend reads a +// commit status, never the GitHub Deployment we already register). +test.describe("Admin preview/PR links + Check-for-Preview fix", () => { + const PLE = path.join(ADMIN, "posts-list-enhance.js"); + const BANNER = path.join(ADMIN, "live-url-banner.js"); + const WF = path.join(REPO_ROOT, ".github", "workflows", "deploy-preview.yml"); + + test("posts-list-enhance.js: relabelled links, diff URLs, view-published-changes", () => { + const src = read(PLE); + // New human labels. + expect(src).toContain("preview draft"); + expect(src).toContain("view draft changes"); + expect(src).toContain("view published changes"); + // Old bare labels gone (distinctive source fragments) so a + // regression can't silently restore them. + expect(src, 'the bare "preview-pr" link label was renamed').not.toContain(">preview-pr"); + expect(src, 'the bare "PR #" link label was renamed').not.toContain(">PR #"); + // Both change links resolve to the GitHub Files-changed diff. + expect(src, "draft-changes link must be the PR /files diff").toMatch( + /esc\(pr\.url\)[\s\S]{0,40}\/files/, + ); + expect(src, "published-changes link must be the PR /files diff").toMatch( + /esc\(publishedPr\.url\)[\s\S]{0,40}\/files/, + ); + // preview-draft link still targets the per-PR preview host. + expect(src).toContain("https://preview-pr"); + // Published PR derived from the last main commit's PR — one + // batched GraphQL query, not a per-row call. + expect(src).toContain("associatedPullRequests"); + expect(src).toMatch(/var publishedPr = le && le\.pr/); + // Order: "view published changes" renders BEFORE "preview draft" + // (bits are pushed in source order). + expect( + src.indexOf("view published changes"), + '"view published changes" must be pushed before "preview draft"', + ).toBeLessThan(src.indexOf("preview draft")); + // Gated on a merged PR existing — an unpublished draft (no main + // history → no le.pr) must not show "view published changes". + const block = src.slice( + src.indexOf("var publishedPr"), + src.indexOf("var pr ="), + ); + expect(block).toMatch(/if \(publishedPr\)/); + }); + + test("live-url-banner.js: preview-aware origin for unmerged drafts", () => { + const src = read(BANNER); + // Locked banner contract still holds. + expect(src).toContain('id="cms-live-url-banner-link"'); + expect(src).toContain('data-testid="cms-live-url-banner-link"'); + expect(src).toMatch(/window\.LiveURL/); + // Swaps host to the per-PR preview env when the open entry has an + // editorial-workflow PR. + expect(src).toMatch(/preview-pr["']\s*\+\s*n\s*\+\s*["']\.adamdaniel\.ai/); + expect(src).toContain("/pulls?state=open"); + // Same operator-token auth as the rest of the admin (no new + // surface); reuses posts-list-enhance's cache when warm. + expect(src).toContain('localStorage.getItem("decap-cms-user")'); + expect(src).toContain("cms-ple-remote-cache-v1"); + // Degrade-safe: the published===false placeholder is untouched, so + // cms-link-crawler's known-bug allowlist still holds. + expect(src).toContain("Not yet published."); + }); + + test("deploy-preview.yml: publishes the deploy/preview commit status", () => { + const wf = read(WF); + expect( + wf, + "createCommitStatus is the github-backend deploy-preview signal Decap reads", + ).toContain("createCommitStatus"); + expect(wf).toMatch(/context:\s*['"]deploy\/preview['"]/); + expect(wf).toMatch(/state:\s*['"]success['"]/); + // Needs the statuses:write scope to post it. + expect(wf, "statuses:write scope is required to set a commit status").toMatch( + /^\s*statuses:\s*write\s*$/m, + ); + // Set on the PR head SHA (the ref Decap's getStatuses queries). + expect(wf).toMatch(/PR_HEAD_SHA:\s*\$\{\{\s*github\.event\.pull_request\.head\.sha/); + }); + + for (const cfg of CONFIGS) { + const rel = path.relative(REPO_ROOT, cfg); + test(`${rel}: backend.preview_context pins deploy/preview`, () => { + expect( + read(cfg), + `${rel} must pin backend.preview_context so Decap matches the deploy-preview.yml commit status`, + ).toMatch(/^\s*preview_context:\s*deploy\/preview\s*$/m); + }); + } +}); diff --git a/e2e/select-specs.js b/e2e/select-specs.js index 94c70cf54..f4b6843e4 100644 --- a/e2e/select-specs.js +++ b/e2e/select-specs.js @@ -138,11 +138,15 @@ const SPEC_RULES = { // Issue #1042 admin posts-UI invariants — fast, pure-fs, no browser. // Locks the restored live-URL banner wiring + posts-list-enhance.js // augment/hide contract + the INVALID-DATE / Automated-tests / - // test_fixture config and canary-marker invariants. Runs on any - // admin/ change and on canary _posts marker edits. + // test_fixture config and canary-marker invariants, PLUS the + // preview/PR link relabel + "Check for Preview" commit-status fix + // (deploy-preview.yml ↔ admin/config*.yml preview_context contract). + // Runs on any admin/ change, canary _posts marker edits, and the + // deploy-preview workflow whose commit status it locks. "e2e/cms-posts-list-enhance.spec.js": [ /^admin\//, /^_posts\//, + /^\.github\/workflows\/deploy-preview\.yml$/, ], // Real-network publish-loop specs. Heavy and slow; run only when // something contributor-relevant changed.