@jakobm/peacekeeper 0.0.0-stage → 0.12.1
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/LICENSE +21 -0
- package/README.md +382 -2
- package/bin/peacekeeper.js +59 -0
- package/package.json +37 -4
- package/scripts/check-project-requirements.txt +326 -0
- package/scripts/check-project.js +235 -0
- package/scripts/check-test-receipt.js +48 -0
- package/scripts/checkpoint-codex-changes.js +236 -0
- package/scripts/checkpoint-codex-session-start.js +37 -0
- package/scripts/checkpoint-codex-stop.js +99 -0
- package/scripts/checkpoint-instructions.js +19 -0
- package/scripts/cleanup-ended-session.js +317 -0
- package/scripts/cleanup-merged-branches.js +382 -0
- package/scripts/install-hooks.js +155 -0
- package/scripts/log-script-invocation.js +80 -0
- package/scripts/project-config.js +99 -0
- package/scripts/queue-merged-worktree-cleanup.js +58 -0
- package/scripts/receipt-state.js +81 -0
- package/scripts/record-test-receipt.js +35 -0
- package/scripts/release-core.js +527 -0
- package/scripts/release-protection.js +67 -0
- package/scripts/release.js +18 -0
- package/scripts/remove-empty-directories.js +163 -0
- package/scripts/run-tests.js +56 -0
- package/scripts/verify-release-core.js +39 -0
- package/scripts/verify-release-protection.js +19 -0
- package/scripts/verify-release-version.js +14 -0
- package/scripts/verify-release.js +26 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE = 'peacekeeper.config.json';
|
|
6
|
+
|
|
7
|
+
function repositoryRoot(cwd = process.cwd()) {
|
|
8
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
}).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requireExactKeys(value, keys, location, file, optionalKeys = []) {
|
|
15
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
16
|
+
throw new Error(`${file}: ${location} must be an object`);
|
|
17
|
+
}
|
|
18
|
+
for (const key of Object.keys(value)) {
|
|
19
|
+
if (!keys.includes(key) && !optionalKeys.includes(key)) throw new Error(`${file}: unknown field ${location}.${key}`);
|
|
20
|
+
}
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
if (!Object.hasOwn(value, key)) throw new Error(`${file}: missing field ${location}.${key}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requireNonemptyString(value, location, file) {
|
|
27
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
28
|
+
throw new Error(`${file}: ${location} must be a nonempty string`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateConfig(config, file) {
|
|
33
|
+
requireExactKeys(config, ['git', 'tests', 'loggingDirectory'], 'config', file, ['projectChecks']);
|
|
34
|
+
if (Object.hasOwn(config, 'projectChecks')) {
|
|
35
|
+
requireExactKeys(config.projectChecks, ['testWorkflow', 'requiredJobs'], 'projectChecks', file);
|
|
36
|
+
if (typeof config.projectChecks.testWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.projectChecks.testWorkflow)) {
|
|
37
|
+
throw new Error(`${file}: projectChecks.testWorkflow must be a workflow filename`);
|
|
38
|
+
}
|
|
39
|
+
const jobs = config.projectChecks.requiredJobs;
|
|
40
|
+
if (!Array.isArray(jobs) || !jobs.length || jobs.some(job => typeof job !== 'string' || !job.trim() || job !== job.trim()) || new Set(jobs).size !== jobs.length) {
|
|
41
|
+
throw new Error(`${file}: projectChecks.requiredJobs must contain unique nonempty job names`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
requireExactKeys(config.git, ['remote', 'defaultBranch', 'branchPrefix'], 'git', file, ['checkpointPushRemotes', 'releaseMergeMethod', 'releaseRequiredChecks', 'releaseTestWorkflow']);
|
|
45
|
+
requireExactKeys(config.tests, ['commands', 'receiptMaxAgeMinutes', 'npmTestUsesRunner'], 'tests', file);
|
|
46
|
+
|
|
47
|
+
for (const field of ['remote', 'defaultBranch', 'branchPrefix']) {
|
|
48
|
+
requireNonemptyString(config.git[field], `git.${field}`, file);
|
|
49
|
+
}
|
|
50
|
+
if (Object.hasOwn(config.git, 'checkpointPushRemotes')) {
|
|
51
|
+
const remotes = config.git.checkpointPushRemotes;
|
|
52
|
+
if (!Array.isArray(remotes)
|
|
53
|
+
|| remotes.some((remote) => typeof remote !== 'string' || remote.trim() === ''
|
|
54
|
+
|| remote !== remote.trim() || remote.startsWith('-'))
|
|
55
|
+
|| new Set(remotes).size !== remotes.length) {
|
|
56
|
+
throw new Error(`${file}: git.checkpointPushRemotes must be an array of unique remote names`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Object.hasOwn(config.git, 'releaseTestWorkflow')) {
|
|
60
|
+
if (typeof config.git.releaseTestWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.git.releaseTestWorkflow)) {
|
|
61
|
+
throw new Error(`${file}: git.releaseTestWorkflow must be a workflow filename ending in .yml or .yaml`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(config.git, 'releaseRequiredChecks')) {
|
|
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)) {
|
|
72
|
+
throw new Error(`${file}: git.releaseMergeMethod must be merge, squash, or rebase`);
|
|
73
|
+
}
|
|
74
|
+
requireNonemptyString(config.loggingDirectory, 'loggingDirectory', file);
|
|
75
|
+
if (!Array.isArray(config.tests.commands) || config.tests.commands.length === 0
|
|
76
|
+
|| config.tests.commands.some((command) => typeof command !== 'string' || command.trim() === '')) {
|
|
77
|
+
throw new Error(`${file}: tests.commands must be a nonempty array of command strings`);
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isSafeInteger(config.tests.receiptMaxAgeMinutes)
|
|
80
|
+
|| config.tests.receiptMaxAgeMinutes < 1) {
|
|
81
|
+
throw new Error(`${file}: tests.receiptMaxAgeMinutes must be a positive integer`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof config.tests.npmTestUsesRunner !== 'boolean') {
|
|
84
|
+
throw new Error(`${file}: tests.npmTestUsesRunner must be a boolean`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function projectConfig(cwd = process.cwd()) {
|
|
89
|
+
const root = repositoryRoot(cwd);
|
|
90
|
+
const file = path.join(root, CONFIG_FILE);
|
|
91
|
+
if (!fs.existsSync(file)) {
|
|
92
|
+
throw new Error(`${file}: configuration file is required; define tests.commands explicitly`);
|
|
93
|
+
}
|
|
94
|
+
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
|
+
validateConfig(config, file);
|
|
96
|
+
return { root, ...config };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { CONFIG_FILE, projectConfig, repositoryRoot };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Queue targeted cleanup for the current linked feature worktree.
|
|
5
|
+
*
|
|
6
|
+
* This explicit entrypoint is intended for workflows that have already
|
|
7
|
+
* confirmed a pull request merge and need cleanup to start before the Codex
|
|
8
|
+
* task is archived. The detached worker performs the same safety checks as the
|
|
9
|
+
* SessionEnd hook.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
captureWorktreeTarget,
|
|
14
|
+
launchWorker,
|
|
15
|
+
} = require('./cleanup-ended-session.js');
|
|
16
|
+
const { logScriptInvocation } = require('./log-script-invocation.js');
|
|
17
|
+
|
|
18
|
+
function helpText() {
|
|
19
|
+
return 'Usage: peacekeeper queue-merged-worktree-cleanup\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function queueCurrentWorktree(cwd = process.cwd(), launch = launchWorker) {
|
|
23
|
+
const target = captureWorktreeTarget(cwd);
|
|
24
|
+
if (!target) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'the current directory must be a linked worktree on a non-default branch',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const job = launch(target);
|
|
31
|
+
return { ...job, target };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function main(args = process.argv.slice(2)) {
|
|
35
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
36
|
+
process.stdout.write(helpText());
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (args.length > 0) {
|
|
40
|
+
throw new Error(`unknown argument: ${args[0]}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logScriptInvocation();
|
|
44
|
+
const { logPath, pid, target } = queueCurrentWorktree();
|
|
45
|
+
console.log(`Queued cleanup for ${target.branch} at ${target.objectId}.`);
|
|
46
|
+
console.log(`Worker PID: ${pid}. Log: ${logPath}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (require.main === module) {
|
|
50
|
+
try {
|
|
51
|
+
main();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`Error: ${error.message}`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { helpText, queueCurrentWorktree };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { repositoryRoot } = require('./project-config');
|
|
7
|
+
|
|
8
|
+
const ROOT = repositoryRoot;
|
|
9
|
+
const RECEIPT_DIRECTORY = 'peacekeeper-test-receipts';
|
|
10
|
+
|
|
11
|
+
function git(root, args) {
|
|
12
|
+
return execFileSync('git', args, { cwd: root, maxBuffer: 10 * 1024 * 1024 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pathsFromOutput(output) {
|
|
16
|
+
return output.toString('utf8').split('\0').filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function changedPaths(root, baseCommit) {
|
|
20
|
+
const tracked = pathsFromOutput(git(root, [
|
|
21
|
+
'diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--name-only', '-z', baseCommit, '--',
|
|
22
|
+
]));
|
|
23
|
+
const untracked = pathsFromOutput(git(root, ['ls-files', '--others', '--exclude-standard', '-z']));
|
|
24
|
+
return [...new Set([...tracked, ...untracked])].sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fileFingerprint(root, relative) {
|
|
28
|
+
const fullPath = path.join(root, relative);
|
|
29
|
+
let stat;
|
|
30
|
+
try {
|
|
31
|
+
stat = fs.lstatSync(fullPath);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') return 'missing';
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const hash = crypto.createHash('sha256');
|
|
37
|
+
hash.update(`${stat.mode & 0o777}\0`);
|
|
38
|
+
if (stat.isSymbolicLink()) hash.update(fs.readlinkSync(fullPath));
|
|
39
|
+
else if (stat.isFile()) hash.update(fs.readFileSync(fullPath));
|
|
40
|
+
else hash.update(`special:${stat.mode}`);
|
|
41
|
+
return hash.digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sharedScriptsFingerprint() {
|
|
45
|
+
const hash = crypto.createHash('sha256');
|
|
46
|
+
for (const name of fs.readdirSync(__dirname).filter((file) => file.endsWith('.js')).sort()) {
|
|
47
|
+
hash.update(name);
|
|
48
|
+
hash.update('\0');
|
|
49
|
+
hash.update(fs.readFileSync(path.join(__dirname, name)));
|
|
50
|
+
hash.update('\0');
|
|
51
|
+
}
|
|
52
|
+
return hash.digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function environmentFingerprint() {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
node: process.version,
|
|
58
|
+
platform: process.platform,
|
|
59
|
+
architecture: process.arch,
|
|
60
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
61
|
+
sharedScripts: sharedScriptsFingerprint(),
|
|
62
|
+
environment: Object.fromEntries(['CI', 'TZ', 'LANG', 'LC_ALL', 'NODE_OPTIONS']
|
|
63
|
+
.map((name) => [name, process.env[name] || ''])),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function snapshot(root = ROOT(), baseCommit = git(root, ['rev-parse', 'HEAD']).toString('utf8').trim()) {
|
|
68
|
+
return {
|
|
69
|
+
baseCommit,
|
|
70
|
+
environment: environmentFingerprint(),
|
|
71
|
+
changedFiles: changedPaths(root, baseCommit)
|
|
72
|
+
.map((relative) => [relative, fileFingerprint(root, relative)]),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function receiptPath(root = ROOT()) {
|
|
77
|
+
const id = crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex');
|
|
78
|
+
return path.join(os.tmpdir(), RECEIPT_DIRECTORY, `${id}.git.json`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { ROOT, environmentFingerprint, receiptPath, snapshot };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { ROOT, receiptPath, snapshot } = require('./receipt-state');
|
|
6
|
+
|
|
7
|
+
function recordReceipt(root = ROOT(), testedSnapshot) {
|
|
8
|
+
const current = snapshot(root, testedSnapshot?.baseCommit);
|
|
9
|
+
if (testedSnapshot && JSON.stringify(current) !== JSON.stringify(testedSnapshot)) {
|
|
10
|
+
throw new Error('Files or environment changed while tests ran; rerun tests before recording a receipt.');
|
|
11
|
+
}
|
|
12
|
+
const receipt = { version: 1, passedAt: Date.now(), ...current };
|
|
13
|
+
const destination = receiptPath(root);
|
|
14
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
15
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
16
|
+
fs.writeFileSync(temporary, `${JSON.stringify(receipt)}\n`);
|
|
17
|
+
fs.renameSync(temporary, destination);
|
|
18
|
+
return receipt;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (require.main === module) {
|
|
22
|
+
if (process.argv.length !== 2) {
|
|
23
|
+
console.error('Usage: node scripts/record-test-receipt.js');
|
|
24
|
+
process.exitCode = 2;
|
|
25
|
+
} else {
|
|
26
|
+
try {
|
|
27
|
+
recordReceipt();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(error.message);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { recordReceipt };
|