diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index dc022ce..fd49c54 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -45,6 +45,8 @@ jobs: - uses: ./.github/actions/setup with: targets: ${{ matrix.rustTargets }} + - name: Record source identity before build + run: node scripts/package.mjs identity-env - run: pnpm install --frozen-lockfile - name: Build unsigned test installer run: node scripts/package.mjs build ${{ matrix.platform }} diff --git a/scripts/package.mjs b/scripts/package.mjs index a80f193..3a92d59 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { createReadStream, existsSync } from "node:fs"; import { + appendFile, copyFile, lstat, mkdir, @@ -179,11 +180,26 @@ export async function packageIdentity(projectRoot = root) { return `v${version}-${sha.slice(0, 12)}${dirty ? "-dirty" : ""}`; } +/** 在安装与编译前把源码标识写入 GitHub Actions 的跨步骤环境文件。 */ +export async function writePackageIdentityEnvironment( + projectRoot = root, + outputFile = process.env.GITHUB_ENV, +) { + if (!outputFile) throw new Error("GITHUB_ENV is required for identity-env."); + await appendFile( + outputFile, + `PACKAGE_SOURCE_IDENTITY=${await packageIdentity(projectRoot)}\n`, + ); +} + /** 复制当前平台的测试安装包,并写入可独立验证的 SHA-256 清单。 */ export async function collectPackage(name, projectRoot = root) { const target = targets[name]; if (!target) throw new Error(`Unknown package platform: ${name}`); - const identity = await packageIdentity(projectRoot); + const identity = + process.env.PACKAGE_SOURCE_IDENTITY || (await packageIdentity(projectRoot)); + if (!/^v[0-9A-Za-z.+-]+-[a-f0-9]{12}(?:-dirty)?$/.test(identity)) + throw new Error(`Invalid package source identity: ${identity}`); const base = join( projectRoot, "src-tauri", @@ -335,21 +351,20 @@ export async function downloadPackage(runId) { if (!/^[1-9]\d*$/.test(runId ?? "")) throw new Error("Expected a numeric GitHub Actions run ID."); const run = JSON.parse( - execFileSync( - "gh", - [ - "run", - "view", - runId, - "--repo", - repository, - "--json", - "workflowName,status,conclusion", - ], - { - cwd: root, - encoding: "utf8", - }, + await retryOperation(() => + execFileSync( + "gh", + [ + "run", + "view", + runId, + "--repo", + repository, + "--json", + "workflowName,status,conclusion", + ], + { cwd: root, encoding: "utf8" }, + ), ), ); if ( @@ -360,35 +375,50 @@ export async function downloadPackage(runId) { throw new Error(`Run ${runId} is not a successful Package workflow.`); const directory = join(root, "artifacts", "packages", "cloud", runId); await mkdir(directory, { recursive: true }); - execFileSync( - "gh", - [ - "run", - "download", - runId, - "--repo", - repository, - "--pattern", - "package-*", - "--dir", - directory, - ], - { - cwd: root, - stdio: "inherit", - }, + await retryOperation(() => + execFileSync( + "gh", + [ + "run", + "download", + runId, + "--repo", + repository, + "--pattern", + "package-*", + "--dir", + directory, + ], + { cwd: root, stdio: "inherit" }, + ), ); for (const name of Object.keys(targets)) await verifyPackage(join(directory, `package-${name}`), name); return directory; } +/** 对偶发 GitHub 请求中断作三次有限重试,文件校验错误仍立即失败。 */ +export async function retryOperation(operation, attempts = 3, delayMs = 1000) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (attempt === attempts) throw error; + console.warn( + `GitHub request interrupted; retrying (${attempt}/${attempts - 1})...`, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs * attempt)); + } + } +} + /** 提供矩阵、构建、收集和 GitHub CLI 操作的统一命令行入口。 */ async function main() { const [command, value] = process.argv.slice(2); if (command === "matrix") console.log(`matrix=${JSON.stringify(packageMatrix())}`); else if (command === "local-platform") console.log(localPlatform()); + else if (command === "identity-env") await writePackageIdentityEnvironment(); else if (command === "package-local") console.log(await packageHere(localPlatform())); else if (command === "package" && value) @@ -400,7 +430,7 @@ async function main() { else if (command === "download") console.log(await downloadPackage(value)); else throw new Error( - "Usage: package.mjs matrix|local-platform|package-local|package |build |collect |dispatch [branch]|download ", + "Usage: package.mjs matrix|local-platform|identity-env|package-local|package |build |collect |dispatch [branch]|download ", ); } diff --git a/scripts/package.test.mjs b/scripts/package.test.mjs index 3f0c4d4..3e86053 100644 --- a/scripts/package.test.mjs +++ b/scripts/package.test.mjs @@ -19,7 +19,9 @@ import { installCommand, localPlatform, packageMatrix, + retryOperation, verifyPackage, + writePackageIdentityEnvironment, } from "./package.mjs"; import { targets } from "./release.mjs"; @@ -115,6 +117,56 @@ test("collects each Linux installer and verifies the generated checksums", async ); }); +test("cloud package identity is fixed before build creates local files", async (t) => { + const root = await fixture(t); + const environmentFile = join(root, "github-env"); + await writePackageIdentityEnvironment(root, environmentFile); + const identity = (await readFile(environmentFile, "utf8")) + .trim() + .split("=", 2)[1]; + assert.match(identity, /^v0\.1\.7-[a-f0-9]{12}$/); + await installer(root, "windows-x64", "msi"); + const previous = process.env.PACKAGE_SOURCE_IDENTITY; + process.env.PACKAGE_SOURCE_IDENTITY = identity; + try { + const directory = await collectPackage("windows-x64", root); + assert.ok(directory.includes(identity)); + assert.ok(!directory.includes("-dirty")); + await verifyPackage(directory, "windows-x64"); + } finally { + if (previous === undefined) delete process.env.PACKAGE_SOURCE_IDENTITY; + else process.env.PACKAGE_SOURCE_IDENTITY = previous; + } +}); + +test("retries interrupted GitHub requests at most three times", async () => { + let attempts = 0; + const result = await retryOperation( + () => { + attempts += 1; + if (attempts < 3) throw new Error("temporary disconnect"); + return "downloaded"; + }, + 3, + 0, + ); + assert.equal(result, "downloaded"); + assert.equal(attempts, 3); + attempts = 0; + await assert.rejects( + retryOperation( + () => { + attempts += 1; + throw new Error("persistent disconnect"); + }, + 3, + 0, + ), + /persistent disconnect/, + ); + assert.equal(attempts, 3); +}); + test("missing, duplicate and empty installer files fail before staging", async (t) => { const root = await fixture(t); await assert.rejects(collectPackage("windows-x64", root), /ENOENT/);