From 603b928dad334158de3c721c960b84bcbd000d1b Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Sun, 13 Sep 2026 22:26:34 +0800 Subject: [PATCH 1/2] feat(release): recover partial publish and release failures --- .changeset/slow-cherries-throw.md | 6 ++ .github/workflows/release.yml | 7 ++ packages/monorepo/src/cli/commands/release.ts | 6 +- packages/monorepo/src/commands/index.ts | 3 +- packages/monorepo/src/commands/release/ci.ts | 6 +- .../src/commands/release/github/client.ts | 79 +++++++++------- .../src/commands/release/github/types.ts | 3 + .../monorepo/src/commands/release/index.ts | 2 + .../src/commands/release/reconcile.ts | 89 +++++++++++++++++++ .../monorepo/src/commands/release/types.ts | 3 +- 10 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 .changeset/slow-cherries-throw.md create mode 100644 packages/monorepo/src/commands/release/reconcile.ts diff --git a/.changeset/slow-cherries-throw.md b/.changeset/slow-cherries-throw.md new file mode 100644 index 000000000..d3cb74ad8 --- /dev/null +++ b/.changeset/slow-cherries-throw.md @@ -0,0 +1,6 @@ +--- +"@icebreakers/monorepo": patch +"repoctl": patch +--- + +Retry transient release API failures and reconcile partially published releases diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f482390c6..55b963b1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ on: - prepare - publish - publish-unpublished + - reconcile package: description: Package used for unpublished-version recovery required: false @@ -29,6 +30,11 @@ on: description: Version used for unpublished-version recovery required: false type: string + dry-run: + description: Preview release reconciliation without changes + required: false + default: false + type: boolean env: HUSKY: 0 @@ -75,4 +81,5 @@ jobs: REPO_RELEASE_MODE: ${{ inputs.mode || 'auto' }} REPO_RELEASE_PACKAGE: ${{ inputs.package }} REPO_RELEASE_VERSION: ${{ inputs.version }} + REPO_RELEASE_DRY_RUN: ${{ inputs.dry-run }} run: pnpm exec repo release ci diff --git a/packages/monorepo/src/cli/commands/release.ts b/packages/monorepo/src/cli/commands/release.ts index 54e938b42..526ef6eda 100644 --- a/packages/monorepo/src/cli/commands/release.ts +++ b/packages/monorepo/src/cli/commands/release.ts @@ -28,10 +28,11 @@ export function registerReleaseCommands(program: Command, cwd: string) { releaseCommand.command('ci') .description(localize('Prepare, publish, or recover versions in CI', '在 CI 中自动准备、发布和恢复版本')) - .option('--mode ', localize('auto / prepare / publish / publish-unpublished', 'auto / prepare / publish / publish-unpublished'), 'auto') + .option('--mode ', localize('auto / prepare / publish / publish-unpublished / reconcile', 'auto / prepare / publish / publish-unpublished / reconcile'), 'auto') .option('--package ', localize('Package used by publish-unpublished mode', 'publish-unpublished 使用的 package')) .option('--version ', localize('Version used by publish-unpublished mode', 'publish-unpublished 使用的版本')) - .action(async (opts: { mode?: 'auto' | 'prepare' | 'publish' | 'publish-unpublished', package?: string, version?: string }) => { + .option('--dry-run', localize('Preview reconcile changes without updating GitHub', '只预览 reconcile 变更,不更新 GitHub')) + .action(async (opts: { mode?: 'auto' | 'prepare' | 'publish' | 'publish-unpublished' | 'reconcile', package?: string, version?: string, dryRun?: boolean }) => { await runReleaseAction(async () => { const { releaseCi } = await import('@/commands') const releaseOptions = await resolveReleaseOptions(cwd) @@ -40,6 +41,7 @@ export function registerReleaseCommands(program: Command, cwd: string) { ...(opts.mode ? { mode: opts.mode } : {}), ...(opts.package ? { packageName: opts.package } : {}), ...(opts.version ? { packageVersion: opts.version } : {}), + ...(opts.dryRun ? { dryRun: true } : {}), }) logger.success(localize('Release CI finished.', 'Release CI 完成。')) }) diff --git a/packages/monorepo/src/commands/index.ts b/packages/monorepo/src/commands/index.ts index cdc0fe1b4..b1f9958f8 100644 --- a/packages/monorepo/src/commands/index.ts +++ b/packages/monorepo/src/commands/index.ts @@ -20,7 +20,7 @@ import { runDoctor } from './doctor' import { collectEnvInfo, collectEnvPaths, collectEnvSnapshot, collectEnvSupportBundle } from './env' import { init, initMetadata, initTooling, initToolingTargets, normalizeInitToolingTargets } from './init' import { setVscodeBinaryMirror } from './mirror' -import { createReleasePullRequest, enterPrerelease, exitPrerelease, GitHubApiError, GitHubClient, parsePublishSummary, prepareStable, publishStable, recoverUnpublished, releaseCi, releasePrerelease, releaseStable, repairReleaseNotes } from './release' +import { createReleasePullRequest, enterPrerelease, exitPrerelease, GitHubApiError, GitHubClient, parsePublishSummary, prepareStable, publishStable, reconcileRelease, recoverUnpublished, releaseCi, releasePrerelease, releaseStable, repairReleaseNotes } from './release' import { getSkillTargetPaths, skillTargets, syncSkills } from './skills' import { checkTemplates } from './templates' import { upgradeMonorepo } from './upgrade' @@ -100,6 +100,7 @@ export { parsePublishSummary, prepareStable, publishStable, + reconcileRelease, recoverUnpublished, releaseCi, releasePrerelease, diff --git a/packages/monorepo/src/commands/release/ci.ts b/packages/monorepo/src/commands/release/ci.ts index a35f9d38c..859295550 100644 --- a/packages/monorepo/src/commands/release/ci.ts +++ b/packages/monorepo/src/commands/release/ci.ts @@ -9,6 +9,7 @@ import { GitHubClient } from './github' import { runAfterPublishHooks, runQualityScripts, runReleaseHooks } from './hooks' import { releasePrerelease } from './prerelease' import { publishWithRetry } from './publish' +import { reconcileRelease } from './reconcile' import { capture, clearPublishSummary, getReleaseEnv, hasPendingIntents, readPublishSummary, resolveBranch, run } from './shared' import { prepareStable, publishStable } from './stable' import { readReleaseTriggerContext, shouldRunRelease } from './trigger' @@ -211,8 +212,11 @@ export async function releaseCi(options: ReleaseCiOptions) { if (mode === 'publish-unpublished') { return recoverUnpublished(options) } + if (mode === 'reconcile') { + return reconcileRelease({ ...options, dryRun: options.dryRun ?? getReleaseEnv(options)['REPO_RELEASE_DRY_RUN'] === 'true' }) + } if (mode !== 'auto') { - throw new ReleaseCommandError(`unknown release CI mode ${mode}; expected auto, prepare, publish, or publish-unpublished`) + throw new ReleaseCommandError(`unknown release CI mode ${mode}; expected auto, prepare, publish, publish-unpublished, or reconcile`) } // GitHub push events use the same trigger contract as Changesets. Local and diff --git a/packages/monorepo/src/commands/release/github/client.ts b/packages/monorepo/src/commands/release/github/client.ts index c1ebb8654..b49ec979b 100644 --- a/packages/monorepo/src/commands/release/github/client.ts +++ b/packages/monorepo/src/commands/release/github/client.ts @@ -28,12 +28,18 @@ export class GitHubClient implements GitHubOperations { private readonly repository: string | undefined private readonly apiUrl: string private readonly requestFetch: typeof fetch + private readonly retryAttempts: number + private readonly retryDelay: number + private readonly sleep: (milliseconds: number) => Promise constructor(options: GitHubClientOptions = {}) { this.token = options.token ?? process.env['GITHUB_TOKEN'] this.repository = options.repository ?? process.env['GITHUB_REPOSITORY'] this.apiUrl = (options.apiUrl ?? process.env['GITHUB_API_URL'] ?? 'https://api.github.com').replace(/\/$/, '') this.requestFetch = options.fetch ?? globalThis.fetch + this.retryAttempts = Math.max(1, options.retryAttempts ?? 3) + this.retryDelay = Math.max(0, options.retryDelay ?? 1_000) + this.sleep = options.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))) } private getRepository() { @@ -48,40 +54,51 @@ export class GitHubClient implements GitHubOperations { private async request(method: string, endpoint: string, body?: unknown): Promise<{ status: number, data: T | undefined }> { const repository = this.getRepository() - let response: Response - try { - response = await this.requestFetch(`${this.apiUrl}/repos/${repository}${endpoint}`, { - method, - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${this.token}`, - 'X-GitHub-Api-Version': '2022-11-28', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }) - } - catch (error) { - const detail = error instanceof Error ? error.message : String(error) - throw new GitHubApiError(`GitHub API request ${method} ${endpoint} failed: ${detail}. Check network access and GITHUB_API_URL.`, 0) - } - const text = await response.text() - let data: T | undefined - if (text) { + for (let attempt = 1; attempt <= this.retryAttempts; attempt += 1) { + let response: Response try { - data = JSON.parse(text) as T + response = await this.requestFetch(`${this.apiUrl}/repos/${repository}${endpoint}`, { + method, + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${this.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } - catch { - throw new GitHubApiError(`GitHub API returned invalid JSON for ${method} ${endpoint}`, response.status, text) + catch (error) { + if (attempt < this.retryAttempts) { + await this.sleep(this.retryDelay * 2 ** (attempt - 1)) + continue + } + const detail = error instanceof Error ? error.message : String(error) + throw new GitHubApiError(`GitHub API request ${method} ${endpoint} failed: ${detail}. Check network access and GITHUB_API_URL.`, 0) } + const text = await response.text() + let data: T | undefined + if (text) { + try { + data = JSON.parse(text) as T + } + catch { + throw new GitHubApiError(`GitHub API returned invalid JSON for ${method} ${endpoint}`, response.status, text) + } + } + if (!response.ok) { + const message = typeof data === 'object' && data !== null && 'message' in data + ? String((data as { message: unknown }).message) + : text || response.statusText + if ((response.status === 429 || response.status >= 500) && attempt < this.retryAttempts) { + await this.sleep(this.retryDelay * 2 ** (attempt - 1)) + continue + } + throw new GitHubApiError(`GitHub API ${method} ${endpoint} failed (${response.status}): ${message}`, response.status, text) + } + return { status: response.status, data } } - if (!response.ok) { - const message = typeof data === 'object' && data !== null && 'message' in data - ? String((data as { message: unknown }).message) - : text || response.statusText - throw new GitHubApiError(`GitHub API ${method} ${endpoint} failed (${response.status}): ${message}`, response.status, text) - } - return { status: response.status, data } + throw new GitHubApiError(`GitHub API request ${method} ${endpoint} exhausted retries`, 0) } private getRequest(): GitHubRequest { @@ -107,7 +124,7 @@ export class GitHubClient implements GitHubOperations { return created.data } catch (error) { - if (!(error instanceof GitHubApiError) || error.status !== 422) { + if (!(error instanceof GitHubApiError) || (![0, 429, 500, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511].includes(error.status) && error.status !== 422)) { throw error } const recovered = await this.request('GET', `/pulls?${query.toString()}`) diff --git a/packages/monorepo/src/commands/release/github/types.ts b/packages/monorepo/src/commands/release/github/types.ts index 74dc5b541..10f6d6e6c 100644 --- a/packages/monorepo/src/commands/release/github/types.ts +++ b/packages/monorepo/src/commands/release/github/types.ts @@ -35,6 +35,9 @@ export interface GitHubClientOptions { repository?: string apiUrl?: string fetch?: typeof fetch + retryAttempts?: number + retryDelay?: number + sleep?: (milliseconds: number) => Promise } export interface EnsurePullRequestOptions { diff --git a/packages/monorepo/src/commands/release/index.ts b/packages/monorepo/src/commands/release/index.ts index 37b9396b5..4ab0cb5e4 100644 --- a/packages/monorepo/src/commands/release/index.ts +++ b/packages/monorepo/src/commands/release/index.ts @@ -6,6 +6,8 @@ export { GitHubApiError, GitHubClient } from './github' export type { CloseLegacyPullRequestsOptions, EnsurePullRequestOptions, EnsureReleaseOptions, EnsureTagOptions, GitHubClientOptions, GitHubOperations, GitHubRelease, UpdateReleaseOptions } from './github' export { runAfterPublishHooks, runQualityScripts, runReleaseHooks } from './hooks' export { enterPrerelease, exitPrerelease, releasePrerelease } from './prerelease' +export { reconcileRelease } from './reconcile' +export type { ReleaseReconcileOptions } from './reconcile' export { repairReleaseNotes } from './repair' export type { RepairReleaseNotesOptions } from './repair' export { parsePublishSummary, readPublishSummary } from './shared' diff --git a/packages/monorepo/src/commands/release/reconcile.ts b/packages/monorepo/src/commands/release/reconcile.ts new file mode 100644 index 000000000..2b60df3d8 --- /dev/null +++ b/packages/monorepo/src/commands/release/reconcile.ts @@ -0,0 +1,89 @@ +import type { GitHubOperations } from './github' +import type { PublishedPackage, ReleaseOptions } from './types' +import { spawnSync } from 'node:child_process' +import { getWorkspacePackages } from '../../core/workspace' +import { buildReleaseNoteDocument, renderGitHubRelease } from './body' +import { GitHubClient } from './github' +import { capture, getReleaseEnv } from './shared' + +export interface ReleaseReconcileOptions extends ReleaseOptions { + packageName?: string + packageVersion?: string + dryRun?: boolean + github?: Pick +} + +function remoteTagExists(tag: string, options: ReleaseOptions) { + return (options.spawn ?? spawnSync)('git', ['ls-remote', '--exit-code', '--refs', 'origin', `refs/tags/${tag}`], { + cwd: options.cwd, + encoding: 'utf8', + shell: false, + stdio: 'ignore', + }).status === 0 +} + +function resolveTarget(options: ReleaseOptions) { + return getReleaseEnv(options)['GITHUB_SHA']?.trim() || capture('git', ['rev-parse', 'HEAD'], options) +} + +function isPublished(pkg: PublishedPackage, options: ReleaseOptions) { + const result = (options.spawn ?? spawnSync)('npm', ['view', `${pkg.name}@${pkg.version}`, 'version'], { + cwd: options.cwd, + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + }) + return result.status === 0 && String(result.stdout ?? '').trim() === pkg.version +} + +export async function reconcileRelease(options: ReleaseReconcileOptions) { + const github = options.github ?? new GitHubClient() + if (!github.listReleases || !github.ensureRelease) { + throw new Error('GitHub release reconcile requires listReleases and ensureRelease operations') + } + const releases = await github.listReleases() + const existing = new Map(releases.map(release => [release.tag_name, release])) + const workspacePackages = await getWorkspacePackages(options.cwd) + const packages: PublishedPackage[] = workspacePackages.flatMap(({ manifest }) => ( + typeof manifest.name === 'string' && typeof manifest.version === 'string' + ? [{ name: manifest.name, version: manifest.version }] + : [] + )).filter(pkg => (!options.packageName || pkg.name === options.packageName) && (!options.packageVersion || pkg.version === options.packageVersion)).filter(pkg => isPublished(pkg, options)) + const env = getReleaseEnv(options) + const metadata: { repository?: string, serverUrl?: string } = {} + if (env['GITHUB_REPOSITORY']) { + metadata.repository = env['GITHUB_REPOSITORY'] + } + if (env['GITHUB_SERVER_URL']) { + metadata.serverUrl = env['GITHUB_SERVER_URL'] + } + const document = await buildReleaseNoteDocument(options.cwd, undefined, metadata, new Set(packages.map(pkg => pkg.name))) + const repaired: string[] = [] + const pending: string[] = [] + for (const pkg of packages) { + const tag = `${pkg.name}@${pkg.version}` + const release = existing.get(tag) + const packageDocument = { + ...document, + packages: document.packages.filter(item => item.name === pkg.name && item.version === pkg.version), + entries: document.entries.filter(entry => entry.packageName === pkg.name && entry.version === pkg.version), + compareUrls: document.compareUrls.filter(url => url.includes(encodeURIComponent(`${pkg.name}@`))), + } + const body = renderGitHubRelease(packageDocument) + const needsTag = !remoteTagExists(tag, options) + const needsRelease = !release || release.name !== tag || release.body !== body + if (!needsTag && !needsRelease) { + continue + } + pending.push(tag) + if (options.dryRun) { + continue + } + if (needsTag && github.ensureTag) { + await github.ensureTag({ tag, target: resolveTarget(options) }) + } + await github.ensureRelease({ tag, target: resolveTarget(options), name: tag, body }) + repaired.push(tag) + } + return { repaired, pending, skipped: packages.filter(pkg => !pending.includes(`${pkg.name}@${pkg.version}`)).map(pkg => `${pkg.name}@${pkg.version}`) } +} diff --git a/packages/monorepo/src/commands/release/types.ts b/packages/monorepo/src/commands/release/types.ts index 8a662220a..3ebde0da6 100644 --- a/packages/monorepo/src/commands/release/types.ts +++ b/packages/monorepo/src/commands/release/types.ts @@ -4,7 +4,7 @@ import type { GitHubOperations } from './github' export const prereleaseBranches = new Set(['alpha', 'beta', 'rc', 'next']) -export type ReleaseMode = 'auto' | 'prepare' | 'publish' | 'publish-unpublished' +export type ReleaseMode = 'auto' | 'prepare' | 'publish' | 'publish-unpublished' | 'reconcile' export interface ReleaseOptions { cwd: string @@ -20,6 +20,7 @@ export interface ReleaseCiOptions extends ReleaseOptions { mode?: ReleaseMode packageName?: string packageVersion?: string + dryRun?: boolean github?: GitHubOperations } From cc20ac17a5e556bd9aafaed16fbc9dbede7f4aed Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Sun, 13 Sep 2026 23:13:34 +0800 Subject: [PATCH 2/2] fix(ci): accept release reconcile workflow mode --- scripts/check-workflows.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-workflows.mjs b/scripts/check-workflows.mjs index 41a5b95bb..bdeddaeec 100644 --- a/scripts/check-workflows.mjs +++ b/scripts/check-workflows.mjs @@ -56,7 +56,7 @@ function checkReleaseWorkflow() { assert.ok(source.startsWith('# repoctl-managed: release/v2\n')) assert.deepEqual(branches, ['main', 'alpha', 'beta', 'rc', 'next']) - assert.deepEqual(modes, ['auto', 'prepare', 'publish', 'publish-unpublished']) + assert.deepEqual(modes, ['auto', 'prepare', 'publish', 'publish-unpublished', 'reconcile']) assert.equal(workflow.permissions?.contents, 'write') assert.equal(workflow.permissions?.['pull-requests'], 'write') assert.equal(workflow.permissions?.['id-token'], 'write')