feat: automate staging deploy from Windows Gitea runner

Build immutable API and web images in Aliyun ACR after staging validation, then deploy them safely to the staging host.
This commit is contained in:
linmeng
2026-07-27 11:54:45 +08:00
parent d5334ebecb
commit 742f8f48b2
5 changed files with 266 additions and 7 deletions
+12 -4
View File
@@ -5,8 +5,14 @@ import { pathToFileURL } from "node:url";
const shaPattern = /^[0-9a-f]{40}$/;
const digestPattern = /^sha256:[0-9a-f]{64}$/;
const expectedKeys = ["git_sha", "api_digest", "web_digest"];
const defaultRegistry = "ghcr.io/jesse-ux";
const acrRepository = "crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha";
const registryPattern = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/;
export function parseStagingImageManifest(text, expectedSha) {
export function parseStagingImageManifest(text, expectedSha, registry = defaultRegistry) {
if (!registryPattern.test(registry)) {
throw new Error("invalid staging image registry");
}
if (!shaPattern.test(expectedSha)) {
throw new Error("invalid expected staging revision");
}
@@ -37,12 +43,13 @@ export function parseStagingImageManifest(text, expectedSha) {
}
}
const sharedRepository = registry === acrRepository;
return {
gitSha: expectedSha,
apiDigest: values.get("api_digest"),
webDigest: values.get("web_digest"),
apiImage: `ghcr.io/jesse-ux/jyotisha-api@${values.get("api_digest")}`,
webImage: `ghcr.io/jesse-ux/jyotisha-web@${values.get("web_digest")}`,
apiImage: `${sharedRepository ? registry : `${registry}/jyotisha-api`}@${values.get("api_digest")}`,
webImage: `${sharedRepository ? registry : `${registry}/jyotisha-web`}@${values.get("web_digest")}`,
};
}
@@ -52,13 +59,14 @@ const invokedPath = process.argv[1]
if (invokedPath === import.meta.url) {
try {
const [manifestPath, expectedSha] = process.argv.slice(2);
const [manifestPath, expectedSha, registry] = process.argv.slice(2);
if (!manifestPath || !expectedSha) {
throw new Error("manifest path and expected revision are required");
}
const manifest = parseStagingImageManifest(
await readFile(manifestPath, "utf8"),
expectedSha,
registry,
);
process.stdout.write(
[
@@ -30,6 +30,10 @@ const syncScript = new URL(
"../../deploy/sync-staging-tree.sh",
import.meta.url,
);
const giteaQualityWorkflow = new URL(
"../../.gitea/workflows/backend-quality-gate.yml",
import.meta.url,
);
function read(url: URL): string {
return readFileSync(url, "utf8");
@@ -376,6 +380,21 @@ test("production remains manual-only and separate from staging database automati
assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/);
});
test("Gitea staging push uses the Windows runner and immutable ACR images", () => {
const workflow = read(giteaQualityWorkflow);
assert.match(workflow, /runs-on: runner-win/);
assert.match(workflow, /shell: powershell/);
assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/);
assert.match(workflow, /secrets\.REGISTRY_USERNAME/);
assert.match(workflow, /secrets\.REGISTRY_PASSWORD/);
assert.match(workflow, /:api-\$env:GITEA_SHA/);
assert.match(workflow, /:web-\$env:GITEA_SHA/);
assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previousSha'/);
assert.match(workflow, /git merge-base --is-ancestor \$previousSha \$env:GITEA_SHA/);
assert.match(workflow, /\$scpOptions = @\([^\n]*'-P'/);
assert.doesNotMatch(workflow, /17631000304|copse\.ai\.2026/);
});
test("staging scripts pass shell syntax validation", () => {
for (const script of [deployScript, migrationScript, syncScript]) {
const path = fileURLToPath(script);
@@ -25,6 +25,18 @@ test("manifest produces immutable GHCR digest references", () => {
});
});
test("manifest produces immutable shared ACR repository references", () => {
const repository =
"crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha";
assert.deepEqual(parseStagingImageManifest(validManifest(), gitSha, repository), {
gitSha,
apiDigest,
webDigest,
apiImage: `${repository}@${apiDigest}`,
webImage: `${repository}@${webDigest}`,
});
});
test("manifest rejects revision drift, mutable tags, duplicates, extras, and malformed digests", () => {
const invalid = [
validManifest().replace(gitSha, "f".repeat(40)),
@@ -41,4 +53,14 @@ test("manifest rejects revision drift, mutable tags, duplicates, extras, and mal
for (const contents of invalid) {
assert.throws(() => parseStagingImageManifest(contents, gitSha));
}
for (const registry of [
"https://git.copse.top/root",
"git.copse.top/root;touch /tmp/pwned",
"git.copse.top/Root",
"git.copse.top/root@evil.example",
"git.copse.top/root/../evil",
"",
]) {
assert.throws(() => parseStagingImageManifest(validManifest(), gitSha, registry));
}
});