Skip to content

Commit fb11316

Browse files
FredFred
authored andcommitted
Add SEO release safety gates
1 parent cf38371 commit fb11316

4 files changed

Lines changed: 263 additions & 1 deletion

File tree

.github/workflows/deploy-cloudflare-pages.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ jobs:
2626
- name: Check out repository
2727
if: ${{ env.CLOUDFLARE_API_TOKEN != '' }}
2828
uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 2
2931

3032
- name: Run SEO regression checks
3133
if: ${{ env.CLOUDFLARE_API_TOKEN != '' }}
3234
run: node scripts/seo-regression.mjs
3335

36+
- name: Enforce safe release size and sitemap dates
37+
if: ${{ env.CLOUDFLARE_API_TOKEN != '' }}
38+
run: node scripts/release-safety.mjs
39+
3440
- name: Deploy production site
3541
if: ${{ env.CLOUDFLARE_API_TOKEN != '' }}
3642
uses: cloudflare/wrangler-action@v3

SEO_RELEASE_CHECKLIST.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# SEO Release Safety Checklist
2+
3+
Use this checklist before changes to indexable pages, navigation, redirects, or sitemap data.
4+
5+
## Release rules
6+
7+
1. Keep each commit to 12 or fewer HTML pages.
8+
2. Do not combine homepage or generator-page changes with broad article updates.
9+
3. Keep canonical URLs extensionless and on `https://global-address.com`.
10+
4. Update a sitemap `lastmod` only when that exact page changed.
11+
5. Keep the homepage order: address generator H1, country generators, supporting QA content.
12+
6. Do not change the homepage title, H1, canonical, URL structure, and navigation in the same release.
13+
7. Wait at least 7 days after a major search-facing change before another broad change.
14+
15+
## Required verification
16+
17+
Run both checks before deployment:
18+
19+
```sh
20+
node scripts/seo-regression.mjs
21+
node scripts/release-safety.mjs
22+
```
23+
24+
After deployment, verify:
25+
26+
- `/`, generator pages, `/guides`, `/sitemap.xml`, and `/robots.txt` return HTTP 200.
27+
- `www`, `.html`, and `/index` variants redirect once to the canonical URL.
28+
- Search Console URL Inspection reports the intended canonical for the homepage.
29+
- Compare 24-hour and 7-day impressions before making another search-facing change.
30+
31+
## Recovery rule
32+
33+
If daily impressions fall by more than 50% for two consecutive complete days:
34+
35+
1. Freeze content, title, URL, canonical, and navigation changes.
36+
2. Compare the last production commit with the previous stable commit.
37+
3. Fix only confirmed technical defects.
38+
4. Do not publish batches of new pages while recovery is being measured.

scripts/release-safety.mjs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
4+
const maxHtmlFilesPerCommit = 12;
5+
const [baseRef = "HEAD^", headRef = "HEAD"] = process.argv.slice(2);
6+
const criticalFiles = new Set([
7+
"index.html",
8+
"us-address-generator.html",
9+
"japan-address-generator.html",
10+
"uk-address-generator.html",
11+
"korea-address-generator.html",
12+
"eu-address-generator.html",
13+
"southeast-asia-address-generator.html",
14+
"guides.html",
15+
]);
16+
17+
function git(args, options = {}) {
18+
return execFileSync("git", args, {
19+
encoding: "utf8",
20+
stdio: ["ignore", "pipe", "pipe"],
21+
...options,
22+
}).trim();
23+
}
24+
25+
function hasParentCommit() {
26+
try {
27+
git(["rev-parse", "--verify", baseRef]);
28+
git(["rev-parse", "--verify", headRef]);
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
function parseSitemap(xml) {
36+
const entries = new Map();
37+
for (const block of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
38+
const location = block[1].match(/<loc>([^<]+)<\/loc>/)?.[1];
39+
const lastmod = block[1].match(/<lastmod>([^<]+)<\/lastmod>/)?.[1];
40+
if (location) entries.set(location, lastmod);
41+
}
42+
return entries;
43+
}
44+
45+
function fileForLocation(location) {
46+
const path = new URL(location).pathname;
47+
if (path === "/") return "index.html";
48+
if (path === "/en/") return "en/index.html";
49+
return `${path.slice(1)}.html`;
50+
}
51+
52+
if (!hasParentCommit()) {
53+
console.log("Release safety checks skipped because no parent commit is available.");
54+
process.exit(0);
55+
}
56+
57+
const changedFiles = git(["diff", "--name-only", baseRef, headRef])
58+
.split("\n")
59+
.filter(Boolean);
60+
const changedHtml = changedFiles.filter((file) => file.endsWith(".html"));
61+
62+
assert.ok(
63+
changedHtml.length <= maxHtmlFilesPerCommit,
64+
`Release changes ${changedHtml.length} HTML pages. Split the update into batches of ${maxHtmlFilesPerCommit} or fewer so search impact can be measured and rolled back safely.`,
65+
);
66+
67+
const changedCritical = changedHtml.filter((file) => criticalFiles.has(file));
68+
assert.ok(
69+
!(changedCritical.length > 0 && changedHtml.length > 3),
70+
`Critical search pages (${changedCritical.join(", ")}) cannot ship in the same commit as a broad ${changedHtml.length}-page update.`,
71+
);
72+
73+
if (changedFiles.includes("sitemap.xml")) {
74+
const previousSitemap = parseSitemap(git(["show", `${baseRef}:sitemap.xml`]));
75+
const currentSitemap = parseSitemap(git(["show", `${headRef}:sitemap.xml`]));
76+
const changedLastmodLocations = [];
77+
78+
for (const [location, lastmod] of currentSitemap) {
79+
if (previousSitemap.get(location) !== lastmod) changedLastmodLocations.push(location);
80+
}
81+
82+
const unrelatedLastmod = changedLastmodLocations.filter(
83+
(location) => !changedHtml.includes(fileForLocation(location)),
84+
);
85+
assert.deepEqual(
86+
unrelatedLastmod,
87+
[],
88+
`Sitemap lastmod changed without matching page content changes: ${unrelatedLastmod.join(", ")}`,
89+
);
90+
}
91+
92+
console.log(
93+
`Release safety checks passed (${changedHtml.length} HTML files changed; limit ${maxHtmlFilesPerCommit}).`,
94+
);

scripts/seo-regression.mjs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,72 @@
11
import assert from "node:assert/strict";
22
import { readFile } from "node:fs/promises";
3+
import { execFileSync } from "node:child_process";
34
import { onRequest } from "../functions/_middleware.js";
45

56
const origin = "https://global-address.com";
7+
const excludedHtml = new Set(["404.html", "baidu_verify_codeva-DlQjPzG0IB.html"]);
8+
const criticalPages = {
9+
"index.html": {
10+
title: "地址生成器 - 免费生成多国随机地址、姓名和电话",
11+
canonical: `${origin}/`,
12+
h1: "地址生成器",
13+
},
14+
"us-address-generator.html": {
15+
title: "美国地址生成器 - 随机美国地址 | Global Address Generator",
16+
canonical: `${origin}/us-address-generator`,
17+
h1: "美国地址生成器",
18+
},
19+
"japan-address-generator.html": {
20+
title: "日本地址随机生成器 - Global Address Generator",
21+
canonical: `${origin}/japan-address-generator`,
22+
h1: "日本地址随机生成器",
23+
},
24+
"uk-address-generator.html": {
25+
title: "英国地址生成器 - Global Address Generator",
26+
canonical: `${origin}/uk-address-generator`,
27+
h1: "随机生成英国地址",
28+
},
29+
"korea-address-generator.html": {
30+
title: "韩国地址生成器 - Global Address Generator",
31+
canonical: `${origin}/korea-address-generator`,
32+
h1: "随机生成韩国地址",
33+
},
34+
"eu-address-generator.html": {
35+
title: "欧盟国家地址生成器 - Global Address Generator",
36+
canonical: `${origin}/eu-address-generator`,
37+
h1: "随机生成欧盟国家地址",
38+
},
39+
"southeast-asia-address-generator.html": {
40+
title: "东南亚地址生成器 - Global Address Generator",
41+
canonical: `${origin}/southeast-asia-address-generator`,
42+
h1: "随机生成东南亚国家地址",
43+
},
44+
"guides.html": {
45+
title: "地址知识中心 - 美国地址生成器",
46+
canonical: `${origin}/guides`,
47+
h1: "地址知识中心",
48+
},
49+
};
50+
51+
function trackedHtmlFiles() {
52+
return execFileSync("git", ["ls-files", "*.html"], { encoding: "utf8" })
53+
.trim()
54+
.split("\n")
55+
.filter(Boolean)
56+
.filter((file) => !excludedHtml.has(file));
57+
}
58+
59+
function canonicalPathForFile(file) {
60+
if (file === "index.html") return "/";
61+
if (file === "en/index.html") return "/en/";
62+
return `/${file.replace(/\.html$/, "")}`;
63+
}
64+
65+
function matchText(html, pattern, label) {
66+
const match = html.match(pattern);
67+
assert.ok(match, `${label} must exist`);
68+
return match[1].replace(/<[^>]+>/g, "").trim();
69+
}
670

771
async function middlewareResult(path, hostname = "global-address.com") {
872
return onRequest({
@@ -51,4 +115,64 @@ assert.match(
51115
"sitemap must include the canonical homepage",
52116
);
53117

54-
console.log(`SEO regression checks passed (${redirectCases.length + 4} assertions groups).`);
118+
const sitemapLocations = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
119+
assert.equal(new Set(sitemapLocations).size, sitemapLocations.length, "sitemap URLs must be unique");
120+
121+
for (const location of sitemapLocations) {
122+
const url = new URL(location);
123+
assert.equal(url.origin, origin, `sitemap URL must use the canonical origin: ${location}`);
124+
assert.ok(!url.pathname.endsWith(".html"), `sitemap URL must be extensionless: ${location}`);
125+
assert.ok(!url.pathname.endsWith("/index"), `sitemap URL must not expose index paths: ${location}`);
126+
}
127+
128+
const htmlFiles = trackedHtmlFiles();
129+
const expectedLocations = new Set(htmlFiles.map((file) => `${origin}${canonicalPathForFile(file)}`));
130+
assert.deepEqual(
131+
new Set(sitemapLocations),
132+
expectedLocations,
133+
"sitemap must contain every indexable HTML page exactly once",
134+
);
135+
136+
for (const file of htmlFiles) {
137+
const html = await readFile(new URL(`../${file}`, import.meta.url), "utf8");
138+
const canonical = matchText(
139+
html,
140+
/<link\s+rel="canonical"\s+href="([^"]+)"/i,
141+
`${file} canonical`,
142+
);
143+
assert.equal(
144+
canonical,
145+
`${origin}${canonicalPathForFile(file)}`,
146+
`${file} canonical must match its extensionless URL`,
147+
);
148+
assert.doesNotMatch(html, /<meta\s+name="robots"\s+content="[^"]*noindex/i, `${file} must remain indexable`);
149+
150+
for (const hrefMatch of html.matchAll(/href="([^"]+)"/g)) {
151+
const href = hrefMatch[1];
152+
if (href.startsWith("./") || href.startsWith("/")) {
153+
assert.ok(!/\.html(?:[?#]|$)/.test(href), `${file} contains a legacy internal .html link: ${href}`);
154+
assert.ok(!/(?:^|\/)index(?:[?#]|$)/.test(href), `${file} contains a legacy internal index link: ${href}`);
155+
}
156+
}
157+
}
158+
159+
for (const [file, expected] of Object.entries(criticalPages)) {
160+
const html = await readFile(new URL(`../${file}`, import.meta.url), "utf8");
161+
assert.equal(matchText(html, /<title>([^<]+)<\/title>/i, `${file} title`), expected.title);
162+
assert.equal(
163+
matchText(html, /<link\s+rel="canonical"\s+href="([^"]+)"/i, `${file} canonical`),
164+
expected.canonical,
165+
);
166+
assert.equal(matchText(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i, `${file} h1`), expected.h1);
167+
}
168+
169+
const homeH1Position = home.indexOf('<h1 id="pageTitle">地址生成器</h1>');
170+
const countrySectionPosition = home.indexOf('<section id="countries"');
171+
const qaLabPosition = home.indexOf('<section class="reference-section qa-promo"');
172+
assert.ok(homeH1Position >= 0, "homepage must keep the address generator H1");
173+
assert.ok(countrySectionPosition > homeH1Position, "country generators must follow the homepage H1");
174+
assert.ok(qaLabPosition > countrySectionPosition, "QA Lab must not appear before the country generators");
175+
176+
console.log(
177+
`SEO regression checks passed (${htmlFiles.length} indexable pages, ${sitemapLocations.length} sitemap URLs).`,
178+
);

0 commit comments

Comments
 (0)