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
3 changes: 2 additions & 1 deletion .github/workflows/stack127-release-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ jobs:
if: steps.migrations.outputs.run == 'true' || (steps.migrations.outputs.functions == 'true' && vars.STACK127_CACHE_WORKER_RUNTIME != 'node')
uses: supabase/setup-cli@v1
with:
version: latest
# Version 3 rejects [inbucket] in supabase/config.toml. Change the two together.
version: 2.122.1

- name: Apply database migrations
if: steps.migrations.outputs.run == 'true'
Expand Down
20 changes: 19 additions & 1 deletion scripts/stack127/frontend/content-queue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const activeStatuses = ["queued", "in_progress", "waiting", "requested", "pendin
const eventKey = event => `${event.entity_type}:${event.slug}`;
const shaPattern = /^[a-f0-9]{40}$/;
const pathPattern = /^\/[a-z0-9/_-]*(?:\.(?:xml|txt|json))?$/;
// Keep equal to paths-ignore in stack127-release-e2e.yml. A commit touching only
// these never deploys, so the backend stays on an older SHA with the same code.
export const releaseIgnoredPaths = ["**/*.md", "content_docs/**", "skills/**", "stack127_docs/**", "stack127_records/**", "tests/**"];
const releaseIgnoredDirectories = releaseIgnoredPaths.filter(path => !path.startsWith("**")).map(path => path.slice(0, -2));
const releaseIgnores = path => path.endsWith(".md") || releaseIgnoredDirectories.some(directory => path.startsWith(directory));
// GitHub lists at most this many files, so a list this long may be incomplete.
const compareFileLimit = 300;

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

async function backendRunsSameCode({ liveSha, sha, repo, token, fetchImplementation }) {
if (liveSha === sha) return true;
if (!shaPattern.test(liveSha ?? "")) return false;
const compare = await (await githubRequest(repo, token, `/compare/${liveSha}...${sha}`, {}, fetchImplementation)).json();
const files = compare.files;
if (compare.status !== "ahead" || !Array.isArray(files) || files.length === 0 || files.length >= compareFileLimit) return false;
return files.every(({ filename, previous_filename: previous }) => releaseIgnores(filename) && (previous === undefined || releaseIgnores(previous)));
}

export async function checkCurrentRelease({ sha, site = "https://stack127.com", repo = "stack127labs/stack127", token, fetchImplementation = fetch }) {
await checkCurrentMain({ sha, repo, token, fetchImplementation });
const response = await siteRequest(site, "/api/health?scope=deploy", {}, fetchImplementation);
if (response.status !== 200) throw new Error(`Backend health failed: HTTP ${response.status}`);
const health = await response.json();
if (health.ok !== true || health.build?.sha !== sha) throw new Error("The live backend SHA does not match current main.");
if (health.ok !== true || !await backendRunsSameCode({ liveSha: health.build?.sha, sha, repo, token, fetchImplementation })) {
throw new Error("The live backend SHA does not match current main.");
}
}

// Dispatch checks are observations, not successful content deployments. The
Expand Down
2 changes: 1 addition & 1 deletion skills/stack127/stack127-release-e2e/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Page content in MDX went live with the merge. The publisher writes the database
3. Run `dispatch` with `--environment production`, `--confirm stack127-production`, and the full current `origin/main` SHA as `--release-sha`.
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.
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.
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.
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.
7. Keep each plan file until the pages are checked. It holds the before values `restore` needs.

Use the single-purpose tool-page commands in production only for a small fix to one live page.
Expand Down
2 changes: 1 addition & 1 deletion stack127_docs/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ A database edit still queues an event. The `cache-worker` doesn't refresh pages.

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

1. Checks out current `main` and checks that the live backend health SHA matches it.
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`).
2. Captures the queued event versions and their source fingerprints in one database snapshot.
3. Builds the full static export, previews it, checks `main` and the backend again, and deploys it.
4. Checks the public pages byte for byte against the export. Removed pages must return 404.
Expand Down
45 changes: 44 additions & 1 deletion tests/stack127/frontend-content-queue.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { targetsForEvents } from "../../scripts/stack127/cache/targets.mjs";
import { cacheBodyDigest } from "../../scripts/stack127/cache/refresh.mjs";
import {
acknowledgeSnapshot, checkCurrentRelease, completeRefresh, contentQueueMain, dispatchFrontendRefresh,
exportProofs, frontendLatencyMinutes, frontendStatus, refreshApiRecords, refreshCommittedContent,
exportProofs, frontendLatencyMinutes, frontendStatus, refreshApiRecords, refreshCommittedContent, releaseIgnoredPaths,
retrySnapshot, snapshotQueue, verifyPublicAssets, verifyResourcePlannerQueries,
} from "../../scripts/stack127/frontend/content-queue.mjs";

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

test("a backend behind main by release-ignored files still runs current code", async () => {
const live = "d".repeat(40);
const check = (compare, health = { ok: true, build: { sha: live } }) => checkCurrentRelease({ sha, token: "read", fetchImplementation: async url => {
if (url.includes("/compare/")) {
assert.ok(url.endsWith(`/compare/${live}...${sha}`));
return typeof compare === "number" ? json({}, compare) : json(compare);
}
return url.includes("api.github.com") ? json({ object: { sha } }) : json(health);
} });
const ahead = (...filenames) => ({ status: "ahead", files: filenames.map(filename => ({ filename })) });

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"));

for (const compare of [
ahead("AGENTS.md", "app/page.tsx"),
ahead("content/page.mdx"),
ahead("skills.json"),
{ status: "ahead", files: [{ filename: "stack127_docs/moved.ts", previous_filename: "lib/moved.ts" }] },
{ ...ahead("AGENTS.md"), status: "behind" },
{ ...ahead("AGENTS.md"), status: "diverged" },
{ status: "ahead", files: [] },
{ status: "ahead" },
ahead(...Array.from({ length: 300 }, (_, index) => `docs/${index}.md`)),
]) await assert.rejects(check(compare), /live backend/);

await assert.rejects(check(404), /HTTP 404/);
await assert.rejects(check(ahead("AGENTS.md"), { ok: false, build: { sha: live } }), /live backend/);
await assert.rejects(check(ahead("AGENTS.md"), { ok: true, build: { sha: "not-a-sha" } }), /live backend/);
});

test("an exact backend match needs no comparison", async () => {
await checkCurrentRelease({ sha, token: "read", fetchImplementation: async url => {
assert.equal(url.includes("/compare/"), false);
return url.includes("api.github.com") ? json({ object: { sha } }) : json({ ok: true, build: { sha } });
} });
});

test("release-ignored paths match the release workflow", async () => {
const workflow = await readFile(new URL("../../.github/workflows/stack127-release-e2e.yml", import.meta.url), "utf8");
const block = workflow.match(/paths-ignore:\n((?:\s+- .+\n)+)/)[1];
assert.deepEqual([...block.matchAll(/- "(.+)"/g)].map(match => match[1]), releaseIgnoredPaths);
});

function captureClient(events = [event]) {
const calls = [];
return { calls, async query(text, values) {
Expand Down
Loading