@jakobm/peacekeeper 0.0.0-stage → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,527 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const yaml = require('yaml');
6
+ const { githubApi, requiredChecks, verifyProtection } = require('./release-protection');
7
+ const { CONFIG_FILE, projectConfig } = require('./project-config');
8
+ const requiredCondition = "${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}";
9
+ const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
10
+ const pollIntervalMs = 5_000;
11
+ const workflowTimeoutMs = 20 * 60_000;
12
+ const registryTimeoutMs = 10 * 60_000;
13
+ class ReleaseError extends Error {
14
+ }
15
+ function execute(spawnSync, environment, command, args, options) {
16
+ const actualCommand = command === 'npm' && environment.npm_execpath ? process.execPath : command;
17
+ const actualArgs = command === 'npm' && environment.npm_execpath
18
+ ? [environment.npm_execpath, ...args]
19
+ : args;
20
+ const result = spawnSync(actualCommand, actualArgs, {
21
+ encoding: 'utf8',
22
+ maxBuffer: 10 * 1024 * 1024,
23
+ ...options,
24
+ });
25
+ if (result.error)
26
+ throw new ReleaseError(`${command} could not start: ${result.error.message}`);
27
+ return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
28
+ }
29
+ function checkedOutput(exec, command, args, cwd) {
30
+ const result = exec(command, args, { cwd });
31
+ if (result.status !== 0) {
32
+ throw new ReleaseError(`${command} ${args.slice(0, 2).join(' ')} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
33
+ }
34
+ return result.stdout;
35
+ }
36
+ function checked(exec, command, args, cwd) {
37
+ return checkedOutput(exec, command, args, cwd).trim();
38
+ }
39
+ function readPackage(cwd) {
40
+ const manifestPath = path.join(cwd, 'package.json');
41
+ if (!fs.existsSync(manifestPath))
42
+ throw new ReleaseError('No package.json exists at the repository root.');
43
+ let manifest;
44
+ try {
45
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
46
+ }
47
+ catch (error) {
48
+ throw new ReleaseError(`Cannot read package.json: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ if (typeof manifest.version !== 'string' || !stableVersion.test(manifest.version)) {
51
+ throw new ReleaseError('package.json needs a stable MAJOR.MINOR.PATCH version.');
52
+ }
53
+ if (typeof manifest.name !== 'string' || !manifest.name) {
54
+ throw new ReleaseError('package.json needs a package name for npm verification.');
55
+ }
56
+ if (manifest.workspaces)
57
+ throw new ReleaseError('Workspace releases are not supported.');
58
+ return { name: manifest.name, version: manifest.version };
59
+ }
60
+ function ensureLockfilesMatch(cwd, version) {
61
+ for (const filename of ['package-lock.json', 'npm-shrinkwrap.json']) {
62
+ const lockPath = path.join(cwd, filename);
63
+ if (!fs.existsSync(lockPath))
64
+ continue;
65
+ let lock;
66
+ try {
67
+ lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
68
+ }
69
+ catch (error) {
70
+ throw new ReleaseError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
72
+ if (lock.version !== version || (lock.packages && lock.packages['']?.version !== version)) {
73
+ throw new ReleaseError(`${filename} does not match package.json version ${version}.`);
74
+ }
75
+ }
76
+ }
77
+ function nextVersion(version, bump) {
78
+ const match = stableVersion.exec(version);
79
+ if (!match)
80
+ throw new ReleaseError(`Unsupported version: ${version}`);
81
+ const [major, minor, patch] = match.slice(1).map(BigInt);
82
+ if (bump === 'major')
83
+ return `${major + 1n}.0.0`;
84
+ if (bump === 'minor')
85
+ return `${major}.${minor + 1n}.0`;
86
+ if (bump === 'patch')
87
+ return `${major}.${minor}.${patch + 1n}`;
88
+ throw new ReleaseError(`Choose one release type: major, minor, or patch (got ${bump || 'none'}).`);
89
+ }
90
+ function matchesTag(patterns, tag) {
91
+ let accepted = false;
92
+ for (const pattern of patterns) {
93
+ if (typeof pattern !== 'string')
94
+ return false;
95
+ const negative = pattern.startsWith('!');
96
+ const glob = negative ? pattern.slice(1) : pattern;
97
+ const expression = new RegExp(`^${glob.split('*').map((part) => part.replace(/[|\\{}()[\]^$+?.]/gu, '\\$&')).join('.*')}$`, 'u');
98
+ if (expression.test(tag))
99
+ accepted = !negative;
100
+ }
101
+ return accepted;
102
+ }
103
+ function acceptsTag(on, tag) {
104
+ if (on === 'push')
105
+ return true;
106
+ if (Array.isArray(on))
107
+ return on.includes('push');
108
+ if (!on || typeof on !== 'object' || !Object.hasOwn(on, 'push'))
109
+ return false;
110
+ const push = on.push;
111
+ if (push === null || push === undefined)
112
+ return true;
113
+ if (typeof push !== 'object' || Array.isArray(push))
114
+ return false;
115
+ const filters = push;
116
+ if ((filters.branches || filters['branches-ignore']) && !filters.tags && !filters['tags-ignore'])
117
+ return false;
118
+ if (filters.tags)
119
+ return Array.isArray(filters.tags) && matchesTag(filters.tags, tag);
120
+ if (filters['tags-ignore'])
121
+ return Array.isArray(filters['tags-ignore']) && !matchesTag(filters['tags-ignore'], tag);
122
+ return true;
123
+ }
124
+ function onlyReleaseTagPushes(on) {
125
+ if (!on || typeof on !== 'object' || Array.isArray(on)
126
+ || Object.keys(on).length !== 1 || !Object.hasOwn(on, 'push')) return false;
127
+ const push = on.push;
128
+ if (!push || typeof push !== 'object' || Array.isArray(push)
129
+ || ['branches', 'branches-ignore', 'tags-ignore'].some(key => Object.hasOwn(push, key))) return false;
130
+ return Array.isArray(push.tags) && push.tags.some(pattern => typeof pattern === 'string' && pattern.startsWith('v'))
131
+ && push.tags.every(pattern => typeof pattern === 'string' && (pattern.startsWith('!') || pattern.startsWith('v')));
132
+ }
133
+ function inspectWorkflow(cwd, tag) {
134
+ const directory = path.join(cwd, '.github', 'workflows');
135
+ const files = fs.existsSync(directory)
136
+ ? fs.readdirSync(directory).filter((name) => /\.ya?ml$/u.test(name)).sort()
137
+ : [];
138
+ const matches = [];
139
+ for (const file of files) {
140
+ const relativePath = `.github/workflows/${file}`;
141
+ let document;
142
+ try {
143
+ document = yaml.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
144
+ }
145
+ catch (error) {
146
+ throw new ReleaseError(`Cannot parse ${relativePath}: ${error instanceof Error ? error.message : String(error)}`);
147
+ }
148
+ if (!document?.jobs || !Object.hasOwn(document.jobs, 'publish'))
149
+ continue;
150
+ const job = document.jobs.publish;
151
+ if (!job || (job.if !== requiredCondition && (Object.hasOwn(job, 'if') || !onlyReleaseTagPushes(document.on)))) {
152
+ throw new ReleaseError(`${relativePath}: publish job needs the exact required if condition unless the workflow only triggers on v-prefixed tag pushes.`);
153
+ }
154
+ if (!Array.isArray(job.steps) || !job.steps.some((step) => step?.run === 'npm publish')) {
155
+ throw new ReleaseError(`${relativePath}: publish job needs a step whose run is exactly npm publish.`);
156
+ }
157
+ const name = job.name || 'publish';
158
+ if (typeof name !== 'string' || name.includes('${{')) {
159
+ throw new ReleaseError(`${relativePath}: publish job needs a static display name.`);
160
+ }
161
+ matches.push({ path: relativePath, name, on: document.on });
162
+ }
163
+ if (matches.length !== 1) {
164
+ throw new ReleaseError(`Expected exactly one publish job; found ${matches.length}.`);
165
+ }
166
+ if (!acceptsTag(matches[0].on, tag)) {
167
+ throw new ReleaseError(`${matches[0].path} does not trigger on push of ${tag}.`);
168
+ }
169
+ return matches[0];
170
+ }
171
+ function originRepository(remote) {
172
+ const scp = /^git@([^:]+):([^/]+\/[^/]+?)(?:\.git)?$/u.exec(remote);
173
+ let host;
174
+ let slug;
175
+ if (scp) {
176
+ [, host, slug] = scp;
177
+ }
178
+ else {
179
+ let url;
180
+ try {
181
+ url = new URL(remote);
182
+ }
183
+ catch {
184
+ throw new ReleaseError('origin must be a GitHub HTTPS or SSH remote.');
185
+ }
186
+ if (!['https:', 'ssh:'].includes(url.protocol)) {
187
+ throw new ReleaseError('origin must be a GitHub HTTPS or SSH remote.');
188
+ }
189
+ host = url.hostname;
190
+ slug = url.pathname.replace(/^\//u, '').replace(/\.git$/u, '');
191
+ }
192
+ if (!/^[\w.-]+\/[\w.-]+$/u.test(slug)) {
193
+ throw new ReleaseError(`Cannot identify a GitHub owner/repository from origin: ${remote}`);
194
+ }
195
+ return { host, slug };
196
+ }
197
+ function github(exec, cwd, host, endpoint, parameters) {
198
+ const args = ['api', '--hostname', host, '--method', 'GET', endpoint];
199
+ for (const [key, value] of Object.entries(parameters))
200
+ args.push('-f', `${key}=${value}`);
201
+ let response;
202
+ try {
203
+ response = JSON.parse(checked(exec, 'gh', args, cwd));
204
+ }
205
+ catch (error) {
206
+ if (error instanceof ReleaseError)
207
+ throw error;
208
+ throw new ReleaseError(`GitHub returned invalid JSON for ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
209
+ }
210
+ return response;
211
+ }
212
+ function ensureClean(exec, cwd) {
213
+ if (checked(exec, 'git', ['status', '--porcelain=v1', '--untracked-files=all'], cwd)) {
214
+ throw new ReleaseError('Repository has uncommitted or untracked work.');
215
+ }
216
+ }
217
+ function prepare(exec, cwd, bump, log) {
218
+ const gitRoot = checked(exec, 'git', ['rev-parse', '--show-toplevel'], cwd);
219
+ if (fs.realpathSync(gitRoot) !== fs.realpathSync(cwd)) {
220
+ throw new ReleaseError(`Release must run from its repository root (${gitRoot}).`);
221
+ }
222
+ ensureClean(exec, cwd);
223
+ const remote = checked(exec, 'git', ['remote', 'get-url', 'origin'], cwd);
224
+ const { host, slug } = originRepository(remote);
225
+ const repository = github(exec, cwd, host, `repos/${slug}`, {});
226
+ const configured = fs.existsSync(path.join(cwd, CONFIG_FILE))
227
+ ? projectConfig(cwd).git.releaseMergeMethod : undefined;
228
+ const allowed = ['merge', 'rebase', 'squash'].filter((method) =>
229
+ repository[{ merge: 'allow_merge_commit', rebase: 'allow_rebase_merge', squash: 'allow_squash_merge' }[method]] === true);
230
+ if (!allowed.length)
231
+ throw new ReleaseError('No supported PR merge method is enabled.');
232
+ const mergeMethod = allowed.length === 1 ? allowed[0] : configured || allowed[0];
233
+ if (!allowed.includes(mergeMethod))
234
+ throw new ReleaseError(`Configured release merge method ${mergeMethod} is not enabled on GitHub.`);
235
+ const defaultBranch = repository.default_branch;
236
+ if (typeof defaultBranch !== 'string' || !defaultBranch) {
237
+ throw new ReleaseError('GitHub did not provide the default branch.');
238
+ }
239
+ verifyProtection({ api: githubApi(exec, cwd, host), slug, branch: defaultBranch, checks: requiredChecks(cwd) });
240
+ checked(exec, 'git', ['fetch', 'origin', defaultBranch], cwd);
241
+ const manifest = JSON.parse(checkedOutput(exec, 'git', ['show', `origin/${defaultBranch}:package.json`], cwd));
242
+ const packageInfo = { name: manifest.name, version: manifest.version };
243
+ const version = nextVersion(packageInfo.version, bump);
244
+ const tag = `v${version}`;
245
+ const localTag = exec('git', ['show-ref', '--verify', '--quiet', `refs/tags/${tag}`], { cwd });
246
+ if (localTag.status === 0)
247
+ throw new ReleaseError(`Local tag ${tag} already exists.`);
248
+ if (localTag.status !== 1)
249
+ throw new ReleaseError(`Could not check local tag ${tag}: ${localTag.stderr.trim()}`);
250
+ const remoteTag = exec('git', ['ls-remote', '--exit-code', '--tags', 'origin', `refs/tags/${tag}`], { cwd });
251
+ if (remoteTag.status === 0)
252
+ throw new ReleaseError(`Origin tag ${tag} already exists.`);
253
+ if (remoteTag.status !== 2)
254
+ throw new ReleaseError(`Could not check origin tag ${tag}: ${remoteTag.stderr.trim()}`);
255
+ log(`Validated ${packageInfo.name}: ${packageInfo.version} -> ${version} on ${defaultBranch}.`);
256
+ return { host, slug, defaultBranch, name: packageInfo.name, version, tag, mergeMethod };
257
+ }
258
+ function makeVersionCommit(exec, cwd, bump, version) {
259
+ checked(exec, 'npm', ['version', bump, '--no-git-tag-version', '--ignore-scripts'], cwd);
260
+ if (readPackage(cwd).version !== version) {
261
+ throw new ReleaseError(`npm did not produce the expected version ${version}; inspect the working tree.`);
262
+ }
263
+ ensureLockfilesMatch(cwd, version);
264
+ const changed = checkedOutput(exec, 'git', ['status', '--porcelain=v1', '--untracked-files=all'], cwd)
265
+ .split('\n').filter(Boolean).map((line) => line.slice(3));
266
+ const allowed = new Set(['package.json', 'package-lock.json', 'npm-shrinkwrap.json']);
267
+ if (!changed.length || changed.some((file) => !allowed.has(file))) {
268
+ throw new ReleaseError(`npm version changed unexpected files: ${changed.join(', ')}.`);
269
+ }
270
+ checked(exec, 'git', ['add', '--', ...changed], cwd);
271
+ checked(exec, 'git', ['commit', '-m', version], cwd);
272
+ if (checked(exec, 'git', ['log', '-1', '--format=%B'], cwd) !== version) {
273
+ throw new ReleaseError(`Version commit message is not exactly ${version}; inspect the local commit.`);
274
+ }
275
+ const committed = checked(exec, 'git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], cwd)
276
+ .split('\n').filter(Boolean);
277
+ if (!committed.length || committed.some((file) => !allowed.has(file))) {
278
+ throw new ReleaseError(`Version commit includes unexpected files: ${committed.join(', ')}.`);
279
+ }
280
+ ensureClean(exec, cwd);
281
+ return checked(exec, 'git', ['rev-parse', 'HEAD'], cwd);
282
+ }
283
+ async function waitForWorkflow(exec, cwd, details, sha, now, sleep, log) {
284
+ const deadline = now() + workflowTimeoutMs;
285
+ let runId;
286
+ log(`Waiting for GitHub to start the workflow run for ${details.tag}.`);
287
+ while (now() < deadline) {
288
+ if (!runId) {
289
+ const filename = encodeURIComponent(path.basename(details.workflow.path));
290
+ const listing = github(exec, cwd, details.host, `repos/${details.slug}/actions/workflows/${filename}/runs`, {
291
+ event: 'push', head_sha: sha, per_page: '100',
292
+ });
293
+ const matches = (listing.workflow_runs || []).filter((run) => run.head_sha === sha &&
294
+ run.head_branch === details.tag && run.path?.split('@')[0] === details.workflow.path);
295
+ if (matches.length) {
296
+ const run = matches.reduce((latest, candidate) => candidate.id > latest.id ? candidate : latest);
297
+ runId = run.id;
298
+ log(`Tag workflow run found: ${run.html_url || `https://${details.host}/${details.slug}/actions/runs/${runId}`}`);
299
+ }
300
+ }
301
+ if (runId) {
302
+ const jobs = github(exec, cwd, details.host, `repos/${details.slug}/actions/runs/${runId}/jobs`, {
303
+ per_page: '100',
304
+ }).jobs || [];
305
+ const matching = jobs.filter((job) => job.name === details.workflow.name);
306
+ if (matching.length > 1)
307
+ throw new ReleaseError(`Multiple publish jobs found in GitHub run ${runId}.`);
308
+ if (matching.length === 1 && matching[0].status === 'completed' && matching[0].conclusion !== 'success') {
309
+ throw new ReleaseError(`Publish job in GitHub run ${runId} ended as ${matching[0].conclusion}.`);
310
+ }
311
+ const runStatus = github(exec, cwd, details.host, `repos/${details.slug}/actions/runs/${runId}`, {});
312
+ if (runStatus.status === 'completed') {
313
+ if (matching.length !== 1 || matching[0].status !== 'completed' || matching[0].conclusion !== 'success') {
314
+ throw new ReleaseError(`Workflow run ${runId} completed without a successful publish job.`);
315
+ }
316
+ const startedAt = Date.parse(runStatus.run_started_at);
317
+ const completedAt = Date.parse(runStatus.updated_at);
318
+ if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || startedAt > completedAt) {
319
+ throw new ReleaseError(`Workflow run ${runId} has invalid start or completion time.`);
320
+ }
321
+ return { completedAt, durationMs: completedAt - startedAt };
322
+ }
323
+ }
324
+ await sleep(pollIntervalMs);
325
+ }
326
+ throw new ReleaseError(`Timed out waiting for the workflow run for ${details.tag}.`);
327
+ }
328
+ async function waitForRegistry(exec, cwd, details, workflow, now, sleep, log) {
329
+ const deadline = now() + registryTimeoutMs;
330
+ log(`Workflow run for ${details.tag} completed in ${(workflow.durationMs / 1000).toFixed(1)} seconds; waiting for ${details.name}@${details.version} on npm.`);
331
+ const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'peacekeeper-registry-'));
332
+ try {
333
+ while (now() < deadline) {
334
+ const result = exec('npm', [
335
+ 'view', `${details.name}@${details.version}`, 'version', '--json',
336
+ '--registry', 'https://registry.npmjs.org/', '--prefer-online', '--cache', cache,
337
+ ], { cwd });
338
+ if (result.status === 0) {
339
+ let observed;
340
+ try {
341
+ observed = JSON.parse(result.stdout);
342
+ }
343
+ catch {
344
+ observed = null;
345
+ }
346
+ if (observed === details.version)
347
+ return Math.max(0, now() - workflow.completedAt);
348
+ }
349
+ await sleep(pollIntervalMs);
350
+ }
351
+ throw new ReleaseError(`Timed out waiting for ${details.name}@${details.version} on npm.`);
352
+ }
353
+ finally {
354
+ fs.rmSync(cache, { recursive: true, force: true });
355
+ }
356
+ }
357
+ function saveState(filename, state) {
358
+ fs.writeFileSync(`${filename}.tmp`, JSON.stringify(state));
359
+ fs.renameSync(`${filename}.tmp`, filename);
360
+ }
361
+ function pullRequest(exec, cwd, details) {
362
+ return github(exec, cwd, details.host, `repos/${details.slug}/pulls/${details.pr}`, {});
363
+ }
364
+ async function mergeRelease(exec, cwd, details, now, sleep, log) {
365
+ const deadline = now() + workflowTimeoutMs;
366
+ while (now() < deadline) {
367
+ const pr = pullRequest(exec, cwd, details);
368
+ if (pr.head.sha !== details.sha || pr.base.ref !== details.defaultBranch)
369
+ throw new ReleaseError('Release PR changed unexpectedly; inspect it before continuing.');
370
+ if (pr.merged)
371
+ return pr.merge_commit_sha;
372
+ if (pr.state !== 'open')
373
+ throw new ReleaseError(`Release PR #${details.pr} was closed without merging.`);
374
+ if (pr.mergeable_state === 'dirty')
375
+ throw new ReleaseError(`Release PR #${details.pr} has conflicts; inspect it before continuing.`);
376
+ verifyProtection({ api: githubApi(exec, cwd, details.host), slug: details.slug,
377
+ branch: details.defaultBranch, checks: requiredChecks(cwd) });
378
+ // GitHub enforces required checks, reviews, and branch freshness on this request.
379
+ const result = exec('gh', ['api', '--hostname', details.host, '--method', 'PUT',
380
+ `repos/${details.slug}/pulls/${details.pr}/merge`,
381
+ '-f', `merge_method=${details.mergeMethod || 'squash'}`, '-f', `sha=${details.sha}`], { cwd });
382
+ if (result.status === 0) {
383
+ const merged = JSON.parse(result.stdout);
384
+ if (merged.merged)
385
+ return merged.sha;
386
+ }
387
+ else if (!/HTTP (405|409)/u.test(result.stderr)) {
388
+ throw new ReleaseError(`Cannot merge release PR: ${result.stderr.trim()}`);
389
+ }
390
+ log(`Waiting for release PR #${details.pr} to satisfy merge requirements.`);
391
+ await sleep(pollIntervalMs);
392
+ }
393
+ throw new ReleaseError(`Timed out waiting for release PR #${details.pr}; retry the same release command to continue.`);
394
+ }
395
+ async function release(bump, dependencies) {
396
+ const { cwd, spawnSync, environment, now, sleep, log } = dependencies;
397
+ const exec = (command, args, options) => execute(spawnSync, environment, command, args, options);
398
+ const common = path.resolve(cwd, checked(exec, 'git', ['rev-parse', '--git-common-dir'], cwd));
399
+ const statePath = path.join(common, 'peacekeeper-release.json');
400
+ const lockPath = `${statePath}.lock`;
401
+ if (fs.existsSync(lockPath)) {
402
+ const pid = Number(fs.readFileSync(lockPath, 'utf8'));
403
+ try {
404
+ process.kill(pid, 0);
405
+ throw new ReleaseError('Another release command is running.');
406
+ }
407
+ catch (error) {
408
+ if (error.code !== 'ESRCH') throw error;
409
+ fs.unlinkSync(lockPath);
410
+ }
411
+ }
412
+ const lock = fs.openSync(lockPath, 'wx');
413
+ fs.writeFileSync(lock, String(process.pid));
414
+ fs.closeSync(lock);
415
+ let temporary;
416
+ let worktree;
417
+ try {
418
+ let details;
419
+ if (fs.existsSync(statePath)) {
420
+ details = JSON.parse(fs.readFileSync(statePath, 'utf8'));
421
+ if (details.bump !== bump)
422
+ throw new ReleaseError(`An unfinished ${details.bump} release exists; retry that release type.`);
423
+ const origin = originRepository(checked(exec, 'git', ['remote', 'get-url', 'origin'], cwd));
424
+ if (origin.host !== details.host || origin.slug !== details.slug)
425
+ throw new ReleaseError('Origin differs from the unfinished release repository.');
426
+ log(`Resuming release ${details.tag}.`);
427
+ }
428
+ else {
429
+ details = prepare(exec, cwd, bump, log);
430
+ for (let page = 1; ; page += 1) {
431
+ const open = github(exec, cwd, details.host, `repos/${details.slug}/pulls`, {
432
+ state: 'open', base: details.defaultBranch, per_page: '100', page: String(page),
433
+ });
434
+ if (open.some((pr) => pr.head.ref.startsWith('release/v')))
435
+ throw new ReleaseError('An open release PR already exists; finish it from its original checkout.');
436
+ if (open.length < 100) break;
437
+ }
438
+ details.bump = bump;
439
+ details.branch = `release/${details.tag}`;
440
+ saveState(statePath, details);
441
+ }
442
+ temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'peacekeeper-release-worktree-'));
443
+ worktree = path.join(temporary, 'repo');
444
+ if (!details.sha) {
445
+ checked(exec, 'git', ['worktree', 'add', '--detach', worktree, `origin/${details.defaultBranch}`], cwd);
446
+ const baseline = readPackage(worktree);
447
+ if (baseline.name !== details.name || nextVersion(baseline.version, bump) !== details.version)
448
+ throw new ReleaseError('Default branch version changed before release preparation completed; inspect release state.');
449
+ ensureLockfilesMatch(worktree, baseline.version);
450
+ inspectWorkflow(worktree, details.tag);
451
+ details.sha = makeVersionCommit(exec, worktree, bump, details.version);
452
+ // Keep the commit reachable if interrupted before the remote push.
453
+ checked(exec, 'git', ['update-ref', `refs/peacekeeper/releases/${details.tag}`, details.sha], cwd);
454
+ saveState(statePath, details);
455
+ }
456
+ else {
457
+ checked(exec, 'git', ['worktree', 'add', '--detach', worktree, details.sha], cwd);
458
+ }
459
+ if (!details.pr) {
460
+ checked(exec, 'git', ['push', 'origin', `${details.sha}:refs/heads/${details.branch}`], cwd);
461
+ const existing = github(exec, cwd, details.host, `repos/${details.slug}/pulls`, {
462
+ state: 'all', head: `${details.slug.split('/')[0]}:${details.branch}`, base: details.defaultBranch,
463
+ });
464
+ if (existing.length > 1)
465
+ throw new ReleaseError('Multiple release PRs found; inspect before continuing.');
466
+ if (existing.length) details.pr = existing[0].number;
467
+ else {
468
+ const body = path.join(temporary, 'pr-body.md');
469
+ fs.writeFileSync(body, `Bump ${details.name} to ${details.version}. After merge, the release command tags the merge commit and waits for npm publication.\n`);
470
+ checked(exec, 'gh', ['pr', 'create', '--repo', `${details.host}/${details.slug}`,
471
+ '--base', details.defaultBranch, '--head', details.branch,
472
+ '--title', `Release ${details.tag}`, '--body-file', body], cwd);
473
+ const created = github(exec, cwd, details.host, `repos/${details.slug}/pulls`, {
474
+ state: 'all', head: `${details.slug.split('/')[0]}:${details.branch}`, base: details.defaultBranch,
475
+ });
476
+ if (created.length !== 1) throw new ReleaseError('Cannot identify created release PR.');
477
+ details.pr = created[0].number;
478
+ }
479
+ saveState(statePath, details);
480
+ }
481
+ log(`Release PR: https://${details.host}/${details.slug}/pull/${details.pr}`);
482
+ const sha = await mergeRelease(exec, cwd, details, now, sleep, log);
483
+ checked(exec, 'git', ['fetch', 'origin', details.defaultBranch], cwd);
484
+ checked(exec, 'git', ['merge-base', '--is-ancestor', sha, `origin/${details.defaultBranch}`], cwd);
485
+ checked(exec, 'git', ['checkout', '--detach', sha], worktree);
486
+ const manifest = readPackage(worktree);
487
+ if (manifest.version !== details.version || manifest.name !== details.name)
488
+ throw new ReleaseError('Merged package does not match the intended release.');
489
+ ensureLockfilesMatch(worktree, details.version);
490
+ details.workflow = inspectWorkflow(worktree, details.tag);
491
+ const remote = checked(exec, 'git', ['ls-remote', '--tags', 'origin', `refs/tags/${details.tag}`, `refs/tags/${details.tag}^{}`], cwd);
492
+ if (remote && !remote.split('\n').some((line) => line.startsWith(`${sha}\t`)))
493
+ throw new ReleaseError(`Origin tag ${details.tag} points to another commit.`);
494
+ if (!remote) {
495
+ const local = exec('git', ['rev-parse', '--verify', `refs/tags/${details.tag}^{commit}`], { cwd });
496
+ if (local.status === 0 && local.stdout.trim() !== sha)
497
+ throw new ReleaseError(`Local tag ${details.tag} points to another commit.`);
498
+ if (local.status !== 0) checked(exec, 'git', ['tag', details.tag, sha], cwd);
499
+ checked(exec, 'git', ['push', 'origin', `refs/tags/${details.tag}`], cwd);
500
+ }
501
+ const workflow = await waitForWorkflow(exec, cwd, details, sha, now, sleep, log);
502
+ const elapsedMs = await waitForRegistry(exec, cwd, details, workflow, now, sleep, log);
503
+ log(`Published ${details.name}@${details.version}; visible on npm ${(elapsedMs / 1000).toFixed(1)} seconds after the workflow run completed.`);
504
+ fs.unlinkSync(statePath);
505
+ checked(exec, 'git', ['update-ref', '-d', `refs/peacekeeper/releases/${details.tag}`], cwd);
506
+ }
507
+ finally {
508
+ if (worktree) exec('git', ['worktree', 'remove', '--force', worktree], { cwd });
509
+ if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
510
+ fs.unlinkSync(lockPath);
511
+ }
512
+ }
513
+ function usageCommand() {
514
+ return 'peacekeeper release';
515
+ }
516
+ async function runCli(options) {
517
+ const { argv, environment, repositoryDirectory, spawnSync, now, sleep, log } = options;
518
+ const bump = argv[2];
519
+ if (!['major', 'minor', 'patch'].includes(bump) || argv.length !== 3) {
520
+ throw new ReleaseError(`Usage: ${usageCommand(options)} <major|minor|patch>`);
521
+ }
522
+ await release(bump, { cwd: repositoryDirectory, spawnSync, environment, now, sleep, log });
523
+ }
524
+ module.exports = {
525
+ ReleaseError, acceptsTag, ensureLockfilesMatch, inspectWorkflow, makeVersionCommit, nextVersion,
526
+ originRepository, prepare, readPackage, release, runCli, waitForWorkflow, waitForRegistry,
527
+ };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ function requiredChecks(cwd) {
4
+ const { projectConfig } = require('./project-config');
5
+ const checks = projectConfig(cwd).git.releaseRequiredChecks;
6
+ if (!checks?.length) throw new Error('Configure git.releaseRequiredChecks with the CI jobs required for releases.');
7
+ return checks;
8
+ }
9
+
10
+ // A missing classic protection is expected for ruleset-only repositories. All
11
+ // other API failures, including permission failures, must propagate.
12
+ function verifyProtection({ api, slug, branch, checks }) {
13
+ const base = `repos/${slug}`;
14
+ let protection;
15
+ try {
16
+ protection = api(`${base}/branches/${encodeURIComponent(branch)}/protection`);
17
+ } catch (error) {
18
+ if (error.status !== 404) throw error;
19
+ }
20
+ const rules = [];
21
+ for (let page = 1; ; page += 1) {
22
+ const batch = api(`${base}/rules/branches/${encodeURIComponent(branch)}?per_page=100&page=${page}`);
23
+ if (!Array.isArray(batch)) throw new Error('GitHub returned invalid branch rules.');
24
+ rules.push(...batch);
25
+ if (batch.length < 100) break;
26
+ }
27
+ const strictChecks = new Set();
28
+ const classic = protection?.required_status_checks;
29
+ if (classic?.strict === true && protection.enforce_admins?.enabled === true) {
30
+ for (const check of classic.checks || []) strictChecks.add(check.context);
31
+ for (const context of classic.contexts || []) strictChecks.add(context);
32
+ }
33
+ // Ruleset bypass actors are not included in the effective-rules response.
34
+ // Read every contributing ruleset and require that it has no bypass actors.
35
+ const checkedRulesets = new Set();
36
+ for (const rule of rules.filter(rule => rule.type === 'required_status_checks')) {
37
+ if (rule.parameters?.strict_required_status_checks_policy !== true) continue;
38
+ if (!Number.isInteger(rule.ruleset_id)) throw new Error('GitHub did not identify the status-check ruleset.');
39
+ if (!checkedRulesets.has(rule.ruleset_id)) {
40
+ const ruleset = api(`${base}/rulesets/${rule.ruleset_id}`);
41
+ if (ruleset.enforcement !== 'active' || !Array.isArray(ruleset.bypass_actors)) {
42
+ throw new Error('Cannot verify active ruleset bypass settings; grant write access to the ruleset so GitHub exposes bypass actors, then rerun.');
43
+ }
44
+ if (ruleset.bypass_actors.length) throw new Error('Strict release rulesets must not allow bypass actors.');
45
+ checkedRulesets.add(rule.ruleset_id);
46
+ }
47
+ for (const check of rule.parameters.required_status_checks || []) strictChecks.add(check.context);
48
+ }
49
+ const missing = checks.filter(check => !strictChecks.has(check));
50
+ if (!checks.length || missing.length) {
51
+ throw new Error(`Branch ${branch} must require strict status checks without bypass for: ${missing.join(', ') || '(no checks configured)'}. Enable strict checks and enforce them for administrators, or use an active ruleset without bypass actors; then rerun.`);
52
+ }
53
+ }
54
+
55
+ function githubApi(exec, cwd, host) {
56
+ return endpoint => {
57
+ const response = exec('gh', ['api', '--hostname', host, '--method', 'GET', endpoint], { cwd });
58
+ if (response.status !== 0) {
59
+ const error = new Error(`Cannot verify GitHub release policy (${endpoint}): ${response.stderr?.trim() || 'API request failed'}. Grant the token Administration read, Contents read, Pull requests read and Actions read permissions, then rerun.`);
60
+ error.status = Number(/HTTP (\d{3})/u.exec(response.stderr || '')?.[1]);
61
+ throw error;
62
+ }
63
+ return JSON.parse(response.stdout);
64
+ };
65
+ }
66
+
67
+ module.exports = { githubApi, requiredChecks, verifyProtection };
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('node:child_process');
5
+ const { runCli } = require('./release-core');
6
+
7
+ runCli({
8
+ argv: process.argv,
9
+ environment: process.env,
10
+ repositoryDirectory: process.cwd(),
11
+ spawnSync,
12
+ now: () => Date.now(),
13
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
14
+ log: (message) => console.log(`${new Date().toTimeString().slice(0, 8)} ${message}`),
15
+ }).catch((error) => {
16
+ console.error(`Release stopped: ${error instanceof Error ? error.message : String(error)}`);
17
+ process.exitCode = 1;
18
+ });