Skip to content

Commit 03c468d

Browse files
authored
Merge pull request #108 from stack127labs/fix/refresh-after-docs-commit
Keep content refresh working after docs-only commits
2 parents d44599f + eff2bc1 commit 03c468d

5 files changed

Lines changed: 67 additions & 5 deletions

File tree

‎.github/workflows/stack127-release-e2e.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ jobs:
125125
if: steps.migrations.outputs.run == 'true' || (steps.migrations.outputs.functions == 'true' && vars.STACK127_CACHE_WORKER_RUNTIME != 'node')
126126
uses: supabase/setup-cli@v1
127127
with:
128-
version: latest
128+
# Version 3 rejects [inbucket] in supabase/config.toml. Change the two together.
129+
version: 2.122.1
129130

130131
- name: Apply database migrations
131132
if: steps.migrations.outputs.run == 'true'

‎scripts/stack127/frontend/content-queue.mjs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ const activeStatuses = ["queued", "in_progress", "waiting", "requested", "pendin
1111
const eventKey = event => `${event.entity_type}:${event.slug}`;
1212
const shaPattern = /^[a-f0-9]{40}$/;
1313
const pathPattern = /^\/[a-z0-9/_-]*(?:\.(?:xml|txt|json))?$/;
14+
// Keep equal to paths-ignore in stack127-release-e2e.yml. A commit touching only
15+
// these never deploys, so the backend stays on an older SHA with the same code.
16+
export const releaseIgnoredPaths = ["**/*.md", "content_docs/**", "skills/**", "stack127_docs/**", "stack127_records/**", "tests/**"];
17+
const releaseIgnoredDirectories = releaseIgnoredPaths.filter(path => !path.startsWith("**")).map(path => path.slice(0, -2));
18+
const releaseIgnores = path => path.endsWith(".md") || releaseIgnoredDirectories.some(directory => path.startsWith(directory));
19+
// GitHub lists at most this many files, so a list this long may be incomplete.
20+
const compareFileLimit = 300;
1421

1522
async function githubRequest(repo, token, pathname, init = {}, fetchImplementation = fetch) {
1623
if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(repo) || !token) throw new Error("A repository and a scoped GitHub token are required.");
@@ -52,12 +59,23 @@ export async function checkCurrentMain({ sha, repo = "stack127labs/stack127", to
5259
if (main.object?.sha !== sha) throw new Error("The frontend checkout is no longer current main.");
5360
}
5461

62+
async function backendRunsSameCode({ liveSha, sha, repo, token, fetchImplementation }) {
63+
if (liveSha === sha) return true;
64+
if (!shaPattern.test(liveSha ?? "")) return false;
65+
const compare = await (await githubRequest(repo, token, `/compare/${liveSha}...${sha}`, {}, fetchImplementation)).json();
66+
const files = compare.files;
67+
if (compare.status !== "ahead" || !Array.isArray(files) || files.length === 0 || files.length >= compareFileLimit) return false;
68+
return files.every(({ filename, previous_filename: previous }) => releaseIgnores(filename) && (previous === undefined || releaseIgnores(previous)));
69+
}
70+
5571
export async function checkCurrentRelease({ sha, site = "https://stack127.com", repo = "stack127labs/stack127", token, fetchImplementation = fetch }) {
5672
await checkCurrentMain({ sha, repo, token, fetchImplementation });
5773
const response = await siteRequest(site, "/api/health?scope=deploy", {}, fetchImplementation);
5874
if (response.status !== 200) throw new Error(`Backend health failed: HTTP ${response.status}`);
5975
const health = await response.json();
60-
if (health.ok !== true || health.build?.sha !== sha) throw new Error("The live backend SHA does not match current main.");
76+
if (health.ok !== true || !await backendRunsSameCode({ liveSha: health.build?.sha, sha, repo, token, fetchImplementation })) {
77+
throw new Error("The live backend SHA does not match current main.");
78+
}
6179
}
6280

6381
// Dispatch checks are observations, not successful content deployments. The

‎skills/stack127/stack127-release-e2e/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Page content in MDX went live with the merge. The publisher writes the database
6363
3. Run `dispatch` with `--environment production`, `--confirm stack127-production`, and the full current `origin/main` SHA as `--release-sha`.
6464
4. Watch `.github/workflows/stack127-publish-pages.yml` until it finishes. It waits up to 10 minutes for the live SHA, joins the production concurrency group, applies the plan in one transaction, checks the public pages and sitemap, and records the result on the GitHub deployment. In Cloudflare mode it also rebuilds and deploys the static frontend between the commit and the public check, which adds several minutes.
6565
5. If it reports that the batch committed but the public check failed, the data is saved. Run `verify` with the same plan and `--release-sha`. `apply` is safe to repeat.
66-
6. If `main` moves before the workflow runs, it refuses the stale SHA. Dispatch again with the new `origin/main` SHA once that release is live.
66+
6. If `main` moves before the workflow runs, it refuses the stale SHA. Dispatch again with the new `origin/main` SHA once that release is live. A docs-only commit moves `main` without a release, so run `stack127-release-e2e.yml` by hand first; publication still needs the exact live SHA.
6767
7. Keep each plan file until the pages are checked. It holds the before values `restore` needs.
6868

6969
Use the single-purpose tool-page commands in production only for a small fix to one live page.

‎stack127_docs/cloudflare.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ A database edit still queues an event. The `cache-worker` doesn't refresh pages.
404404

405405
The workflow joins the `stack127-production` concurrency group, then:
406406

407-
1. Checks out current `main` and checks that the live backend health SHA matches it.
407+
1. Checks out current `main` and checks that the live backend runs the same code. The health SHA must match `main`, or be behind it only by files the release ignores (the `paths-ignore` list in `stack127-release-e2e.yml`).
408408
2. Captures the queued event versions and their source fingerprints in one database snapshot.
409409
3. Builds the full static export, previews it, checks `main` and the backend again, and deploys it.
410410
4. Checks the public pages byte for byte against the export. Removed pages must return 404.

‎tests/stack127/frontend-content-queue.test.mjs‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { targetsForEvents } from "../../scripts/stack127/cache/targets.mjs";
1313
import { cacheBodyDigest } from "../../scripts/stack127/cache/refresh.mjs";
1414
import {
1515
acknowledgeSnapshot, checkCurrentRelease, completeRefresh, contentQueueMain, dispatchFrontendRefresh,
16-
exportProofs, frontendLatencyMinutes, frontendStatus, refreshApiRecords, refreshCommittedContent,
16+
exportProofs, frontendLatencyMinutes, frontendStatus, refreshApiRecords, refreshCommittedContent, releaseIgnoredPaths,
1717
retrySnapshot, snapshotQueue, verifyPublicAssets, verifyResourcePlannerQueries,
1818
} from "../../scripts/stack127/frontend/content-queue.mjs";
1919

@@ -75,6 +75,49 @@ test("release checks reject stale main and a different backend revision", async
7575
}
7676
});
7777

78+
test("a backend behind main by release-ignored files still runs current code", async () => {
79+
const live = "d".repeat(40);
80+
const check = (compare, health = { ok: true, build: { sha: live } }) => checkCurrentRelease({ sha, token: "read", fetchImplementation: async url => {
81+
if (url.includes("/compare/")) {
82+
assert.ok(url.endsWith(`/compare/${live}...${sha}`));
83+
return typeof compare === "number" ? json({}, compare) : json(compare);
84+
}
85+
return url.includes("api.github.com") ? json({ object: { sha } }) : json(health);
86+
} });
87+
const ahead = (...filenames) => ({ status: "ahead", files: filenames.map(filename => ({ filename })) });
88+
89+
await check(ahead("AGENTS.md", "docs/deep/notes.md", "content_docs/a.csv", "skills/x/run.sh", "stack127_docs/a.txt", "stack127_records/a.json", "tests/a.mjs"));
90+
91+
for (const compare of [
92+
ahead("AGENTS.md", "app/page.tsx"),
93+
ahead("content/page.mdx"),
94+
ahead("skills.json"),
95+
{ status: "ahead", files: [{ filename: "stack127_docs/moved.ts", previous_filename: "lib/moved.ts" }] },
96+
{ ...ahead("AGENTS.md"), status: "behind" },
97+
{ ...ahead("AGENTS.md"), status: "diverged" },
98+
{ status: "ahead", files: [] },
99+
{ status: "ahead" },
100+
ahead(...Array.from({ length: 300 }, (_, index) => `docs/${index}.md`)),
101+
]) await assert.rejects(check(compare), /live backend/);
102+
103+
await assert.rejects(check(404), /HTTP 404/);
104+
await assert.rejects(check(ahead("AGENTS.md"), { ok: false, build: { sha: live } }), /live backend/);
105+
await assert.rejects(check(ahead("AGENTS.md"), { ok: true, build: { sha: "not-a-sha" } }), /live backend/);
106+
});
107+
108+
test("an exact backend match needs no comparison", async () => {
109+
await checkCurrentRelease({ sha, token: "read", fetchImplementation: async url => {
110+
assert.equal(url.includes("/compare/"), false);
111+
return url.includes("api.github.com") ? json({ object: { sha } }) : json({ ok: true, build: { sha } });
112+
} });
113+
});
114+
115+
test("release-ignored paths match the release workflow", async () => {
116+
const workflow = await readFile(new URL("../../.github/workflows/stack127-release-e2e.yml", import.meta.url), "utf8");
117+
const block = workflow.match(/paths-ignore:\n((?:\s+- .+\n)+)/)[1];
118+
assert.deepEqual([...block.matchAll(/- "(.+)"/g)].map(match => match[1]), releaseIgnoredPaths);
119+
});
120+
78121
function captureClient(events = [event]) {
79122
const calls = [];
80123
return { calls, async query(text, values) {

0 commit comments

Comments
 (0)