From 06b5b88ac1058debf84ad8ef0dd782635af0bd32 Mon Sep 17 00:00:00 2001 From: Hccake Date: Wed, 12 Aug 2026 18:02:38 +0800 Subject: [PATCH] fix(release): omit MSI from prereleases --- .github/workflows/release.yml | 6 +- CONTRIBUTING.md | 2 + README.md | 2 +- README.zh-CN.md | 2 +- scripts/__tests__/release-policy.test.mjs | 41 ++++---- .../__tests__/verify-release-assets.test.mjs | 99 ++++++++++++++----- scripts/verify-release-assets.mjs | 39 ++++++-- 7 files changed, 137 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 230c662..4af18bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,15 +193,19 @@ jobs: include: - runner: macos-latest args: --target aarch64-apple-darwin --bundles app,dmg + prerelease_args: --target aarch64-apple-darwin --bundles app,dmg asset_platform: macos - runner: macos-latest args: --target x86_64-apple-darwin --bundles app,dmg + prerelease_args: --target x86_64-apple-darwin --bundles app,dmg asset_platform: macos - runner: ubuntu-22.04 args: --bundles appimage,deb,rpm + prerelease_args: --bundles appimage,deb,rpm asset_platform: linux - runner: windows-latest args: --bundles nsis,msi + prerelease_args: --bundles nsis asset_platform: windows runs-on: ${{ matrix.runner }} steps: @@ -239,7 +243,7 @@ jobs: updaterJsonPreferNsis: false retryAttempts: 3 releaseAssetNamePattern: skill-deck_[version]_${{ matrix.asset_platform }}_[arch][setup][ext] - args: ${{ matrix.args }} + args: ${{ needs.validate.outputs.prerelease == 'true' && matrix.prerelease_args || matrix.args }} verify-release: needs: [validate, prepare-release, build-release] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0124106..92ec06c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,6 +137,8 @@ docker run --rm \ 质量检查通过后,工作流会创建或复用草稿 Release,并由官方 `tauri-action` 构建和上传各平台安装包、更新签名与 `latest.json`。自动校验完成后,由维护者检查版本、Release 正文、安装包和更新信息,再通过 GitHub 界面公开发布。 +预发布版本在 Windows 上提供 NSIS 安装包;稳定版本同时提供 NSIS 和 MSI。MSI 的数值版本不能表达 SemVer 预发布标识,发布工作流和资产验证器根据版本类型使用对应的安装包契约。 + Release 正文来自标签对应提交中的 `CHANGELOG.md`。公开安装包名称由 `README.md` 和 `README.zh-CN.md` 维护,完整资产和更新清单契约由 `scripts/verify-release-assets.mjs` 及其测试维护。该契约要求 `latest.json` 不超过 1 MiB,更新器引用的安装资产不超过 256 MiB。修改发布流程时,需要同步更新工作流、验证脚本、脚本测试和受影响的用户文档。 ## 提交内容 diff --git a/README.md b/README.md index c76e7ac..fe33ac5 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Skill availability is determined by its installation location and agent associat Download the installer for your platform from [GitHub Releases](https://github.com/hccake/skill-deck/releases): -- **Windows**: `skill-deck_x.x.x_windows_x64-setup.exe` or `skill-deck_x.x.x_windows_x64.msi` +- **Windows**: `skill-deck_x.x.x_windows_x64-setup.exe`; stable releases also provide `skill-deck_x.x.x_windows_x64.msi` - **macOS Apple Silicon**: `skill-deck_x.x.x_macos_aarch64.dmg` - **macOS Intel**: `skill-deck_x.x.x_macos_x64.dmg` > macOS builds are currently unsigned. If macOS blocks the app after installation, run: diff --git a/README.zh-CN.md b/README.zh-CN.md index e7f31b7..c1629c4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,7 +93,7 @@ Skill 是否可用取决于它的安装位置以及 Agent 是否读取该位置 从 [GitHub Releases](https://github.com/hccake/skill-deck/releases) 下载对应平台的安装包: -- **Windows**:`skill-deck_x.x.x_windows_x64-setup.exe` 或 `skill-deck_x.x.x_windows_x64.msi` +- **Windows**:`skill-deck_x.x.x_windows_x64-setup.exe`;稳定版本还会提供 `skill-deck_x.x.x_windows_x64.msi` - **macOS Apple Silicon**:`skill-deck_x.x.x_macos_aarch64.dmg` - **macOS Intel**:`skill-deck_x.x.x_macos_x64.dmg` > macOS 构建目前没有 Apple 开发者签名。如果安装后被系统拦截,可执行: diff --git a/scripts/__tests__/release-policy.test.mjs b/scripts/__tests__/release-policy.test.mjs index e1e8fff..c2c3deb 100644 --- a/scripts/__tests__/release-policy.test.mjs +++ b/scripts/__tests__/release-policy.test.mjs @@ -3,7 +3,10 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import { parse } from "yaml"; -import { documentedInstallerNames } from "../verify-release-assets.mjs"; +import { + documentedInstallerNames, + expectedReleaseAssetNames, +} from "../verify-release-assets.mjs"; const workflowUrl = new URL( "../../.github/workflows/release.yml", @@ -179,21 +182,22 @@ test("release workflow prepares one draft and lets tauri-action upload each plat assert.equal(workflow.jobs.aggregate, undefined); }); -test("release workflow builds every public installer with explicit bundle arguments", async () => { - const workflow = await readFile(workflowUrl, "utf8"); - const build = - workflow.match(/\n build-release:[\s\S]*?(?=\n verify-release:)/)?.[0] ?? - ""; +test("release workflow omits MSI for prereleases and retains it for stable releases", async () => { + const workflow = await readWorkflow(workflowUrl); + const build = workflow.jobs["build-release"]; + const windows = build.strategy.matrix.include.find( + (entry) => entry.runner === "windows-latest", + ); + const action = build.steps.find((step) => + step.uses?.startsWith("tauri-apps/tauri-action@"), + ); - assert.match(build, /args: --target aarch64-apple-darwin --bundles app,dmg/); - assert.match(build, /args: --target x86_64-apple-darwin --bundles app,dmg/); - assert.match(build, /args: --bundles appimage,deb,rpm/); - assert.match(build, /args: --bundles nsis,msi/); - assert.match( - build, - /releaseId: \$\{\{ needs\.prepare-release\.outputs\.release_id \}\}/, + assert.equal(windows.args, "--bundles nsis,msi"); + assert.equal(windows.prerelease_args, "--bundles nsis"); + assert.equal( + action.with.args, + "${{ needs.validate.outputs.prerelease == 'true' && matrix.prerelease_args || matrix.args }}", ); - assert.doesNotMatch(workflow, /Expected 8 installer\/signature files/); }); test("release helpers come from the workflow commit instead of the tagged application commit", async () => { @@ -304,14 +308,11 @@ test("release workflow derives and enforces GitHub prerelease state from the ver }); test("release verification checks the complete remote asset and updater manifest contract", async () => { - const [workflow, verifier] = await Promise.all([ - readFile(workflowUrl, "utf8"), - readFile(releaseVerifierUrl, "utf8"), - ]); + const workflow = await readFile(workflowUrl, "utf8"); const verify = workflow.match(/\n verify-release:[\s\S]*/)?.[0] ?? ""; - assert.match(verifier, /Expected 17 Release assets/i); - assert.match(verifier, /Expected 11 updater platform keys/i); + assert.equal(expectedReleaseAssetNames("1.7.0-beta.4").length, 15); + assert.equal(expectedReleaseAssetNames("1.7.0").length, 17); assert.match(verify, /gh release download[\s\S]*latest\.json/); assert.match(verify, /gh release download[\s\S]*\*\.sig/); assert.match(verify, /--mode complete/); diff --git a/scripts/__tests__/verify-release-assets.test.mjs b/scripts/__tests__/verify-release-assets.test.mjs index 746f97b..0acdcc2 100644 --- a/scripts/__tests__/verify-release-assets.test.mjs +++ b/scripts/__tests__/verify-release-assets.test.mjs @@ -18,11 +18,12 @@ const verifierPath = fileURLToPath( new URL("../verify-release-assets.mjs", import.meta.url), ); const version = "1.7.0-beta.4"; +const stableVersion = "1.7.0"; const tag = `v${version}`; const repository = "hccake/skill-deck"; const notes = "## Changes\n\n- Complete updater matrix.\n"; -const platformAssets = Object.freeze({ +const commonPlatformAssets = Object.freeze({ "darwin-aarch64": `skill-deck_${version}_macos_aarch64.app.tar.gz`, "darwin-aarch64-app": `skill-deck_${version}_macos_aarch64.app.tar.gz`, "darwin-x86_64": `skill-deck_${version}_macos_x64.app.tar.gz`, @@ -31,12 +32,30 @@ const platformAssets = Object.freeze({ "linux-x86_64-appimage": `skill-deck_${version}_linux_amd64.AppImage`, "linux-x86_64-deb": `skill-deck_${version}_linux_amd64.deb`, "linux-x86_64-rpm": `skill-deck_${version}_linux_x86_64.rpm`, - "windows-x86_64": `skill-deck_${version}_windows_x64.msi`, - "windows-x86_64-msi": `skill-deck_${version}_windows_x64.msi`, + "windows-x86_64": `skill-deck_${version}_windows_x64-setup.exe`, "windows-x86_64-nsis": `skill-deck_${version}_windows_x64-setup.exe`, }); -async function createCompleteFixture() { +function platformAssetsFor(releaseVersion) { + const renderVersion = (name) => name.replaceAll(version, releaseVersion); + const assets = Object.fromEntries( + Object.entries(commonPlatformAssets).map(([platform, name]) => [ + platform, + renderVersion(name), + ]), + ); + if (!releaseVersion.includes("-")) { + const msi = `skill-deck_${releaseVersion}_windows_x64.msi`; + assets["windows-x86_64"] = msi; + assets["windows-x86_64-msi"] = msi; + } + return assets; +} + +async function createCompleteFixture(releaseVersion = version) { + const fixtureTag = `v${releaseVersion}`; + const fixturePrerelease = releaseVersion.includes("-"); + const platformAssets = platformAssetsFor(releaseVersion); const root = await mkdtemp(path.join(os.tmpdir(), "skill-deck-release-")); const signaturesDirectory = path.join(root, "release-downloads"); await mkdir(signaturesDirectory); @@ -54,7 +73,7 @@ async function createCompleteFixture() { ); } - const releaseAssetNames = expectedReleaseAssetNames(version); + const releaseAssetNames = expectedReleaseAssetNames(releaseVersion); const assetApiUrls = new Map( releaseAssetNames.map((name, index) => [ name, @@ -62,7 +81,7 @@ async function createCompleteFixture() { ]), ); const manifest = { - version, + version: releaseVersion, notes, pub_date: "2026-08-08T12:00:00.000Z", platforms: Object.fromEntries( @@ -83,9 +102,9 @@ async function createCompleteFixture() { await writeFile(notesFile, notes); const release = { - tagName: tag, + tagName: fixtureTag, isDraft: true, - isPrerelease: true, + isPrerelease: fixturePrerelease, body: notes.trimEnd(), assets: releaseAssetNames.map((name) => ({ name, @@ -101,10 +120,13 @@ async function createCompleteFixture() { notesFile, manifest, release, + version: releaseVersion, + tag: fixtureTag, + expectedPrerelease: fixturePrerelease, }; } -test("declares the 17 uploaded assets and seven documented installers", () => { +test("prereleases omit MSI assets that cannot represent SemVer prerelease identifiers", () => { assert.deepEqual(expectedReleaseAssetNames(version), [ "latest.json", `skill-deck_${version}_linux_amd64.AppImage`, @@ -121,17 +143,31 @@ test("declares the 17 uploaded assets and seven documented installers", () => { `skill-deck_${version}_macos_x64.dmg`, `skill-deck_${version}_windows_x64-setup.exe`, `skill-deck_${version}_windows_x64-setup.exe.sig`, - `skill-deck_${version}_windows_x64.msi`, - `skill-deck_${version}_windows_x64.msi.sig`, ]); - assert.deepEqual(documentedInstallerNames(version), [ - `skill-deck_${version}_macos_aarch64.dmg`, - `skill-deck_${version}_macos_x64.dmg`, - `skill-deck_${version}_linux_amd64.AppImage`, - `skill-deck_${version}_linux_amd64.deb`, - `skill-deck_${version}_linux_x86_64.rpm`, - `skill-deck_${version}_windows_x64-setup.exe`, - `skill-deck_${version}_windows_x64.msi`, +}); + +test("stable releases retain the complete MSI and NSIS asset contract", () => { + const assetNames = expectedReleaseAssetNames(stableVersion); + + assert.equal(assetNames.length, 17); + assert.ok(assetNames.includes(`skill-deck_${stableVersion}_windows_x64.msi`)); + assert.ok( + assetNames.includes(`skill-deck_${stableVersion}_windows_x64.msi.sig`), + ); + assert.ok( + assetNames.includes(`skill-deck_${stableVersion}_windows_x64-setup.exe`), + ); +}); + +test("documents every stable installer offered to users", () => { + assert.deepEqual(documentedInstallerNames(stableVersion), [ + `skill-deck_${stableVersion}_macos_aarch64.dmg`, + `skill-deck_${stableVersion}_macos_x64.dmg`, + `skill-deck_${stableVersion}_linux_amd64.AppImage`, + `skill-deck_${stableVersion}_linux_amd64.deb`, + `skill-deck_${stableVersion}_linux_x86_64.rpm`, + `skill-deck_${stableVersion}_windows_x64-setup.exe`, + `skill-deck_${stableVersion}_windows_x64.msi`, ]); }); @@ -212,6 +248,25 @@ test("accepts the complete official tauri-action release contract", async () => } }); +test("accepts the complete stable release contract with MSI and NSIS", async () => { + const fixture = await createCompleteFixture(stableVersion); + try { + await verifyReleaseAssets({ + mode: "complete", + release: fixture.release, + manifest: fixture.manifest, + signaturesDirectory: fixture.signaturesDirectory, + notes, + version: fixture.version, + tag: fixture.tag, + repository, + expectedPrerelease: fixture.expectedPrerelease, + }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + test("rejects an updater manifest larger than 1 MiB", async () => { const fixture = await createCompleteFixture(); try { @@ -240,7 +295,7 @@ test("rejects an updater manifest larger than 1 MiB", async () => { test("rejects an updater asset larger than 256 MiB", async () => { const fixture = await createCompleteFixture(); try { - const assetName = platformAssets["linux-x86_64"]; + const assetName = platformAssetsFor(version)["linux-x86_64"]; fixture.release.assets.find((asset) => asset.name === assetName).size = MAX_UPDATER_ASSET_BYTES + 1; await assert.rejects( @@ -267,7 +322,7 @@ for (const [name, mutate, expected] of [ [ "missing Release asset", ({ release }) => release.assets.pop(), - /Expected 17 Release assets/i, + /Expected 15 Release assets/i, ], [ "empty Release asset", @@ -279,7 +334,7 @@ for (const [name, mutate, expected] of [ [ "missing updater platform", ({ manifest }) => delete manifest.platforms["linux-x86_64-deb"], - /Expected 11 updater platform keys/i, + /Expected 10 updater platform keys/i, ], [ "wrong updater URL", diff --git a/scripts/verify-release-assets.mjs b/scripts/verify-release-assets.mjs index ab2cd03..26075ad 100644 --- a/scripts/verify-release-assets.mjs +++ b/scripts/verify-release-assets.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; export const MAX_UPDATER_MANIFEST_BYTES = 1024 * 1024; export const MAX_UPDATER_ASSET_BYTES = 256 * 1024 * 1024; -const PLATFORM_ASSET_TEMPLATES = Object.freeze({ +const COMMON_PLATFORM_ASSET_TEMPLATES = Object.freeze({ "darwin-aarch64": "skill-deck_{version}_macos_aarch64.app.tar.gz", "darwin-aarch64-app": "skill-deck_{version}_macos_aarch64.app.tar.gz", "darwin-x86_64": "skill-deck_{version}_macos_x64.app.tar.gz", @@ -14,6 +14,14 @@ const PLATFORM_ASSET_TEMPLATES = Object.freeze({ "linux-x86_64-appimage": "skill-deck_{version}_linux_amd64.AppImage", "linux-x86_64-deb": "skill-deck_{version}_linux_amd64.deb", "linux-x86_64-rpm": "skill-deck_{version}_linux_x86_64.rpm", +}); + +const NSIS_PLATFORM_ASSET_TEMPLATES = Object.freeze({ + "windows-x86_64": "skill-deck_{version}_windows_x64-setup.exe", + "windows-x86_64-nsis": "skill-deck_{version}_windows_x64-setup.exe", +}); + +const STABLE_WINDOWS_PLATFORM_ASSET_TEMPLATES = Object.freeze({ "windows-x86_64": "skill-deck_{version}_windows_x64.msi", "windows-x86_64-msi": "skill-deck_{version}_windows_x64.msi", "windows-x86_64-nsis": "skill-deck_{version}_windows_x64-setup.exe", @@ -29,7 +37,7 @@ const INSTALLER_TEMPLATES = Object.freeze([ "skill-deck_{version}_windows_x64.msi", ]); -const RELEASE_ASSET_TEMPLATES = Object.freeze([ +const COMMON_RELEASE_ASSET_TEMPLATES = Object.freeze([ "latest.json", "skill-deck_{version}_linux_amd64.AppImage", "skill-deck_{version}_linux_amd64.AppImage.sig", @@ -45,6 +53,9 @@ const RELEASE_ASSET_TEMPLATES = Object.freeze([ "skill-deck_{version}_macos_x64.dmg", "skill-deck_{version}_windows_x64-setup.exe", "skill-deck_{version}_windows_x64-setup.exe.sig", +]); + +const MSI_RELEASE_ASSET_TEMPLATES = Object.freeze([ "skill-deck_{version}_windows_x64.msi", "skill-deck_{version}_windows_x64.msi.sig", ]); @@ -69,6 +80,10 @@ function validateVersion(version) { ); } +function isPrereleaseVersion(version) { + return version.split("+", 1)[0].includes("-"); +} + export function documentedInstallerNames(version) { invariant(typeof version === "string" && version, "Missing release version"); return INSTALLER_TEMPLATES.map((template) => render(template, version)); @@ -76,7 +91,10 @@ export function documentedInstallerNames(version) { export function expectedReleaseAssetNames(version) { validateVersion(version); - return RELEASE_ASSET_TEMPLATES.map((template) => render(template, version)); + const templates = isPrereleaseVersion(version) + ? COMMON_RELEASE_ASSET_TEMPLATES + : [...COMMON_RELEASE_ASSET_TEMPLATES, ...MSI_RELEASE_ASSET_TEMPLATES]; + return templates.map((template) => render(template, version)); } function verifyDraftState({ release, tag, expectedPrerelease }) { @@ -112,7 +130,7 @@ function readRemoteAssets(release, expectedNames, repository) { invariant(Array.isArray(release.assets), "Release assets must be an array"); invariant( release.assets.length === expectedNames.length, - `Expected 17 Release assets, found ${release.assets.length}`, + `Expected ${expectedNames.length} Release assets, found ${release.assets.length}`, ); const assets = new Map(); @@ -133,11 +151,14 @@ function readRemoteAssets(release, expectedNames, repository) { } function expectedPlatformAssets(version) { + const windowsTemplates = isPrereleaseVersion(version) + ? NSIS_PLATFORM_ASSET_TEMPLATES + : STABLE_WINDOWS_PLATFORM_ASSET_TEMPLATES; return Object.fromEntries( - Object.entries(PLATFORM_ASSET_TEMPLATES).map(([platform, template]) => [ - platform, - render(template, version), - ]), + Object.entries({ + ...COMMON_PLATFORM_ASSET_TEMPLATES, + ...windowsTemplates, + }).map(([platform, template]) => [platform, render(template, version)]), ); } @@ -218,7 +239,7 @@ async function verifyCompleteRelease({ const expectedKeys = Object.keys(platformAssets).sort(); invariant( platformKeys.length === expectedKeys.length, - `Expected 11 updater platform keys, found ${platformKeys.length}`, + `Expected ${expectedKeys.length} updater platform keys, found ${platformKeys.length}`, ); invariant( platformKeys.every((key, index) => key === expectedKeys[index]),