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:
1 parent
7cd8143a24
commit
50448bddda
7 files changed
+221
-9
No files matched your search
+35
-5
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user