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:
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user