diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 0600e194..b21caccf 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -857,14 +857,15 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} run: | mkdir -p release/desktop - gh release download "$RELEASE_TAG" \ + node scripts/download-release-assets.mjs \ --repo "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --dir release/desktop \ --pattern 'EdgeEver-*-windows-x64.exe' \ --pattern 'latest.yml' \ --pattern 'latest-windows.json' \ --pattern 'latest-windows.json.sig' \ - --pattern 'SHA256SUMS-windows.txt' \ - --dir release/desktop + --pattern 'SHA256SUMS-windows.txt' - name: Verify signature and installer digests run: node scripts/verify-windows-update-release.mjs release/desktop @@ -894,12 +895,13 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} run: | mkdir -p release/desktop - gh release download "$RELEASE_TAG" \ + node scripts/download-release-assets.mjs \ --repo "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --dir release/desktop \ --pattern 'EdgeEver-*-linux-x64.AppImage' \ --pattern 'latest-linux.yml' \ - --pattern 'SHA256SUMS-linux.txt' \ - --dir release/desktop + --pattern 'SHA256SUMS-linux.txt' - name: Verify Linux automatic update release run: node scripts/verify-linux-update-release.mjs release/desktop diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 65f2b627..e3579a7d 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -141,10 +141,11 @@ jobs: GH_TOKEN: ${{ github.token }} run: | mkdir -p "$RUNNER_TEMP/native-assets" - gh release download "$CURRENT_TAG" \ + node scripts/download-release-assets.mjs \ --repo "$GITHUB_REPOSITORY" \ - --pattern 'edgeever-android-v*-arm64-v8a.apk' \ - --dir "$RUNNER_TEMP/native-assets" + --tag "$CURRENT_TAG" \ + --dir "$RUNNER_TEMP/native-assets" \ + --pattern 'edgeever-android-v*-arm64-v8a.apk' apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)" test -n "$apk_path" test -n "$ANDROID_PLAY_APP_SIGNER_SHA256" diff --git a/docs/release.md b/docs/release.md index 5fafacb4..1aca8d46 100644 --- a/docs/release.md +++ b/docs/release.md @@ -87,10 +87,10 @@ actually needed. Issue closed; retry with `bun run publish:stores -- --release vX.Y.Z --platform ios`. See [Mobile Store Delivery](store-delivery.md). -- Published desktop and Android audits read asset names from the release API - URL. `gh release view --json assets` can stay empty after a draft is - published, and treating that empty list as a missing asset set returns the - Release to Draft. +- Published desktop and Android audits read asset names and download + installers through the release API URL. `gh release view --json assets` and + `gh release download` can stay empty after a draft is published, and treating + that empty list as a missing asset set returns the Release to Draft. - After rebuilt desktop assets are uploaded to the Draft, the local release command signs only `latest-windows.json`; the private key never enters GitHub Actions. A second desktop workflow run downloads the Windows installer, diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index 8ce442cd..a52d1bac 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -74,9 +74,9 @@ bun run release -- \ GitHub Release 保持已发布,跟踪 Issue 保持已关闭;用 `bun run publish:stores -- --release vX.Y.Z --platform ios` 重试即可。详见 [移动端商店交付](store-delivery.zh-CN.md)。 -- 公开发布后的桌面和 Android 审计通过 Release API URL 读取资产文件名。 - Draft 刚公开时,`gh release view --json assets` 可能持续返回空列表;如果把 - 这个空列表当成资产缺失,发布会被退回 Draft。 +- 公开发布后的桌面和 Android 审计通过 Release API URL 读取文件名并下载安装包。 + Draft 刚公开时,`gh release view --json assets` 和 `gh release download` + 可能持续看不到这些文件;如果把空列表当成资产缺失,发布会被退回 Draft。 - 重建后的桌面资产上传到 Draft 后,本地发布命令只签署 `latest-windows.json`,私钥不会进入 GitHub Actions。第二次桌面工作流会重新 下载 Windows 安装包、`latest.yml`、清单、签名和校验和文件并独立审计,通过后 diff --git a/scripts/download-release-assets.mjs b/scripts/download-release-assets.mjs new file mode 100644 index 00000000..d4a6a63c --- /dev/null +++ b/scripts/download-release-assets.mjs @@ -0,0 +1,85 @@ +import { spawnSync } from "node:child_process"; +import { closeSync, mkdirSync, openSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const globToRegExp = (pattern) => { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*"); + return new RegExp(`^${escaped}$`); +}; + +export const selectReleaseAssets = (assets, patterns) => { + const selected = []; + for (const pattern of patterns) { + const expression = globToRegExp(pattern); + const matches = assets.filter((asset) => expression.test(asset.name)); + if (matches.length === 0) { + throw new Error(`Release has no asset matching ${pattern}.`); + } + selected.push(...matches); + } + return selected; +}; + +const parseArgs = (argv) => { + const options = { patterns: [] }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const value = argv[index + 1]; + if (argument === "--repo") options.repo = value; + else if (argument === "--tag") options.tag = value; + else if (argument === "--dir") options.dir = value; + else if (argument === "--pattern") options.patterns.push(value); + else throw new Error(`Unknown argument: ${argument}`); + index += 1; + } + if (!options.repo || !options.tag || !options.dir || options.patterns.length === 0) { + throw new Error("Usage: download-release-assets.mjs --repo owner/name --tag vX.Y.Z --dir path --pattern glob"); + } + return options; +}; + +const gh = (args, stdio = "pipe") => { + const result = spawnSync("gh", args, { encoding: stdio === "pipe" ? "utf8" : undefined, stdio }); + if (result.status !== 0) { + throw new Error(`gh ${args.join(" ")} failed: ${result.stderr || result.stdout || result.status}`); + } + return result.stdout; +}; + +export const downloadReleaseAssets = ({ repo, tag, dir, patterns }) => { + const apiUrl = gh([ + "release", "view", tag, + "--repo", repo, + "--json", "apiUrl", + "--jq", ".apiUrl", + ]).trim(); + const assets = JSON.parse(gh(["api", apiUrl])); + const selected = selectReleaseAssets(assets.assets ?? [], patterns); + mkdirSync(dir, { recursive: true }); + for (const asset of selected) { + const destination = join(dir, asset.name); + const output = openSync(destination, "w"); + try { + gh([ + "api", + asset.url, + "-H", + "Accept: application/octet-stream", + ], ["ignore", output, "inherit"]); + } finally { + closeSync(output); + } + } + return selected.map((asset) => asset.name); +}; + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const names = downloadReleaseAssets(parseArgs(process.argv.slice(2))); + console.log(names.join("\n")); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/download-release-assets.test.mjs b/scripts/download-release-assets.test.mjs new file mode 100644 index 00000000..cac967ad --- /dev/null +++ b/scripts/download-release-assets.test.mjs @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { globToRegExp, selectReleaseAssets } from "./download-release-assets.mjs"; + +describe("release asset download", () => { + test("matches the published audit filenames", () => { + const assets = [ + { name: "EdgeEver-1.84.0-linux-x64.AppImage" }, + { name: "EdgeEver-1.84.0-windows-x64.exe" }, + { name: "edgeever-android-v1.84.0-arm64-v8a.apk" }, + { name: "latest-linux.yml" }, + { name: "SHA256SUMS-linux.txt" }, + ]; + expect(selectReleaseAssets(assets, [ + "EdgeEver-*-linux-x64.AppImage", + "latest-linux.yml", + "SHA256SUMS-linux.txt", + ]).map((asset) => asset.name)).toEqual([ + "EdgeEver-1.84.0-linux-x64.AppImage", + "latest-linux.yml", + "SHA256SUMS-linux.txt", + ]); + expect(globToRegExp("edgeever-android-v*-arm64-v8a.apk").test(assets[2].name)).toBe(true); + }); + + test("rejects a pattern that matches nothing", () => { + expect(() => selectReleaseAssets([{ name: "latest.yml" }], ["missing-*"])).toThrow( + "Release has no asset matching missing-*.", + ); + }); + + test("published audits download through the release API", () => { + const desktop = readFileSync(new URL("../.github/workflows/desktop-build.yml", import.meta.url), "utf8"); + const mobile = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8"); + expect(desktop).toContain("node scripts/download-release-assets.mjs"); + expect(mobile).toContain("node scripts/download-release-assets.mjs"); + expect(desktop).not.toContain('gh release download "$RELEASE_TAG"'); + expect(mobile).not.toContain('gh release download "$CURRENT_TAG"'); + }); +});