diff --git a/doc/release.md b/doc/release.md index 41247245..23c5eaa0 100644 --- a/doc/release.md +++ b/doc/release.md @@ -8,7 +8,7 @@ 发布只遵循四项原则: -- 平台 Tag 创建后不可修改或覆盖。 +- 平台 Tag **正式上线后**不可修改、覆盖或删除;未上线(构建/测试失败、中断)用 `release:abort` 删掉后可重打同版本,不要堆无效 Tag。 - 候选包必须从 Tag 导出的干净代码构建。 - 根据当前版本与上一已发布同平台版本的差异选择正式包 E2E,并测试实际安装后的候选包。 - 问题处理完后,发布测试过的同一份候选包,并验证公开下载和更新通道。 @@ -25,11 +25,13 @@ flowchart TD G --> H["发布同一候选包"] H --> I["验证下载、更新元数据和公开状态"] I --> J["清理本地旧候选包和临时工作区"] + C -->|未上线失败| K["release:abort 删除 Tag"] + K --> B ``` ### 发布准备与 Tag -在干净且与 `origin/main` 完全一致的 `main` 上开始发布。新版本必须高于该平台已有的最高版本。 +在干净且与 `origin/main` 完全一致的 `main` 上开始发布。新版本必须高于该平台**当前仍存在的**最高 Tag;若上一轮从未上线,先 abort 再重打同版本,不要另起一串版本号。 `release:start` 会先执行: @@ -50,6 +52,28 @@ pnpm release:start -- --platform windows --version X.Y.Z pnpm release:start -- --platform mac --version X.Y.Z ``` +### 未上线 Tag 中止与重打 + +Tag 已推送但**尚未**成为国内或海外 `releases/latest` 上的 live 版本时,可以中止并重打同版本: + +```bash +export NCURATOR_ALLOW_ABORT_TAG=abort-unpublished-tag +pnpm release:abort -- --tag mac-vX.Y.Z +# 或 +pnpm release:abort -- --tag windows-vX.Y.Z +``` + +`release:abort` 会: + +- 拒绝删除任一公开通道(`download.guanzhangai.cn` / `download.ncurator.com`)上已 live 的 Tag; +- 拒绝删除本地候选状态为 `published` 的 Tag; +- 删除 origin 与本地的该 annotated Tag; +- 删除该 Tag 的本地候选目录。 + +`main` 上的 `release: prepare …` 提交会保留(版本号与 migration 锁仍在)。abort 后重新执行同版本 `release:start` 即可重打 Tag。若误多打了更高版本的 prepare 提交且尚未需要,用常规 git 手段回退该提交后再 abort / 重打目标版本。 + +已正式上线的版本有问题:发布更高版本修复;不要 abort live Tag。 + ### 干净构建与候选包 prepare 阶段从 Tag 创建独立工作区,执行 `pnpm install --frozen-lockfile`、正式构建、安装包审计和 bundle 固化。它不复用当前源码、旧 `node_modules` 或旧 `dist`,也不上传任何对象存储。 @@ -102,7 +126,10 @@ macOS arm64 候选包必须通过选定的回归范围。项目不再构建、 发现问题后先定位原因,修复实现或测试,再重新判断修复带来的影响并复测相关正式包用例,直到没有发布阻塞问题。同一次发布中的后续改动不影响已通过用例时,无需重复执行这些用例。 -临时环境问题可以复测同一候选包;如果修改了产品、打包或测试代码,由于 Tag 不可变,必须创建更高版本的新 Tag 和候选包。 +临时环境问题可以复测同一候选包。若修改了产品、打包或测试代码: + +- **尚未上线**:`release:abort` 删除该 Tag 后,在修复后的 `main` 上重打**同版本**并重新 prepare; +- **已经上线**:必须发布更高版本的新 Tag 与候选包。 测试完成后应向用户说明本次版本、主要影响范围、执行的回归、结果和剩余风险。确认可发布后,publish 阶段只读取已经通过测试的候选包,不重新构建。 @@ -269,12 +296,13 @@ Windows 更新采用“先完成应用关闭、再启动安装器”的时序: ### 中断、恢复与回滚 -- Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`。 +- Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`;若源码已改且未上线,先 `release:abort` 再重打同版本。 - 候选包已生成但测试中断:基于原候选包重新执行 `--test-only`。 - 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:storage:finalize`。 +- 未上线整轮作废:`release:abort` 删除 Tag 后重打同版本,不要另起无用版本号。 - 已发布版本存在问题:通常发布更高版本修复包。回退下载通道不会让已安装客户端自动降级。 -同一个 Tag 的对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。 +同一个 Tag 在对象存储中若已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包;未上线需要换产物时走 abort + 同版本重打。 ## 4. 命令参考 @@ -314,6 +342,8 @@ pnpm test:e2e:package -- --bundle --case --case ```bash pnpm release:status -- --tag +export NCURATOR_ALLOW_ABORT_TAG=abort-unpublished-tag +pnpm release:abort -- --tag pnpm release:storage:plan pnpm release:storage:finalize -- --tag ``` diff --git a/package.json b/package.json index 0dce7e95..eb62b978 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:update:e2e": "node scripts/test/workflow.mjs --domain workbench --type e2e --case sidebar-app-update --case database-version-update-required --target source", "test:update:real:win": "node scripts/test/update-real-windows.mjs", "release:start": "node scripts/release/prepare.mjs", + "release:abort": "node scripts/release/abort.mjs", "release:win": "node scripts/release/windows.mjs", "release:doctor": "node scripts/release/doctor.mjs", "release:status": "node scripts/release/status.mjs", diff --git a/scripts/release/abort.mjs b/scripts/release/abort.mjs new file mode 100644 index 00000000..a5a0b033 --- /dev/null +++ b/scripts/release/abort.mjs @@ -0,0 +1,108 @@ +import { execFile } from 'node:child_process' +import { access, readFile, rm } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { resolveCandidateDirectory } from './candidate.mjs' +import { findLiveChannelsForTag } from './shared/live-tag.mjs' +import { parseReleaseTag } from './tag.mjs' + +const execFileAsync = promisify(execFile) +const ROOT = process.cwd() +const ABORT_TOKEN = 'abort-unpublished-tag' +const args = process.argv.slice(2) +const valueAfter = (name) => { + const index = args.indexOf(name) + return index >= 0 ? args[index + 1] : undefined +} + +const releaseTag = valueAfter('--tag') || '' +let family = '' +let version = '' +try { + ;({ family, version } = parseReleaseTag(releaseTag)) +} catch { + throw new Error('usage: node scripts/release/abort.mjs --tag ') +} + +if (process.env.NCURATOR_ALLOW_ABORT_TAG !== ABORT_TOKEN) { + throw new Error( + `aborting an unpublished release tag requires NCURATOR_ALLOW_ABORT_TAG=${ABORT_TOKEN}` + ) +} + +const branch = ( + await execFileAsync('git', ['branch', '--show-current'], { cwd: ROOT }) +).stdout.trim() +if (branch !== 'main') throw new Error(`release abort must run on main, not ${branch}`) + +const { stdout: worktreeStatus } = await execFileAsync('git', ['status', '--porcelain'], { + cwd: ROOT +}) +if (worktreeStatus.trim()) throw new Error('release abort requires a clean worktree') + +await execFileAsync('git', ['fetch', '--tags', 'origin', 'main'], { cwd: ROOT }) + +const liveChannels = await findLiveChannelsForTag(releaseTag) +if (liveChannels.length > 0) { + throw new Error( + `${releaseTag} is live on ${liveChannels.join(', ')}; published tags cannot be aborted` + ) +} + +const platforms = family === 'mac' ? ['macos-arm64'] : ['windows-x64'] +for (const platform of platforms) { + const directory = resolveCandidateDirectory(releaseTag, platform) + const statePath = path.join(directory, 'candidate-state.json') + const exists = await access(statePath) + .then(() => true) + .catch((error) => { + if (error?.code === 'ENOENT') return false + throw error + }) + if (!exists) continue + const state = JSON.parse(await readFile(statePath, 'utf8')) + if (state?.status === 'published') { + throw new Error( + `local candidate ${releaseTag} ${platform} is marked published; refuse to abort` + ) + } +} + +const remoteTags = ( + await execFileAsync('git', ['ls-remote', '--tags', 'origin', releaseTag], { cwd: ROOT }) +).stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) +const remoteExists = remoteTags.some((line) => line.endsWith(`refs/tags/${releaseTag}`)) + +const localExists = await execFileAsync('git', ['rev-parse', '--verify', '--quiet', releaseTag], { + cwd: ROOT +}) + .then(() => true) + .catch(() => false) + +if (!remoteExists && !localExists) { + throw new Error(`tag not found locally or on origin: ${releaseTag}`) +} + +if (remoteExists) { + await execFileAsync('git', ['push', 'origin', `:refs/tags/${releaseTag}`], { cwd: ROOT }) + process.stdout.write(`[release] deleted origin tag ${releaseTag}\n`) +} + +if (localExists) { + await execFileAsync('git', ['tag', '-d', releaseTag], { cwd: ROOT }) + process.stdout.write(`[release] deleted local tag ${releaseTag}\n`) +} + +const tagCandidateRoot = path.dirname(resolveCandidateDirectory(releaseTag, platforms[0])) +await rm(tagCandidateRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }) + +process.stdout.write( + `[release] aborted unpublished ${releaseTag}; re-run: pnpm release:start -- --platform ${family} --version ${version}\n` +) +process.stdout.write( + '[release] prepare commits on main are kept; only the tag and local candidates are removed\n' +) diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs index e31b5b93..7034ad3b 100644 --- a/scripts/release/prepare.mjs +++ b/scripts/release/prepare.mjs @@ -83,7 +83,9 @@ const platformVersions = platformTagsOutput .sort(compareReleaseVersions) const latestPlatformVersion = platformVersions.at(-1) if (latestPlatformVersion && compareReleaseVersions(version, latestPlatformVersion) <= 0) { - throw new Error(`new ${platform} version ${version} must be higher than ${latestPlatformVersion}`) + throw new Error( + `new ${platform} version ${version} must be higher than ${latestPlatformVersion}; abort an unpublished tag with pnpm release:abort -- --tag ${platform}-v${latestPlatformVersion} before reusing a version` + ) } const tagExists = await execFileAsync('git', ['rev-parse', '--verify', '--quiet', releaseTag], { @@ -91,7 +93,11 @@ const tagExists = await execFileAsync('git', ['rev-parse', '--verify', '--quiet' }) .then(() => true) .catch(() => false) -if (tagExists) throw new Error(`tag already exists: ${releaseTag}`) +if (tagExists) { + throw new Error( + `tag already exists: ${releaseTag}; abort it with pnpm release:abort -- --tag ${releaseTag} if it was never published live` + ) +} if (platform === 'windows') { const localInspection = await inspectLocalWindowsRelease(ROOT) diff --git a/scripts/release/shared/live-tag.mjs b/scripts/release/shared/live-tag.mjs new file mode 100644 index 00000000..a071bc66 --- /dev/null +++ b/scripts/release/shared/live-tag.mjs @@ -0,0 +1,30 @@ +import { + DOMESTIC_DOWNLOAD_BASE_URL, + GLOBAL_DOWNLOAD_BASE_URL +} from '../storage/config.mjs' +import { fetchWithRetry } from './http.mjs' +import { parseReleaseTag } from '../tag.mjs' + +const LATEST_MANIFEST = { + mac: 'releases/latest/macos.json', + windows: 'releases/latest/windows.json' +} + +export const RELEASE_PUBLIC_BASE_URLS = [DOMESTIC_DOWNLOAD_BASE_URL, GLOBAL_DOWNLOAD_BASE_URL] + +export const findLiveChannelsForTag = async (releaseTag, fetchImpl = fetchWithRetry) => { + const { family } = parseReleaseTag(releaseTag) + const manifestPath = LATEST_MANIFEST[family] + const live = [] + + for (const base of RELEASE_PUBLIC_BASE_URLS) { + const response = await fetchImpl(`${base}/${manifestPath}?check=${Date.now()}`, { + cache: 'no-store' + }).catch(() => undefined) + if (!response?.ok) continue + const manifest = await response.json().catch(() => undefined) + if (manifest?.tag === releaseTag) live.push(base) + } + + return live +} diff --git a/tests/case/unit/release/app-update-publish-config.test.ts b/tests/case/unit/release/app-update-publish-config.test.ts index ba8708f6..50ffe64e 100644 --- a/tests/case/unit/release/app-update-publish-config.test.ts +++ b/tests/case/unit/release/app-update-publish-config.test.ts @@ -72,16 +72,18 @@ describe('app update publish config', () => { }) it('runs the Windows pipeline on a local build backend only', async () => { - const [packageJsonText, windowsRelease, prepareRelease] = await Promise.all([ + const [packageJsonText, windowsRelease, prepareRelease, abortRelease] = await Promise.all([ readFile(path.resolve('package.json'), 'utf8'), readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'), - readFile(path.resolve('scripts/release/prepare.mjs'), 'utf8') + readFile(path.resolve('scripts/release/prepare.mjs'), 'utf8'), + readFile(path.resolve('scripts/release/abort.mjs'), 'utf8') ]) const packageJson = JSON.parse(packageJsonText) as { scripts: Record } expect(packageJson.scripts['release:win']).toContain('release/windows.mjs') + expect(packageJson.scripts['release:abort']).toContain('release/abort.mjs') expect(packageJson.scripts['release:win:auto']).toBeUndefined() expect(windowsRelease).toContain('inspectLocalWindowsRelease') expect(windowsRelease).not.toContain('dispatchWindowsRelease') @@ -98,6 +100,7 @@ describe('app update publish config', () => { expect(prepareRelease).toContain("valueAfter('--platform')") expect(prepareRelease).toContain("['mac', 'windows']") expect(prepareRelease).toContain('latestPlatformVersion') + expect(prepareRelease).toContain('release:abort') expect(prepareRelease).toContain('prepared for independent macOS release') expect(prepareRelease).toContain("'--exclude-group'") expect(prepareRelease).toContain("'live'") @@ -108,6 +111,10 @@ describe('app update publish config', () => { expect(prepareRelease).not.toContain('test:update:e2e') expect(prepareRelease).not.toContain('--package-e2e') expect(prepareRelease).not.toContain('--actions') + expect(abortRelease).toContain('NCURATOR_ALLOW_ABORT_TAG') + expect(abortRelease).toContain('abort-unpublished-tag') + expect(abortRelease).toContain('findLiveChannelsForTag') + expect(abortRelease).toContain(`:refs/tags/`) }) it('finalizes Windows and macOS release channels independently with a live-publish lock', async () => { diff --git a/tests/case/unit/release/live-tag.test.ts b/tests/case/unit/release/live-tag.test.ts new file mode 100644 index 00000000..c8e370ab --- /dev/null +++ b/tests/case/unit/release/live-tag.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { findLiveChannelsForTag } from '../../../../scripts/release/shared/live-tag.mjs' + +describe('release live tag detection', () => { + it('reports channels whose latest manifest points at the tag', async () => { + const calls: string[] = [] + const fetchImpl = async (url: string) => { + calls.push(url) + const live = url.includes('guanzhangai.cn') && url.includes('macos.json') + return { + ok: true, + json: async () => (live ? { tag: 'mac-v1.0.0', version: '1.0.0' } : { tag: 'mac-v0.9.0' }) + } + } + + await expect(findLiveChannelsForTag('mac-v1.0.0', fetchImpl)).resolves.toEqual([ + 'https://download.guanzhangai.cn' + ]) + expect(calls.some((url) => url.includes('download.guanzhangai.cn'))).toBe(true) + expect(calls.some((url) => url.includes('download.ncurator.com'))).toBe(true) + }) + + it('returns empty when the tag is not live on any channel', async () => { + const fetchImpl = async () => ({ + ok: true, + json: async () => ({ tag: 'mac-v0.1.0' }) + }) + await expect(findLiveChannelsForTag('mac-v1.0.0', fetchImpl)).resolves.toEqual([]) + }) +})