@jakobm/peacekeeper 0.14.1 → 0.16.0

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.
package/README.md CHANGED
@@ -90,7 +90,7 @@ Complete example:
90
90
  }
91
91
  ```
92
92
 
93
- `git.remote` and `git.defaultBranch` identify the branch used for checkpointing and cleanup. `git.branchPrefix` names new task branches. `tests.commands` are complete shell commands, run in order from the caller's Git root; install any executables they use. The test runner stops on the first failure and records a receipt only after every command succeeds. `receiptMaxAgeMinutes` controls how long a receipt can be reused. Set `npmTestUsesRunner` to `true` only when the caller's `npm test` script invokes `peacekeeper run-tests`; this makes the rerun hint `npm test -- rerun`. Otherwise the hint uses the explicit package path. `loggingDirectory` stores hook invocation logs relative to the primary Git worktree's root.
93
+ `git.remote` and `git.defaultBranch` identify the branch used for checkpointing and cleanup. `git.branchPrefix` names new task branches. `run-tests` always runs the local `check-project` validation first, even when reusing a test receipt. Explicit `check-project` invocations in `tests.commands` are rejected as redundant. The configured commands are complete shell commands, run in order from the caller's Git root; install any executables they use. The test runner stops on the first failure and records a receipt only after every command succeeds. `receiptMaxAgeMinutes` controls how long a receipt can be reused. Set `npmTestUsesRunner` to `true` only when the caller's `npm test` script invokes `peacekeeper run-tests`; this makes the rerun hint `npm test -- rerun`. Otherwise the hint uses the explicit package path. `loggingDirectory` stores hook invocation logs relative to the primary Git worktree's root.
94
94
 
95
95
  `loggingEnabled` has the bundled default `true`; set it to `false` to disable invocation logs and the background cleanup worker's log file. Existing logs are kept. The setting is read from the primary checkout's configuration, including for linked worktrees. SessionStart and Stop logs include the process and event working directories, event type, primary-checkout classification, and hook response or error. Only selected event fields are logged, never the full incoming payload. These logs require the Peacekeeper script to start; a missing Node.js executable or package must be diagnosed in Codex's own logs.
96
96
 
@@ -244,6 +244,9 @@ does not grant trust automatically.
244
244
  ## Check project conventions locally
245
245
 
246
246
  Run `peacekeeper check-project` to check the local project configuration.
247
+ All projects must declare nonblank `author` and `repository` values in
248
+ `package.json`. npm object forms are supported with nonblank `author.name`
249
+ and `repository.url` strings.
247
250
  Run `peacekeeper check-project --explain` for the complete expected
248
251
  architecture, requirements, configuration examples, and verification limits.
249
252
  The command prints the bundled [formal requirements](scripts/check-project-requirements.txt),
@@ -284,8 +287,39 @@ from the organization are included. Merge queues are not currently an alternativ
284
287
  to this strict policy. Repositories must also enable **Automatically delete head
285
288
  branches** (`delete_branch_on_merge=true`).
286
289
 
290
+ The default branch must require pull requests (zero required reviewer approvals
291
+ is allowed), and only merge commits may be used. Either disable squash and rebase
292
+ in repository settings, or restrict the default branch's pull-request ruleset to
293
+ `allowed_merge_methods: ["merge"]`. Other branches are outside this policy.
294
+ Required linear history and any applicable PR rule excluding merge commits fail
295
+ verification. PR and strict-check rules must enforce the policy without bypass.
296
+ Enable `allow_auto_merge` in repository settings: this permits opting in per PR;
297
+ Peacekeeper never enables auto-merge on individual PRs.
298
+
299
+ `check-project` makes no network requests. It runs implicitly before
300
+ `tests.commands`, including when reusing a receipt, and checks that the configured
301
+ PR workflow covers the default branch and runs `verify-release-protection` with `GH_TOKEN` after `npm ci` and
302
+ before tests in a job listed in `git.releaseRequiredChecks`. The job and step must
303
+ not skip verification or ignore failures. The online verifier then checks that
304
+ all those configured check names are mandatory on GitHub, including its own job.
305
+ For example, in a required `test` job:
306
+
307
+ ```yaml
308
+ - run: npm ci
309
+ - run: peacekeeper verify-release-protection
310
+ env:
311
+ GH_TOKEN: ${{ secrets.RELEASE_POLICY_TOKEN }}
312
+ - run: npm test
313
+ ```
314
+
315
+ This creates a local-to-CI chain: missing workflows are detected on the next local
316
+ `npm test`; incorrect GitHub settings are detected on the next PR run. It is not
317
+ continuous monitoring. Removing both CI and the local invocation prevents this
318
+ chain from running. No central scheduled audit is required by this setup.
319
+
287
320
  The PR test job runs `peacekeeper verify-release-protection` before its tests.
288
- The command checks `GITHUB_BASE_REF`, or `git.defaultBranch` outside PR CI.
321
+ The command reads the actual default branch from GitHub, regardless of the PR
322
+ base. Other release commands require their configured branch to match it.
289
323
  `peacekeeper release` checks the default branch before preparing a release
290
324
  and again immediately before each merge attempt. Before publishing,
291
325
  `peacekeeper verify-release` checks the policy, matching package/tag/lockfile
@@ -347,19 +381,10 @@ not retain historical proof of the tested file tree if administrators change
347
381
  settings between CI and merge. Keep the `package.yml` filename unchanged: npm
348
382
  trusted publishing is configured for that workflow file.
349
383
 
350
- Release merge selection reads the calling repository's root
351
- `peacekeeper.config.json`, not the installed package's configuration:
352
-
353
- - If GitHub allows only one merge method, that method is used regardless of the preference.
354
- - If multiple methods are allowed, `git.releaseMergeMethod` selects
355
- `"merge"` (merge commit), `"rebase"`, or `"squash"`. A configured method that
356
- GitHub disallows causes the release to stop before creating a branch or PR.
357
- - The bundled preference is `"merge"`.
358
-
359
- For example, set `"releaseMergeMethod": "rebase"` inside the existing `git` object.
360
- A caller without a configuration file uses the bundled defaults.
361
- The chosen method is saved with release state and reused on retries; unfinished
362
- releases created by older versions retain their original squash behavior.
384
+ Releases use merge commits on the default branch, including retries of legacy
385
+ state without a merge method. Saved unfinished releases that explicitly select
386
+ squash or rebase must be restarted. `git.releaseMergeMethod` remains accepted for
387
+ configuration compatibility, but no longer selects the default-branch merge method.
363
388
 
364
389
  From a clean repository root, run `npm run release -- patch`, `minor`, or `major`.
365
390
  The command fetches the default branch and uses a temporary detached worktree to
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@jakobm/peacekeeper",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "Shared repository test, Git worktree, and Codex hook commands",
5
5
  "license": "MIT",
6
+ "author": "Jakob Mattsson",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/jakobmattsson/peacekeeper.git"
@@ -19,7 +20,6 @@
19
20
  "scripts/"
20
21
  ],
21
22
  "scripts": {
22
- "pretest": "node bin/peacekeeper.js check-project",
23
23
  "test": "node bin/peacekeeper.js run-tests",
24
24
  "release": "node bin/peacekeeper.js release",
25
25
  "cleanup:branches": "node bin/peacekeeper.js cleanup-merged-branches",
@@ -39,6 +39,13 @@ Project configuration
39
39
  }
40
40
  projectChecks and both its fields are mandatory in an existing configuration.
41
41
 
42
+ Package metadata: all projects, including private packages
43
+ package.json must declare author and repository.
44
+ author must be a nonblank string or an object with a nonblank string name.
45
+ repository must be a nonblank string (including npm repository shorthand)
46
+ or an object with a nonblank string url. Whitespace-only values fail.
47
+ These checks validate presence, not repository reachability or author identity.
48
+
42
49
  Toolchain: package.json and all Node-based CI jobs
43
50
  Pin exact versions in package.json with:
44
51
  volta pin node@<exact-version> npm@<exact-version>
@@ -325,7 +332,31 @@ What a passing check does and does not establish
325
332
  online verification; verify-release handles the release checks it supports.
326
333
 
327
334
  Using this check in the test chain
328
- Add "pretest": "peacekeeper check-project" to package.json, prepending it
329
- to any existing pretest command. This also runs before cached test receipts
330
- can skip the test runner. Putting the check only in tests.commands does not
331
- guarantee it runs when a cached receipt is reused.
335
+ run-tests always runs check-project as an implicit first step before
336
+ tests.commands, including when a valid receipt skips the configured commands.
337
+ Explicit check-project invocations in tests.commands are rejected as redundant
338
+ because the runner always includes this step automatically. Supported direct
339
+ peacekeeper, npm exec, npx and node CLI forms are checked, including commands
340
+ separated by shell control operators. Failure stops the run and invalidates any
341
+ existing receipt. This check remains entirely local.
342
+
343
+ Default-branch GitHub policy chain
344
+ The configured PR workflow must run verify-release-protection with step GH_TOKEN
345
+ after npm ci and before tests in at least one job in git.releaseRequiredChecks.
346
+ Verification must be unconditional and must not allow failure. The existing
347
+ required-job validation applies to that job and its dependencies.
348
+ This applies to private projects too; configure nonempty releaseRequiredChecks.
349
+
350
+ Online verification reads the actual GitHub default branch and requires PRs,
351
+ strict mandatory configured checks without bypass, and merge commits only.
352
+ Zero reviewer approvals is allowed. Enable repository allow_auto_merge; this
353
+ only offers per-PR opt-in and does not activate auto-merge for any PR.
354
+ Restrict merge methods either repository-wide (merge enabled, squash and rebase
355
+ disabled) or with a default-branch PR ruleset allowing only merge. Other branches
356
+ are not checked. Linear-history rules or PR rules excluding merge are errors.
357
+ Ruleset PR requirements, like status checks, require active rules without bypass.
358
+ Existing automatic head-branch deletion requirements still apply.
359
+
360
+ Missing CI is detected on the next local npm test. GitHub settings are checked
361
+ on the next PR run, before release merges and before publication. This is not
362
+ continuous monitoring and cannot run if both local checks and CI are removed.
@@ -40,6 +40,21 @@ function checkProject(cwd = process.cwd()) {
40
40
  try { action(); } catch (error) { errors.push(`[${category}] ${error.message}`); }
41
41
  finally { category = previous; }
42
42
  };
43
+ check('tests', () => {
44
+ const projectCheck = /(?:^|&&|\|\||;|\n)\s*(?:peacekeeper|npm\s+exec\s+--no\s+--\s+peacekeeper|npx\s+--no-install\s+peacekeeper|node\s+(?:\.\/)?(?:node_modules\/@jakobm\/peacekeeper\/)?bin\/peacekeeper\.js)\s+check-project(?=\s|$|;|&|\|)/u;
45
+ for (const [index, command] of config.tests.commands.entries()) {
46
+ requireThat(!projectCheck.test(command),
47
+ `tests.commands[${index}]: remove check-project; it is redundant because Peacekeeper always includes it automatically before the configured test commands, even when reusing a test receipt.`);
48
+ }
49
+ });
50
+ check('package', () => {
51
+ const nonblank = value => typeof value === 'string' && value.trim().length > 0;
52
+ requireThat(nonblank(manifest.author) || nonblank(manifest.author?.name),
53
+ 'package.json: author must be a nonblank string or an object with a nonblank name.');
54
+ requireThat(nonblank(manifest.repository) || nonblank(manifest.repository?.url),
55
+ 'package.json: repository must be a nonblank string or an object with a nonblank url.');
56
+ });
57
+
43
58
  const workflows = new Map();
44
59
  check('CI', () => {
45
60
  const directory = path.join(root, '.github/workflows');
@@ -153,6 +168,7 @@ function checkProject(cwd = process.cwd()) {
153
168
  && !['paths', 'paths-ignore', 'branches-ignore', 'types'].some(key => Object.hasOwn(filters, key))
154
169
  && (!filters.branches || (Array.isArray(filters.branches) && filters.branches.includes(config.git.defaultBranch)))),
155
170
  `${filename}: PR filters must include the default branch and must not skip required checks by path or event type.`);
171
+ let policyJobs = 0;
156
172
  const checked = new Set();
157
173
  function requiredJob(id, job, testsRequired) {
158
174
  if (!requireThat(job && typeof job === 'object', `${filename}/${id}: required job is missing; checks for this job are blocked.`)) return;
@@ -166,6 +182,9 @@ function checkProject(cwd = process.cwd()) {
166
182
  const steps = job.steps || [];
167
183
  const installed = steps.findIndex(step => directCommand(step, installCommand) && unconditional(step.if) && !step['continue-on-error']);
168
184
  const tested = steps.findIndex(step => directCommand(step, testCommand) && unconditional(step.if) && !step['continue-on-error']);
185
+ const policy = steps.findIndex(step => directCommand(step, /^(?:peacekeeper|npm exec --no -- peacekeeper|node (?:\.\/)?(?:node_modules\/@jakobm\/peacekeeper\/)?bin\/peacekeeper\.js) verify-release-protection$/u)
186
+ && unconditional(step.if) && !step['continue-on-error'] && step.env?.GH_TOKEN);
187
+ if (policy > installed && installed >= 0 && policy < tested && config.git.releaseRequiredChecks.includes(job.name || id)) policyJobs += 1;
169
188
  requireThat(installed >= 0 && tested > installed, `${filename}/${id}: run unconditional npm ci before npm test, npm run test:<name>, or npm pack --dry-run (without continue-on-error).`);
170
189
  }
171
190
  for (const name of required) {
@@ -175,6 +194,7 @@ function checkProject(cwd = process.cwd()) {
175
194
  checked.clear();
176
195
  requiredJob(...matching[0], true);
177
196
  }
197
+ requireThat(policyJobs > 0, `${filename}: a job in git.releaseRequiredChecks must run unconditional verify-release-protection with GH_TOKEN after npm ci and before tests.`);
178
198
  });
179
199
 
180
200
  if (published) check('publication', () => {
@@ -227,7 +247,7 @@ function main() {
227
247
  if (errors.length) {
228
248
  for (const error of errors) console.error(`FAIL ${error}`);
229
249
  process.exitCode = 1;
230
- } else console.log(`Project checks passed: tools, hooks, CI${published ? ', publication' : ' (private package; publication skipped)'}.`);
250
+ } else console.log(`Project checks passed: package, tools, hooks, CI${published ? ', publication' : ' (private package; publication skipped)'}.`);
231
251
  }
232
252
  if (require.main === module) {
233
253
  try { main(); } catch (error) { console.error(`FAIL ${error.message}`); process.exitCode = 1; }
@@ -223,12 +223,12 @@ function prepare(exec, cwd, bump, log) {
223
223
  const remote = checked(exec, 'git', ['remote', 'get-url', 'origin'], cwd);
224
224
  const { host, slug } = originRepository(remote);
225
225
  const repository = github(exec, cwd, host, `repos/${slug}`, {});
226
- const configured = projectConfig(cwd).git.releaseMergeMethod;
226
+ projectConfig(cwd);
227
227
  const allowed = ['merge', 'rebase', 'squash'].filter((method) =>
228
228
  repository[{ merge: 'allow_merge_commit', rebase: 'allow_rebase_merge', squash: 'allow_squash_merge' }[method]] === true);
229
229
  if (!allowed.length)
230
230
  throw new ReleaseError('No supported PR merge method is enabled.');
231
- const mergeMethod = allowed.length === 1 ? allowed[0] : configured;
231
+ const mergeMethod = 'merge';
232
232
  if (!allowed.includes(mergeMethod))
233
233
  throw new ReleaseError(`Configured release merge method ${mergeMethod} is not enabled on GitHub.`);
234
234
  const defaultBranch = repository.default_branch;
@@ -374,10 +374,11 @@ async function mergeRelease(exec, cwd, details, now, sleep, log) {
374
374
  throw new ReleaseError(`Release PR #${details.pr} has conflicts; inspect it before continuing.`);
375
375
  verifyProtection({ api: githubApi(exec, cwd, details.host), slug: details.slug,
376
376
  branch: details.defaultBranch, checks: requiredChecks(cwd) });
377
+ if (details.mergeMethod && details.mergeMethod !== 'merge') throw new ReleaseError('Release must use merge commits on the default branch; restart this release.');
377
378
  // GitHub enforces required checks, reviews, and branch freshness on this request.
378
379
  const result = exec('gh', ['api', '--hostname', details.host, '--method', 'PUT',
379
380
  `repos/${details.slug}/pulls/${details.pr}/merge`,
380
- '-f', `merge_method=${details.mergeMethod || 'squash'}`, '-f', `sha=${details.sha}`], { cwd });
381
+ '-f', `merge_method=${details.mergeMethod || 'merge'}`, '-f', `sha=${details.sha}`], { cwd });
381
382
  if (result.status === 0) {
382
383
  const merged = JSON.parse(result.stdout);
383
384
  if (merged.merged)
@@ -11,9 +11,12 @@ function requiredChecks(cwd) {
11
11
  // other API failures, including permission failures, must propagate.
12
12
  function verifyProtection({ api, slug, branch, checks }) {
13
13
  const base = `repos/${slug}`;
14
- if (api(base)?.delete_branch_on_merge !== true) {
14
+ const repository = api(base);
15
+ if (repository?.delete_branch_on_merge !== true) {
15
16
  throw new Error(`Repository ${slug} must enable delete_branch_on_merge. Run gh api --method PATCH ${base} -F delete_branch_on_merge=true, then rerun.`);
16
17
  }
18
+ if (repository.allow_auto_merge !== true) throw new Error('Repository must enable allow_auto_merge; this only makes auto-merge available per PR.');
19
+ if (repository.default_branch !== branch) throw new Error(`Verify the actual default branch (${repository.default_branch}), not ${branch}.`);
17
20
  let protection;
18
21
  try {
19
22
  protection = api(`${base}/branches/${encodeURIComponent(branch)}/protection`);
@@ -27,6 +30,33 @@ function verifyProtection({ api, slug, branch, checks }) {
27
30
  rules.push(...batch);
28
31
  if (batch.length < 100) break;
29
32
  }
33
+ if (protection?.required_linear_history?.enabled || rules.some(rule => rule.type === 'required_linear_history')) {
34
+ throw new Error('Default branch must allow merge commits; disable required linear history.');
35
+ }
36
+ const pullRules = rules.filter(rule => rule.type === 'pull_request');
37
+ let requiresPullRequest = Boolean(protection?.required_pull_request_reviews && protection.enforce_admins?.enabled === true
38
+ && !(protection.required_pull_request_reviews.bypass_pull_request_allowances?.users?.length)
39
+ && !(protection.required_pull_request_reviews.bypass_pull_request_allowances?.teams?.length)
40
+ && !(protection.required_pull_request_reviews.bypass_pull_request_allowances?.apps?.length));
41
+ let branchMergeOnly = false;
42
+ const verifiedRulesets = new Set();
43
+ function verifyRuleset(rule) {
44
+ if (!Number.isInteger(rule.ruleset_id)) throw new Error('GitHub did not identify the policy ruleset.');
45
+ if (verifiedRulesets.has(rule.ruleset_id)) return;
46
+ const ruleset = api(`${base}/rulesets/${rule.ruleset_id}`);
47
+ if (ruleset.enforcement !== 'active' || !Array.isArray(ruleset.bypass_actors)) {
48
+ throw new Error('Cannot verify active ruleset bypass settings; grant write access to the ruleset so GitHub exposes bypass actors, then rerun.');
49
+ }
50
+ if (ruleset.bypass_actors.length) throw new Error('Default branch rulesets must not allow bypass actors.');
51
+ verifiedRulesets.add(rule.ruleset_id);
52
+ }
53
+ for (const rule of pullRules) {
54
+ verifyRuleset(rule);
55
+ requiresPullRequest = true;
56
+ const methods = rule.parameters?.allowed_merge_methods;
57
+ if (methods && !methods.includes('merge')) throw new Error('Default branch PR rules must allow merge commits.');
58
+ if (Array.isArray(methods) && methods.length === 1 && methods[0] === 'merge') branchMergeOnly = true;
59
+ }
30
60
  const strictChecks = new Set();
31
61
  const classic = protection?.required_status_checks;
32
62
  if (classic?.strict === true && protection.enforce_admins?.enabled === true) {
@@ -35,24 +65,20 @@ function verifyProtection({ api, slug, branch, checks }) {
35
65
  }
36
66
  // Ruleset bypass actors are not included in the effective-rules response.
37
67
  // Read every contributing ruleset and require that it has no bypass actors.
38
- const checkedRulesets = new Set();
39
68
  for (const rule of rules.filter(rule => rule.type === 'required_status_checks')) {
40
69
  if (rule.parameters?.strict_required_status_checks_policy !== true) continue;
41
- if (!Number.isInteger(rule.ruleset_id)) throw new Error('GitHub did not identify the status-check ruleset.');
42
- if (!checkedRulesets.has(rule.ruleset_id)) {
43
- const ruleset = api(`${base}/rulesets/${rule.ruleset_id}`);
44
- if (ruleset.enforcement !== 'active' || !Array.isArray(ruleset.bypass_actors)) {
45
- throw new Error('Cannot verify active ruleset bypass settings; grant write access to the ruleset so GitHub exposes bypass actors, then rerun.');
46
- }
47
- if (ruleset.bypass_actors.length) throw new Error('Strict release rulesets must not allow bypass actors.');
48
- checkedRulesets.add(rule.ruleset_id);
49
- }
70
+ verifyRuleset(rule);
50
71
  for (const check of rule.parameters.required_status_checks || []) strictChecks.add(check.context);
51
72
  }
52
73
  const missing = checks.filter(check => !strictChecks.has(check));
53
74
  if (!checks.length || missing.length) {
54
75
  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.`);
55
76
  }
77
+ if (!requiresPullRequest) throw new Error('Default branch must require pull requests without bypass.');
78
+ if (repository.allow_merge_commit !== true || (!branchMergeOnly
79
+ && (repository.allow_squash_merge !== false || repository.allow_rebase_merge !== false))) {
80
+ throw new Error('Default branch must allow only merge commits: restrict its pull request ruleset to merge, or disable repository squash and rebase merging.');
81
+ }
56
82
  }
57
83
 
58
84
  function githubApi(exec, cwd, host) {
@@ -31,6 +31,15 @@ function main() {
31
31
  process.exitCode = 2;
32
32
  return;
33
33
  }
34
+ const projectStatus = spawnSync(process.execPath, [require.resolve('./check-project.js')], {
35
+ cwd: config.root, stdio: 'inherit',
36
+ });
37
+ if (projectStatus.error) throw projectStatus.error;
38
+ if (projectStatus.status !== 0) {
39
+ fs.rmSync(receiptPath(config.root), { force: true });
40
+ process.exitCode = projectStatus.status ?? 1;
41
+ return;
42
+ }
34
43
  if (!force && !process.env.CI && hasReusableReceipt()) {
35
44
  console.log(successMessage(config.root));
36
45
  return;
@@ -12,7 +12,7 @@ try {
12
12
  if (remote.status !== 0) throw new Error(remote.stderr);
13
13
  const { host, slug } = originRepository(remote.stdout.trim());
14
14
  const api = githubApi((command, args, options) => spawnSync(command, args, { ...options, encoding: 'utf8' }), cwd, host);
15
- verifyProtection({ api, slug, branch: process.env.GITHUB_BASE_REF || config.git.defaultBranch, checks: requiredChecks(cwd) });
15
+ verifyProtection({ api, slug, branch: api(`repos/${slug}`).default_branch, checks: requiredChecks(cwd) });
16
16
  } catch (error) {
17
17
  console.error(error.message);
18
18
  process.exitCode = 1;