fix: allow aborting unpublished release tags for same-version retake

Failed pre-publish tags can be deleted and reused instead of stacking versions; docs and prepare errors point at release:abort.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hyandCursor committed 2026-09-11 17:59:14 +08:00
1 parent 7cd8143a24
commit 50448bddda
7 files changed
+221 -9

No files matched your search

+35 -5
View File
@@ -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 <release-bundle.json> --case <caseId-1> --case
```bash
pnpm release:status -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
export NCURATOR_ALLOW_ABORT_TAG=abort-unpublished-tag
pnpm release:abort -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
pnpm release:storage:plan
pnpm release:storage:finalize -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
```
+1
View File
@@ -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",
+108
View File
@@ -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 <mac-vX.Y.Z|windows-vX.Y.Z>')
}
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'
)
+8 -2
View File
@@ -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)
+30
View File
@@ -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
}
@@ -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<string, string>
}
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 () => {
+30
View File
@@ -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([])
})
})