Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/slow-cherries-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@icebreakers/monorepo": patch
"repoctl": patch
---

Retry transient release API failures and reconcile partially published releases
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ on:
- prepare
- publish
- publish-unpublished
- reconcile
package:
description: Package used for unpublished-version recovery
required: false
Expand All @@ -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
Expand Down Expand Up @@ -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
6 changes: 4 additions & 2 deletions packages/monorepo/src/cli/commands/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode>', localize('auto / prepare / publish / publish-unpublished', 'auto / prepare / publish / publish-unpublished'), 'auto')
.option('--mode <mode>', localize('auto / prepare / publish / publish-unpublished / reconcile', 'auto / prepare / publish / publish-unpublished / reconcile'), 'auto')
.option('--package <name>', localize('Package used by publish-unpublished mode', 'publish-unpublished 使用的 package'))
.option('--version <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)
Expand All @@ -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 完成。'))
})
Expand Down
3 changes: 2 additions & 1 deletion packages/monorepo/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -100,6 +100,7 @@ export {
parsePublishSummary,
prepareStable,
publishStable,
reconcileRelease,
recoverUnpublished,
releaseCi,
releasePrerelease,
Expand Down
6 changes: 5 additions & 1 deletion packages/monorepo/src/commands/release/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
79 changes: 48 additions & 31 deletions packages/monorepo/src/commands/release/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>

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() {
Expand All @@ -48,40 +54,51 @@ export class GitHubClient implements GitHubOperations {

private async request<T>(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 {
Expand All @@ -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<GitHubPullRequest[]>('GET', `/pulls?${query.toString()}`)
Expand Down
3 changes: 3 additions & 0 deletions packages/monorepo/src/commands/release/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface GitHubClientOptions {
repository?: string
apiUrl?: string
fetch?: typeof fetch
retryAttempts?: number
retryDelay?: number
sleep?: (milliseconds: number) => Promise<void>
}

export interface EnsurePullRequestOptions {
Expand Down
2 changes: 2 additions & 0 deletions packages/monorepo/src/commands/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
89 changes: 89 additions & 0 deletions packages/monorepo/src/commands/release/reconcile.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubOperations, 'listReleases' | 'ensureRelease' | 'ensureTag' | 'enrichReleaseNote' | 'readReleasePullRequestContributors'>
}

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}`) }
}
3 changes: 2 additions & 1 deletion packages/monorepo/src/commands/release/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +20,7 @@ export interface ReleaseCiOptions extends ReleaseOptions {
mode?: ReleaseMode
packageName?: string
packageVersion?: string
dryRun?: boolean
github?: GitHubOperations
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down