@jakobm/peacekeeper 0.12.1 → 0.13.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 +50 -17
- package/bin/peacekeeper.js +4 -0
- package/package.json +1 -1
- package/scripts/check-project-requirements.txt +11 -9
- package/scripts/check-project.js +5 -5
- package/scripts/check-test-receipt.js +2 -1
- package/scripts/checkpoint-codex-changes.js +2 -6
- package/scripts/checkpoint-codex-session-start.js +2 -15
- package/scripts/checkpoint-codex-stop.js +2 -15
- package/scripts/cleanup-ended-session.js +4 -3
- package/scripts/log-script-invocation.js +33 -2
- package/scripts/project-config-defaults.json +26 -0
- package/scripts/project-config.js +59 -47
- package/scripts/receipt-state.js +1 -1
- package/scripts/record-test-receipt.js +2 -0
- package/scripts/release-core.js +3 -4
- package/scripts/run-tests.js +2 -1
- package/scripts/update-config.js +41 -0
- package/scripts/verify-release.js +2 -2
package/README.md
CHANGED
|
@@ -44,27 +44,55 @@ Run `npm install` from the project root and commit the resulting `package-lock.j
|
|
|
44
44
|
|
|
45
45
|
### 2. `peacekeeper.config.json`
|
|
46
46
|
|
|
47
|
-
Place this
|
|
47
|
+
Place this file at the caller's Git root. Every field, including nested fields,
|
|
48
|
+
is required. Unknown fields and invalid values are rejected. If no file exists,
|
|
49
|
+
Peacekeeper uses the complete bundled [defaults](scripts/project-config-defaults.json).
|
|
50
|
+
The defaults contain no test commands; configure those before running tests or
|
|
51
|
+
recording or reusing a test receipt.
|
|
52
|
+
|
|
53
|
+
Run `peacekeeper update-config` to create the file or recursively fill missing
|
|
54
|
+
fields from the defaults. Existing values, including empty arrays, are preserved.
|
|
55
|
+
Review newly inserted values. Invalid JSON, unknown fields, or invalid existing
|
|
56
|
+
values cause failure without changing the file. Repeating the command on a
|
|
57
|
+
complete valid file leaves it unchanged.
|
|
58
|
+
|
|
59
|
+
All CLI commands validate configuration before executing, except help,
|
|
60
|
+
`check-project --explain`, and `update-config`. An incomplete existing file fails
|
|
61
|
+
with missing field paths and a repair hint. Updates introducing new fields
|
|
62
|
+
therefore require `peacekeeper update-config` before ordinary commands can run.
|
|
63
|
+
|
|
64
|
+
Complete example:
|
|
48
65
|
|
|
49
66
|
```json
|
|
50
67
|
{
|
|
51
68
|
"git": {
|
|
52
69
|
"remote": "origin",
|
|
53
70
|
"defaultBranch": "main",
|
|
54
|
-
"branchPrefix": "codex/"
|
|
71
|
+
"branchPrefix": "codex/",
|
|
72
|
+
"checkpointPushRemotes": ["origin"],
|
|
73
|
+
"releaseMergeMethod": "merge",
|
|
74
|
+
"releaseRequiredChecks": ["test"],
|
|
75
|
+
"releaseTestWorkflow": "test.yml"
|
|
55
76
|
},
|
|
56
77
|
"tests": {
|
|
57
78
|
"commands": ["eslint .", "node --test"],
|
|
58
79
|
"receiptMaxAgeMinutes": 60,
|
|
59
80
|
"npmTestUsesRunner": true
|
|
60
81
|
},
|
|
61
|
-
"loggingDirectory": "tmp/logs"
|
|
82
|
+
"loggingDirectory": "tmp/logs",
|
|
83
|
+
"loggingEnabled": true,
|
|
84
|
+
"projectChecks": {
|
|
85
|
+
"testWorkflow": "test.yml",
|
|
86
|
+
"requiredJobs": ["test"]
|
|
87
|
+
}
|
|
62
88
|
}
|
|
63
89
|
```
|
|
64
90
|
|
|
65
91
|
`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.
|
|
66
92
|
|
|
67
|
-
|
|
93
|
+
`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.
|
|
94
|
+
|
|
95
|
+
To publish checkpoint branches automatically, set `git.checkpointPushRemotes` (fragment):
|
|
68
96
|
|
|
69
97
|
```json
|
|
70
98
|
{
|
|
@@ -75,11 +103,9 @@ To publish checkpoint branches automatically, add `checkpointPushRemotes` to `gi
|
|
|
75
103
|
}
|
|
76
104
|
```
|
|
77
105
|
|
|
78
|
-
Each name must identify a Git remote configured in the calling repository.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
checkpoints keep the previous behavior: push only when the branch already exists
|
|
82
|
-
on `git.remote`. Pushes never use force. Only a push to `git.remote` sets the
|
|
106
|
+
Each name must identify a Git remote configured in the calling repository. Checkpoints push to exactly those remotes, creating the task branch if
|
|
107
|
+
needed. An empty list disables checkpoint pushes and is the bundled default.
|
|
108
|
+
The field must be present; there is no implicit push fallback. Pushes never use force. Only a push to `git.remote` sets the
|
|
83
109
|
branch's upstream. If a push fails, earlier pushes are kept and the checkpoint
|
|
84
110
|
records a failure; fix the cause and rerun the checkpoint command to retry without
|
|
85
111
|
creating another commit when there are no new changes.
|
|
@@ -167,7 +193,7 @@ The installed file contains these definitions (other existing hooks are retained
|
|
|
167
193
|
}
|
|
168
194
|
```
|
|
169
195
|
|
|
170
|
-
The commands resolve the package from the current Git worktree. Install dependencies in each worktree where the hooks will run; the commands need that worktree's `node_modules/@jakobm/peacekeeper`. The checkpoint hooks apply only to linked worktrees. `SessionStart` supplies the checkpoint instructions before work begins, including on resume, fork, and after compaction; no matcher is needed. `Stop` uses the same package-owned instructions as a fallback. You do not need to copy checkpoint rules into each project's `AGENTS.md`. Explicit user instructions not to commit or push take precedence: the agent must skip the checkpoint command when they conflict, even if `Stop` requests it. The hook checks Git state and cannot determine user intent itself. When uncommitted changes remain, the `Stop` hook asks the agent to review the diff and run `checkpoint-codex-changes` with a descriptive English commit message. That command stages all changes and commits them on a task branch; it refuses the configured default branch. With `git.checkpointPushRemotes`, it pushes without force to each listed remote, including new branches.
|
|
196
|
+
The commands resolve the package from the current Git worktree. Install dependencies in each worktree where the hooks will run; the commands need that worktree's `node_modules/@jakobm/peacekeeper`. The checkpoint hooks apply only to linked worktrees. `SessionStart` supplies the checkpoint instructions before work begins, including on resume, fork, and after compaction; no matcher is needed. `Stop` uses the same package-owned instructions as a fallback. You do not need to copy checkpoint rules into each project's `AGENTS.md`. Explicit user instructions not to commit or push take precedence: the agent must skip the checkpoint command when they conflict, even if `Stop` requests it. The hook checks Git state and cannot determine user intent itself. When uncommitted changes remain, the `Stop` hook asks the agent to review the diff and run `checkpoint-codex-changes` with a descriptive English commit message. That command stages all changes and commits them on a task branch; it refuses the configured default branch. With `git.checkpointPushRemotes`, it pushes without force to each listed remote, including new branches. An empty array keeps the branch local.
|
|
171
197
|
|
|
172
198
|
`SessionEnd` starts a detached worker to clean up the session worktree and branch only if the captured branch tip is still unchanged and fully merged into the remote default branch. Dirty or locked worktrees are kept. Worker output is appended to `cleanup-ended-session.log` in the shared Git directory. Cleanup does not require the primary worktree to have the default branch checked out. A local default branch that can fast-forward is updated before cleanup: a branch not checked out in any worktree is moved atomically, while a checked-out branch is merged in its clean worktree so its files and index stay synchronized. A dirty or diverged default branch is left unchanged. Codex loads project hooks only after the project hook source is trusted; review them with `/hooks`. The three-second `SessionEnd` timeout is the maximum documented for that event. See the [Codex hooks reference](https://learn.chatgpt.com/docs/hooks) for hook loading, trust, and timeout behavior.
|
|
173
199
|
|
|
@@ -319,13 +345,13 @@ Release merge selection reads the calling repository's root
|
|
|
319
345
|
`peacekeeper.config.json`, not the installed package's configuration:
|
|
320
346
|
|
|
321
347
|
- If GitHub allows only one merge method, that method is used regardless of the preference.
|
|
322
|
-
- If multiple methods are allowed,
|
|
348
|
+
- If multiple methods are allowed, `git.releaseMergeMethod` selects
|
|
323
349
|
`"merge"` (merge commit), `"rebase"`, or `"squash"`. A configured method that
|
|
324
350
|
GitHub disallows causes the release to stop before creating a branch or PR.
|
|
325
|
-
-
|
|
351
|
+
- The bundled preference is `"merge"`.
|
|
326
352
|
|
|
327
|
-
For example,
|
|
328
|
-
A caller without a
|
|
353
|
+
For example, set `"releaseMergeMethod": "rebase"` inside the existing `git` object.
|
|
354
|
+
A caller without a configuration file uses the bundled defaults.
|
|
329
355
|
The chosen method is saved with release state and reused on retries; unfinished
|
|
330
356
|
releases created by older versions retain their original squash behavior.
|
|
331
357
|
|
|
@@ -371,9 +397,8 @@ publish job. Add `peacekeeper verify-release-protection` to a required PR
|
|
|
371
397
|
test job and `peacekeeper verify-release` before publishing, with `GH_TOKEN`
|
|
372
398
|
and `RELEASE_TAG` set for release verification. Both commands are shipped with
|
|
373
399
|
the package; no verifier script or artifact upload needs to be copied.
|
|
374
|
-
Set
|
|
375
|
-
repository: `"test.yml"`).
|
|
376
|
-
filename for compatibility with combined workflows. All configured check names
|
|
400
|
+
Set `git.releaseTestWorkflow` to the PR workflow filename (for this
|
|
401
|
+
repository: `"test.yml"`). For combined workflows, explicitly set the publish workflow filename. All configured check names
|
|
377
402
|
must be jobs in the selected PR workflow's run.
|
|
378
403
|
The `publish` job must contain a step whose command is exactly `npm publish`.
|
|
379
404
|
Its `if` can be omitted when the workflow triggers only on pushes of
|
|
@@ -381,3 +406,11 @@ Its `if` can be omitted when the workflow triggers only on pushes of
|
|
|
381
406
|
For combined workflows or other triggers, the job must use the exact condition
|
|
382
407
|
`${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}`.
|
|
383
408
|
Explicit alternative job conditions are not supported. Workspaces are not supported.
|
|
409
|
+
|
|
410
|
+
Configuration migration: previously optional fields must now be declared. Run
|
|
411
|
+
`peacekeeper update-config`, review the inserted defaults, and commit the result.
|
|
412
|
+
In particular, set `git.checkpointPushRemotes` explicitly to retain automatic
|
|
413
|
+
pushes. Empty `git.releaseRequiredChecks` is valid configuration but blocks release
|
|
414
|
+
policy verification; empty `tests.commands` blocks test execution and receipts.
|
|
415
|
+
`projectChecks` always explicitly selects the workflow and jobs. This change does
|
|
416
|
+
not add online GitHub verification to `check-project`.
|
package/bin/peacekeeper.js
CHANGED
|
@@ -4,6 +4,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
6
|
const COMMANDS = new Map([
|
|
7
|
+
['update-config', 'Create or complete project configuration using bundled defaults.'],
|
|
7
8
|
['check-project', 'Validate local toolchain, hooks, CI and publication conventions.'],
|
|
8
9
|
['install-hooks', 'Install or update project Codex hooks while preserving other hooks.'],
|
|
9
10
|
['verify-release', 'Verify release versions, required CI and strict branch protection.'],
|
|
@@ -42,6 +43,9 @@ function main() {
|
|
|
42
43
|
process.exitCode = 2;
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
46
|
+
const informational = (args.length === 1 && ['--help', '-h'].includes(args[0]))
|
|
47
|
+
|| (command === 'check-project' && args.length === 1 && args[0] === '--explain');
|
|
48
|
+
if (command !== 'update-config' && !informational) require('../scripts/project-config').projectConfig();
|
|
45
49
|
const script = path.resolve(__dirname, '..', 'scripts', `${command}.js`);
|
|
46
50
|
const result = spawnSync(process.execPath, [script, ...args], {
|
|
47
51
|
cwd: process.cwd(),
|
package/package.json
CHANGED
|
@@ -13,13 +13,16 @@ Purpose and scope
|
|
|
13
13
|
--explain prints this entire document without loading or checking a project.
|
|
14
14
|
|
|
15
15
|
Project configuration
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
package.json is required at the Git root. Without peacekeeper.config.json,
|
|
17
|
+
bundled defaults apply. An existing configuration must declare every schema
|
|
18
|
+
field, including nested fields; unknown fields and invalid values fail.
|
|
19
|
+
Run peacekeeper update-config to create or complete it without overwriting
|
|
20
|
+
existing values. Review inserted defaults. Empty tests.commands blocks tests
|
|
21
|
+
and receipts. Help, --explain and update-config bypass completeness checking.
|
|
22
|
+
|
|
23
|
+
projectChecks.testWorkflow selects the PR workflow filename.
|
|
24
|
+
projectChecks.requiredJobs selects required job names.
|
|
25
|
+
Defaults are test.yml and ["test"], respectively.
|
|
23
26
|
For published packages, git.releaseRequiredChecks is mandatory and its names
|
|
24
27
|
are always added to the required jobs. Set git.releaseTestWorkflow to the
|
|
25
28
|
selected PR workflow for release verification. Published projects cannot
|
|
@@ -34,8 +37,7 @@ Project configuration
|
|
|
34
37
|
"testWorkflow": "package.yml",
|
|
35
38
|
"requiredJobs": ["test"]
|
|
36
39
|
}
|
|
37
|
-
projectChecks
|
|
38
|
-
requiredJobs. Omit it when release settings already select the desired jobs.
|
|
40
|
+
projectChecks and both its fields are mandatory in an existing configuration.
|
|
39
41
|
|
|
40
42
|
Toolchain: package.json and all Node-based CI jobs
|
|
41
43
|
Pin exact versions in package.json with:
|
package/scripts/check-project.js
CHANGED
|
@@ -140,11 +140,11 @@ function checkProject(cwd = process.cwd()) {
|
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
const published = manifest.private !== true;
|
|
143
|
-
const filename = config.projectChecks
|
|
144
|
-
const required = [...new Set([...(config.projectChecks
|
|
143
|
+
const filename = config.projectChecks.testWorkflow;
|
|
144
|
+
const required = [...new Set([...(config.projectChecks.requiredJobs), ...(published ? config.git.releaseRequiredChecks : [])])];
|
|
145
145
|
check('CI', () => {
|
|
146
146
|
const workflow = workflows.get(filename);
|
|
147
|
-
if (!requireThat(workflow, `${filename}: configured PR workflow is missing (selected by
|
|
147
|
+
if (!requireThat(workflow, `${filename}: configured PR workflow is missing (selected by projectChecks.testWorkflow). Set projectChecks.testWorkflow${published ? ' and git.releaseTestWorkflow to the same existing file' : ' to an existing file'} in peacekeeper.config.json. PR checks are blocked until it can be read.`)) return;
|
|
148
148
|
const on = workflow.on;
|
|
149
149
|
const simpleTrigger = on === 'pull_request' || (Array.isArray(on) && on.includes('pull_request'));
|
|
150
150
|
requireThat(simpleTrigger || (on && typeof on === 'object' && Object.hasOwn(on, 'pull_request')), `${filename}: declare an on.pull_request trigger.`);
|
|
@@ -179,7 +179,7 @@ function checkProject(cwd = process.cwd()) {
|
|
|
179
179
|
|
|
180
180
|
if (published) check('publication', () => {
|
|
181
181
|
requireThat(config.git.releaseRequiredChecks?.length, 'Configure git.releaseRequiredChecks for a published package.');
|
|
182
|
-
requireThat(
|
|
182
|
+
requireThat(config.git.releaseTestWorkflow === filename,
|
|
183
183
|
'projectChecks.testWorkflow must match git.releaseTestWorkflow.');
|
|
184
184
|
requireThat(semver.valid(manifest.version) === manifest.version && !semver.prerelease(manifest.version), 'package.json: published packages need an exact stable version.');
|
|
185
185
|
requireThat(['package-lock.json', 'npm-shrinkwrap.json'].some(file => fs.existsSync(path.join(root, file))), 'Commit a package lockfile before publishing.');
|
|
@@ -205,7 +205,7 @@ function checkProject(cwd = process.cwd()) {
|
|
|
205
205
|
const permissions = job.permissions || workflow.permissions;
|
|
206
206
|
requireThat(permissions && permissions['id-token'] === 'write' && ['read', 'write'].includes(permissions.contents),
|
|
207
207
|
`${publishing.path}: declare contents: read and id-token: write for publishing.`);
|
|
208
|
-
const selected = config.git.releaseTestWorkflow
|
|
208
|
+
const selected = config.git.releaseTestWorkflow;
|
|
209
209
|
requireThat(selected === filename, 'Configure git.releaseTestWorkflow to select the checked PR workflow.');
|
|
210
210
|
});
|
|
211
211
|
return { errors, published };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const { ROOT, environmentFingerprint, receiptPath, snapshot } = require('./receipt-state');
|
|
5
|
-
const { projectConfig } = require('./project-config');
|
|
5
|
+
const { projectConfig, requireTestCommands } = require('./project-config');
|
|
6
6
|
function successMessage(root = ROOT()) {
|
|
7
7
|
const command = projectConfig(root).tests.npmTestUsesRunner
|
|
8
8
|
? 'npm test -- rerun'
|
|
@@ -20,6 +20,7 @@ function matchesReceipt(receipt, root = ROOT()) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function checkReceipt(root = ROOT()) {
|
|
23
|
+
requireTestCommands(root);
|
|
23
24
|
let receipt;
|
|
24
25
|
try {
|
|
25
26
|
receipt = JSON.parse(fs.readFileSync(receiptPath(root), 'utf8'));
|
|
@@ -176,14 +176,10 @@ function checkpoint(repositoryRoot, message, log = console.log) {
|
|
|
176
176
|
return { committed: false, pushed: false };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
const pushRemotes = checkpointPushRemotes
|
|
180
|
-
remoteBranchExists(repositoryRoot, branch) ? [remote] : []
|
|
181
|
-
);
|
|
179
|
+
const pushRemotes = checkpointPushRemotes;
|
|
182
180
|
if (pushRemotes.length === 0) {
|
|
183
181
|
clearFailureMarker(repositoryRoot);
|
|
184
|
-
log(
|
|
185
|
-
? `Kept ${branch} local because ${remote}/${branch} does not exist.`
|
|
186
|
-
: `Kept ${branch} local because checkpoint push is disabled.`);
|
|
182
|
+
log(`Kept ${branch} local because checkpoint push is disabled.`);
|
|
187
183
|
return { committed: status !== '', pushed: false };
|
|
188
184
|
}
|
|
189
185
|
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const fs = require('node:fs');
|
|
4
3
|
const { checkpointInstructions } = require('./checkpoint-instructions');
|
|
5
4
|
const { projectConfig } = require('./project-config');
|
|
6
|
-
const { isPrimaryWorktree,
|
|
5
|
+
const { isPrimaryWorktree, runLoggedCheckpointHook } = require('./log-script-invocation');
|
|
7
6
|
|
|
8
7
|
function buildResponse(event) {
|
|
9
8
|
if (event?.hook_event_name !== 'SessionStart' || typeof event.cwd !== 'string') {
|
|
@@ -18,20 +17,8 @@ function buildResponse(event) {
|
|
|
18
17
|
};
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
function main() {
|
|
22
|
-
logScriptInvocation();
|
|
23
|
-
const input = fs.readFileSync(0, 'utf8').trim();
|
|
24
|
-
process.stdout.write(`${JSON.stringify(buildResponse(input === '' ? null : JSON.parse(input)))}\n`);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
20
|
if (require.main === module) {
|
|
28
|
-
|
|
29
|
-
main();
|
|
30
|
-
} catch (error) {
|
|
31
|
-
process.stdout.write(`${JSON.stringify({
|
|
32
|
-
systemMessage: `Codex checkpoint instructions failed: ${error.message}`,
|
|
33
|
-
})}\n`);
|
|
34
|
-
}
|
|
21
|
+
runLoggedCheckpointHook(buildResponse, 'Codex checkpoint instructions failed');
|
|
35
22
|
}
|
|
36
23
|
|
|
37
24
|
module.exports = { buildResponse };
|
|
@@ -14,7 +14,7 @@ const { projectConfig } = require('./project-config');
|
|
|
14
14
|
const { checkpointInstructions } = require('./checkpoint-instructions');
|
|
15
15
|
const {
|
|
16
16
|
isPrimaryWorktree,
|
|
17
|
-
|
|
17
|
+
runLoggedCheckpointHook,
|
|
18
18
|
} = require('./log-script-invocation.js');
|
|
19
19
|
|
|
20
20
|
function runGit(args, cwd) {
|
|
@@ -79,21 +79,8 @@ function buildResponse(event) {
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function main() {
|
|
83
|
-
logScriptInvocation();
|
|
84
|
-
const input = fs.readFileSync(0, 'utf8').trim();
|
|
85
|
-
const event = input === '' ? null : JSON.parse(input);
|
|
86
|
-
process.stdout.write(`${JSON.stringify(buildResponse(event))}\n`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
82
|
if (require.main === module) {
|
|
90
|
-
|
|
91
|
-
main();
|
|
92
|
-
} catch (error) {
|
|
93
|
-
process.stdout.write(`${JSON.stringify({
|
|
94
|
-
systemMessage: `Codex checkpoint check failed: ${error.message}`,
|
|
95
|
-
})}\n`);
|
|
96
|
-
}
|
|
83
|
+
runLoggedCheckpointHook(buildResponse, 'Codex checkpoint check failed');
|
|
97
84
|
}
|
|
98
85
|
|
|
99
86
|
module.exports = { buildResponse };
|
|
@@ -267,8 +267,9 @@ function runWorker(target) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
function launchWorker(target) {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
270
|
+
const loggingEnabled = projectConfig(target.repositoryRoot).loggingEnabled !== false;
|
|
271
|
+
const logPath = loggingEnabled ? path.join(target.commonGitDirectory, 'cleanup-ended-session.log') : null;
|
|
272
|
+
const logFile = loggingEnabled ? fs.openSync(logPath, 'a') : 'ignore';
|
|
272
273
|
const payload = Buffer.from(JSON.stringify(target)).toString('base64url');
|
|
273
274
|
const child = spawn(process.execPath, [__filename, '--worker', payload], {
|
|
274
275
|
cwd: target.repositoryRoot,
|
|
@@ -277,7 +278,7 @@ function launchWorker(target) {
|
|
|
277
278
|
});
|
|
278
279
|
|
|
279
280
|
child.unref();
|
|
280
|
-
fs.closeSync(logFile);
|
|
281
|
+
if (loggingEnabled) fs.closeSync(logFile);
|
|
281
282
|
return { logPath, pid: child.pid };
|
|
282
283
|
}
|
|
283
284
|
|
|
@@ -60,18 +60,49 @@ function logScriptInvocation(options = {}) {
|
|
|
60
60
|
const date = options.date || new Date();
|
|
61
61
|
const processId = options.processId || process.pid;
|
|
62
62
|
const repositoryRoot = sourceRepositoryRoot(cwd);
|
|
63
|
-
const
|
|
63
|
+
const config = projectConfig(repositoryRoot);
|
|
64
|
+
if (config.loggingEnabled === false) return null;
|
|
65
|
+
const logDirectory = path.join(repositoryRoot, config.loggingDirectory);
|
|
64
66
|
const scriptName = path.basename(argv[1] || 'unknown-script');
|
|
65
67
|
const fileName = `${filesystemTimestamp(date)}-${scriptName}-${processId}.log`;
|
|
66
68
|
const commandLine = argv.map(shellQuote).join(' ');
|
|
67
69
|
|
|
68
70
|
fs.mkdirSync(logDirectory, { recursive: true });
|
|
69
71
|
const logPath = path.join(logDirectory, fileName);
|
|
70
|
-
fs.writeFileSync(logPath, `${commandLine}\n`, { flag: 'wx' });
|
|
72
|
+
fs.writeFileSync(logPath, `${commandLine}\n${JSON.stringify({ cwd })}\n`, { flag: 'wx' });
|
|
71
73
|
return logPath;
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
// Only allowlisted event fields are recorded; hook payloads can contain user content.
|
|
77
|
+
function runLoggedCheckpointHook(buildResponse, errorPrefix) {
|
|
78
|
+
let logPath;
|
|
79
|
+
let event;
|
|
80
|
+
let response;
|
|
81
|
+
try {
|
|
82
|
+
logPath = logScriptInvocation();
|
|
83
|
+
const input = fs.readFileSync(0, 'utf8').trim();
|
|
84
|
+
event = input === '' ? null : JSON.parse(input);
|
|
85
|
+
const details = {
|
|
86
|
+
hookEventName: event?.hook_event_name,
|
|
87
|
+
eventCwd: event?.cwd,
|
|
88
|
+
source: event?.source,
|
|
89
|
+
stopHookActive: event?.stop_hook_active,
|
|
90
|
+
};
|
|
91
|
+
if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ event: details })}\n`);
|
|
92
|
+
if (typeof event?.cwd === 'string') {
|
|
93
|
+
const primaryWorktree = isPrimaryWorktree(event.cwd);
|
|
94
|
+
if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ primaryWorktree })}\n`);
|
|
95
|
+
}
|
|
96
|
+
response = buildResponse(event);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
response = { systemMessage: `${errorPrefix}: ${error.message}` };
|
|
99
|
+
}
|
|
100
|
+
if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ response })}\n`);
|
|
101
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
module.exports = {
|
|
105
|
+
runLoggedCheckpointHook,
|
|
75
106
|
filesystemTimestamp,
|
|
76
107
|
isPrimaryWorktree,
|
|
77
108
|
logScriptInvocation,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"git": {
|
|
3
|
+
"remote": "origin",
|
|
4
|
+
"defaultBranch": "main",
|
|
5
|
+
"branchPrefix": "codex/",
|
|
6
|
+
"checkpointPushRemotes": [],
|
|
7
|
+
"releaseMergeMethod": "merge",
|
|
8
|
+
"releaseRequiredChecks": [
|
|
9
|
+
"test"
|
|
10
|
+
],
|
|
11
|
+
"releaseTestWorkflow": "test.yml"
|
|
12
|
+
},
|
|
13
|
+
"tests": {
|
|
14
|
+
"commands": [],
|
|
15
|
+
"receiptMaxAgeMinutes": 60,
|
|
16
|
+
"npmTestUsesRunner": false
|
|
17
|
+
},
|
|
18
|
+
"loggingEnabled": true,
|
|
19
|
+
"loggingDirectory": "tmp/logs",
|
|
20
|
+
"projectChecks": {
|
|
21
|
+
"testWorkflow": "test.yml",
|
|
22
|
+
"requiredJobs": [
|
|
23
|
+
"test"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -11,15 +11,29 @@ function repositoryRoot(cwd = process.cwd()) {
|
|
|
11
11
|
}).trim();
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Kept independent of the defaults so additions require an explicit default.
|
|
15
|
+
const CONFIG_SCHEMA = {
|
|
16
|
+
git: { remote: true, defaultBranch: true, branchPrefix: true,
|
|
17
|
+
checkpointPushRemotes: true, releaseMergeMethod: true,
|
|
18
|
+
releaseRequiredChecks: true, releaseTestWorkflow: true },
|
|
19
|
+
tests: { commands: true, receiptMaxAgeMinutes: true, npmTestUsesRunner: true },
|
|
20
|
+
loggingDirectory: true,
|
|
21
|
+
loggingEnabled: true,
|
|
22
|
+
projectChecks: { testWorkflow: true, requiredJobs: true },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function validateShape(value, schema, location, errors) {
|
|
15
26
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
16
|
-
|
|
27
|
+
errors.push(`${location} must be an object`);
|
|
28
|
+
return;
|
|
17
29
|
}
|
|
18
30
|
for (const key of Object.keys(value)) {
|
|
19
|
-
if (!
|
|
31
|
+
if (!Object.hasOwn(schema, key)) errors.push(`unknown field ${location}.${key}`);
|
|
20
32
|
}
|
|
21
|
-
for (const key of
|
|
22
|
-
|
|
33
|
+
for (const [key, child] of Object.entries(schema)) {
|
|
34
|
+
const name = location === 'config' ? key : `${location}.${key}`;
|
|
35
|
+
if (!Object.hasOwn(value, key)) errors.push(`missing field ${name}`);
|
|
36
|
+
else if (child !== true) validateShape(value[key], child, name, errors);
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
|
|
@@ -30,51 +44,44 @@ function requireNonemptyString(value, location, file) {
|
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
function validateConfig(config, file) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
requireExactKeys(config.git, ['remote', 'defaultBranch', 'branchPrefix'], 'git', file, ['checkpointPushRemotes', 'releaseMergeMethod', 'releaseRequiredChecks', 'releaseTestWorkflow']);
|
|
45
|
-
requireExactKeys(config.tests, ['commands', 'receiptMaxAgeMinutes', 'npmTestUsesRunner'], 'tests', file);
|
|
47
|
+
const errors = [];
|
|
48
|
+
validateShape(config, CONFIG_SCHEMA, 'config', errors);
|
|
49
|
+
if (errors.length) throw new Error(`${file}: ${errors.join('; ')}. Run peacekeeper update-config to add missing fields.`);
|
|
50
|
+
if (typeof config.projectChecks.testWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.projectChecks.testWorkflow)) {
|
|
51
|
+
throw new Error(`${file}: projectChecks.testWorkflow must be a workflow filename`);
|
|
52
|
+
}
|
|
53
|
+
const jobs = config.projectChecks.requiredJobs;
|
|
54
|
+
if (!Array.isArray(jobs) || jobs.some(job => typeof job !== 'string' || !job.trim() || job !== job.trim()) || new Set(jobs).size !== jobs.length) {
|
|
55
|
+
throw new Error(`${file}: projectChecks.requiredJobs must contain unique nonempty job names`);
|
|
56
|
+
}
|
|
46
57
|
|
|
47
58
|
for (const field of ['remote', 'defaultBranch', 'branchPrefix']) {
|
|
48
59
|
requireNonemptyString(config.git[field], `git.${field}`, file);
|
|
49
60
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
||
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (
|
|
65
|
-
const checks = config.git.releaseRequiredChecks;
|
|
66
|
-
if (!Array.isArray(checks) || !checks.length || checks.some(check => typeof check !== 'string' || !check.trim() || check !== check.trim()) || new Set(checks).size !== checks.length) {
|
|
67
|
-
throw new Error(`${file}: git.releaseRequiredChecks must be a nonempty array of unique check names`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
if (Object.hasOwn(config.git, 'releaseMergeMethod')
|
|
71
|
-
&& !['merge', 'squash', 'rebase'].includes(config.git.releaseMergeMethod)) {
|
|
61
|
+
const remotes = config.git.checkpointPushRemotes;
|
|
62
|
+
if (!Array.isArray(remotes)
|
|
63
|
+
|| remotes.some((remote) => typeof remote !== 'string' || remote.trim() === ''
|
|
64
|
+
|| remote !== remote.trim() || remote.startsWith('-'))
|
|
65
|
+
|| new Set(remotes).size !== remotes.length) {
|
|
66
|
+
throw new Error(`${file}: git.checkpointPushRemotes must be an array of unique remote names`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof config.git.releaseTestWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.git.releaseTestWorkflow)) {
|
|
69
|
+
throw new Error(`${file}: git.releaseTestWorkflow must be a workflow filename ending in .yml or .yaml`);
|
|
70
|
+
}
|
|
71
|
+
const checks = config.git.releaseRequiredChecks;
|
|
72
|
+
if (!Array.isArray(checks) || checks.some(check => typeof check !== 'string' || !check.trim() || check !== check.trim()) || new Set(checks).size !== checks.length) {
|
|
73
|
+
throw new Error(`${file}: git.releaseRequiredChecks must be an array of unique check names`);
|
|
74
|
+
}
|
|
75
|
+
if (!['merge', 'squash', 'rebase'].includes(config.git.releaseMergeMethod)) {
|
|
72
76
|
throw new Error(`${file}: git.releaseMergeMethod must be merge, squash, or rebase`);
|
|
73
77
|
}
|
|
78
|
+
if (typeof config.loggingEnabled !== 'boolean') {
|
|
79
|
+
throw new Error(`${file}: loggingEnabled must be a boolean`);
|
|
80
|
+
}
|
|
74
81
|
requireNonemptyString(config.loggingDirectory, 'loggingDirectory', file);
|
|
75
|
-
if (!Array.isArray(config.tests.commands)
|
|
82
|
+
if (!Array.isArray(config.tests.commands)
|
|
76
83
|
|| config.tests.commands.some((command) => typeof command !== 'string' || command.trim() === '')) {
|
|
77
|
-
throw new Error(`${file}: tests.commands must be
|
|
84
|
+
throw new Error(`${file}: tests.commands must be an array of command strings`);
|
|
78
85
|
}
|
|
79
86
|
if (!Number.isSafeInteger(config.tests.receiptMaxAgeMinutes)
|
|
80
87
|
|| config.tests.receiptMaxAgeMinutes < 1) {
|
|
@@ -88,12 +95,17 @@ function validateConfig(config, file) {
|
|
|
88
95
|
function projectConfig(cwd = process.cwd()) {
|
|
89
96
|
const root = repositoryRoot(cwd);
|
|
90
97
|
const file = path.join(root, CONFIG_FILE);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
98
|
+
const config = fs.existsSync(file)
|
|
99
|
+
? JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
100
|
+
: structuredClone(require('./project-config-defaults.json'));
|
|
95
101
|
validateConfig(config, file);
|
|
96
102
|
return { root, ...config };
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
function requireTestCommands(root) {
|
|
106
|
+
if (!projectConfig(root).tests.commands.length) {
|
|
107
|
+
throw new Error('Configure tests.commands before running tests or using a test receipt.');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { CONFIG_FILE, CONFIG_SCHEMA, validateConfig, projectConfig, repositoryRoot, requireTestCommands };
|
package/scripts/receipt-state.js
CHANGED
|
@@ -43,7 +43,7 @@ function fileFingerprint(root, relative) {
|
|
|
43
43
|
|
|
44
44
|
function sharedScriptsFingerprint() {
|
|
45
45
|
const hash = crypto.createHash('sha256');
|
|
46
|
-
for (const name of fs.readdirSync(__dirname).filter((file) => file.endsWith('.js')).sort()) {
|
|
46
|
+
for (const name of fs.readdirSync(__dirname).filter((file) => file.endsWith('.js') || file === 'project-config-defaults.json').sort()) {
|
|
47
47
|
hash.update(name);
|
|
48
48
|
hash.update('\0');
|
|
49
49
|
hash.update(fs.readFileSync(path.join(__dirname, name)));
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const { requireTestCommands } = require('./project-config');
|
|
5
6
|
const { ROOT, receiptPath, snapshot } = require('./receipt-state');
|
|
6
7
|
|
|
7
8
|
function recordReceipt(root = ROOT(), testedSnapshot) {
|
|
9
|
+
requireTestCommands(root);
|
|
8
10
|
const current = snapshot(root, testedSnapshot?.baseCommit);
|
|
9
11
|
if (testedSnapshot && JSON.stringify(current) !== JSON.stringify(testedSnapshot)) {
|
|
10
12
|
throw new Error('Files or environment changed while tests ran; rerun tests before recording a receipt.');
|
package/scripts/release-core.js
CHANGED
|
@@ -4,7 +4,7 @@ const os = require('node:os');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const yaml = require('yaml');
|
|
6
6
|
const { githubApi, requiredChecks, verifyProtection } = require('./release-protection');
|
|
7
|
-
const {
|
|
7
|
+
const { projectConfig } = require('./project-config');
|
|
8
8
|
const requiredCondition = "${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}";
|
|
9
9
|
const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
|
|
10
10
|
const pollIntervalMs = 5_000;
|
|
@@ -223,13 +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 =
|
|
227
|
-
? projectConfig(cwd).git.releaseMergeMethod : undefined;
|
|
226
|
+
const configured = projectConfig(cwd).git.releaseMergeMethod;
|
|
228
227
|
const allowed = ['merge', 'rebase', 'squash'].filter((method) =>
|
|
229
228
|
repository[{ merge: 'allow_merge_commit', rebase: 'allow_rebase_merge', squash: 'allow_squash_merge' }[method]] === true);
|
|
230
229
|
if (!allowed.length)
|
|
231
230
|
throw new ReleaseError('No supported PR merge method is enabled.');
|
|
232
|
-
const mergeMethod = allowed.length === 1 ? allowed[0] : configured
|
|
231
|
+
const mergeMethod = allowed.length === 1 ? allowed[0] : configured;
|
|
233
232
|
if (!allowed.includes(mergeMethod))
|
|
234
233
|
throw new ReleaseError(`Configured release merge method ${mergeMethod} is not enabled on GitHub.`);
|
|
235
234
|
const defaultBranch = repository.default_branch;
|
package/scripts/run-tests.js
CHANGED
|
@@ -4,7 +4,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const { checkReceipt, successMessage } = require('./check-test-receipt');
|
|
6
6
|
const { recordReceipt } = require('./record-test-receipt');
|
|
7
|
-
const { projectConfig } = require('./project-config');
|
|
7
|
+
const { projectConfig, requireTestCommands } = require('./project-config');
|
|
8
8
|
const { receiptPath, snapshot } = require('./receipt-state');
|
|
9
9
|
|
|
10
10
|
function run(command, root) {
|
|
@@ -23,6 +23,7 @@ function hasReusableReceipt() {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function main() {
|
|
26
|
+
requireTestCommands();
|
|
26
27
|
const config = projectConfig();
|
|
27
28
|
const force = process.argv.length === 3 && ['rerun', '--force'].includes(process.argv[2]);
|
|
28
29
|
if (process.argv.length !== 2 && !force) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
6
|
+
const { CONFIG_FILE, repositoryRoot, validateConfig } = require('./project-config');
|
|
7
|
+
|
|
8
|
+
function complete(value, defaults) {
|
|
9
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
10
|
+
const result = { ...value };
|
|
11
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
12
|
+
if (!Object.hasOwn(value, key)) result[key] = structuredClone(fallback);
|
|
13
|
+
else if (fallback !== null && typeof fallback === 'object' && !Array.isArray(fallback)) {
|
|
14
|
+
result[key] = complete(value[key], fallback);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function updateConfig(cwd = process.cwd()) {
|
|
21
|
+
const file = path.join(repositoryRoot(cwd), CONFIG_FILE);
|
|
22
|
+
const defaults = require('./project-config-defaults.json');
|
|
23
|
+
validateConfig(defaults, 'bundled defaults');
|
|
24
|
+
const exists = fs.existsSync(file);
|
|
25
|
+
const original = exists ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
26
|
+
const updated = complete(original, defaults);
|
|
27
|
+
validateConfig(updated, file);
|
|
28
|
+
if (!exists || !isDeepStrictEqual(original, updated)) {
|
|
29
|
+
fs.writeFileSync(file, `${JSON.stringify(updated, null, 2)}\n`);
|
|
30
|
+
console.log(`Updated ${file}. Review defaults before using project commands.`);
|
|
31
|
+
} else console.log(`Configuration already complete: ${file}`);
|
|
32
|
+
}
|
|
33
|
+
if (require.main === module) {
|
|
34
|
+
try {
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) console.log('Usage: peacekeeper update-config');
|
|
37
|
+
else if (args.length) throw new Error('Usage: peacekeeper update-config');
|
|
38
|
+
else updateConfig();
|
|
39
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
40
|
+
}
|
|
41
|
+
module.exports = { updateConfig };
|
|
@@ -17,9 +17,9 @@ try {
|
|
|
17
17
|
const api = githubApi((command, args, options) => spawnSync(command, args, { ...options, encoding: 'utf8' }), cwd, host);
|
|
18
18
|
const tag = process.env.RELEASE_TAG;
|
|
19
19
|
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
20
|
-
|
|
20
|
+
inspectWorkflow(cwd, tag);
|
|
21
21
|
verifyRelease({ api, slug, sha, tag, branch: config.git.defaultBranch,
|
|
22
|
-
checks: requiredChecks(cwd), workflow: config.git.releaseTestWorkflow
|
|
22
|
+
checks: requiredChecks(cwd), workflow: config.git.releaseTestWorkflow });
|
|
23
23
|
} catch (error) {
|
|
24
24
|
console.error(error.message);
|
|
25
25
|
process.exitCode = 1;
|