@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.
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+
7
+ const PROTECTED_ROOT_DIRECTORIES = new Set(['.git', 'node_modules', 'tmp']);
8
+
9
+ function findRepositoryRoot(cwd) {
10
+ return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
11
+ encoding: 'utf8',
12
+ }).trim();
13
+ }
14
+
15
+ function listPresentFiles(root) {
16
+ const output = execFileSync(
17
+ 'git',
18
+ [
19
+ '-C',
20
+ root,
21
+ 'ls-files',
22
+ '-z',
23
+ '--cached',
24
+ '--others',
25
+ '--exclude-standard',
26
+ ],
27
+ { encoding: 'buffer' },
28
+ );
29
+
30
+ return output
31
+ .toString('utf8')
32
+ .split('\0')
33
+ .filter(Boolean)
34
+ .filter((relativePath) => fs.lstatSync(path.join(root, relativePath), {
35
+ throwIfNoEntry: false,
36
+ }));
37
+ }
38
+
39
+ function requiredDirectories(root, files) {
40
+ const required = new Set();
41
+
42
+ for (const file of files) {
43
+ const absolutePath = path.join(root, file);
44
+ let directory = fs.lstatSync(absolutePath).isDirectory()
45
+ ? file.replace(/\/+$/u, '')
46
+ : path.posix.dirname(file);
47
+
48
+ while (directory !== '.') {
49
+ required.add(directory);
50
+ directory = path.posix.dirname(directory);
51
+ }
52
+ }
53
+
54
+ return required;
55
+ }
56
+
57
+ function findEmptyDirectories(root) {
58
+ const required = requiredDirectories(root, listPresentFiles(root));
59
+
60
+ function visit(absoluteDirectory, relativeDirectory) {
61
+ // A nested repository owns its contents, even when its parent is ignored.
62
+ if (relativeDirectory !== '' && fs.lstatSync(path.join(absoluteDirectory, '.git'), {
63
+ throwIfNoEntry: false,
64
+ })) return { keep: true, directories: [] };
65
+
66
+ let keep = relativeDirectory === '' || required.has(relativeDirectory);
67
+ const directories = [];
68
+ for (const entry of fs.readdirSync(absoluteDirectory, {
69
+ withFileTypes: true,
70
+ })) {
71
+ if (!entry.isDirectory()) continue;
72
+ if (
73
+ relativeDirectory === ''
74
+ && PROTECTED_ROOT_DIRECTORIES.has(entry.name)
75
+ ) continue;
76
+
77
+ const relativePath = relativeDirectory
78
+ ? `${relativeDirectory}/${entry.name}`
79
+ : entry.name;
80
+ const absolutePath = path.join(absoluteDirectory, entry.name);
81
+
82
+ const child = visit(absolutePath, relativePath);
83
+ keep ||= child.keep;
84
+ directories.push(...child.directories);
85
+ }
86
+ return { keep, directories: keep ? directories : [relativeDirectory] };
87
+ }
88
+
89
+ return visit(root, '').directories;
90
+ }
91
+
92
+ function removeEmptyDirectories(root, directories) {
93
+ for (const directory of directories) {
94
+ fs.rmSync(path.join(root, directory), { recursive: true });
95
+ }
96
+ }
97
+
98
+ function parseArguments(args) {
99
+ let execute = false;
100
+
101
+ for (const argument of args) {
102
+ if (argument === '--execute') {
103
+ execute = true;
104
+ } else if (argument === '--dry-run') {
105
+ execute = false;
106
+ } else {
107
+ throw new Error(`unknown argument: ${argument}`);
108
+ }
109
+ }
110
+
111
+ return { execute };
112
+ }
113
+
114
+ function run({ args, cwd, log }) {
115
+ const { execute } = parseArguments(args);
116
+ const root = findRepositoryRoot(cwd);
117
+ const directories = findEmptyDirectories(root);
118
+
119
+ if (directories.length === 0) {
120
+ log('No empty directories found.');
121
+ return;
122
+ }
123
+
124
+ if (execute) {
125
+ removeEmptyDirectories(root, directories);
126
+ for (const directory of directories) log(`Removed ${directory}`);
127
+ return;
128
+ }
129
+
130
+ for (const directory of directories) log(`Would remove ${directory}`);
131
+ log('');
132
+ log('To remove these directories, run:');
133
+ log(' npm exec --no -- peacekeeper remove-empty-directories --execute');
134
+ }
135
+
136
+ function main() {
137
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
138
+ console.log('Usage: remove-empty-directories.js [--dry-run | --execute]');
139
+ console.log('');
140
+ console.log('Dry-run is the default. Use --execute to remove directories containing only ignored files.');
141
+ return;
142
+ }
143
+
144
+ try {
145
+ run({
146
+ args: process.argv.slice(2),
147
+ cwd: process.cwd(),
148
+ log: console.log,
149
+ });
150
+ } catch (error) {
151
+ console.error(`Error: ${error.message}`);
152
+ process.exitCode = 1;
153
+ }
154
+ }
155
+
156
+ if (require.main === module) main();
157
+
158
+ module.exports = {
159
+ findEmptyDirectories,
160
+ parseArguments,
161
+ removeEmptyDirectories,
162
+ run,
163
+ };
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawnSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const { checkReceipt, successMessage } = require('./check-test-receipt');
6
+ const { recordReceipt } = require('./record-test-receipt');
7
+ const { projectConfig } = require('./project-config');
8
+ const { receiptPath, snapshot } = require('./receipt-state');
9
+
10
+ function run(command, root) {
11
+ const result = spawnSync(command, { cwd: root, stdio: 'inherit', shell: true });
12
+ if (result.error) throw result.error;
13
+ return result.status ?? 1;
14
+ }
15
+
16
+ function hasReusableReceipt() {
17
+ try {
18
+ return checkReceipt();
19
+ } catch (error) {
20
+ console.error(error.message);
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function main() {
26
+ const config = projectConfig();
27
+ const force = process.argv.length === 3 && ['rerun', '--force'].includes(process.argv[2]);
28
+ if (process.argv.length !== 2 && !force) {
29
+ console.error('Usage: node scripts/run-tests.js [rerun|--force]');
30
+ process.exitCode = 2;
31
+ return;
32
+ }
33
+ if (!force && !process.env.CI && hasReusableReceipt()) {
34
+ console.log(successMessage(config.root));
35
+ return;
36
+ }
37
+ fs.rmSync(receiptPath(config.root), { force: true });
38
+ const testedSnapshot = snapshot(config.root);
39
+ for (const command of config.tests.commands) {
40
+ const status = run(command, config.root);
41
+ if (status !== 0) {
42
+ process.exitCode = status;
43
+ return;
44
+ }
45
+ }
46
+ recordReceipt(config.root, testedSnapshot);
47
+ }
48
+
49
+ if (require.main === module) {
50
+ try {
51
+ main();
52
+ } catch (error) {
53
+ console.error(error.message);
54
+ process.exitCode = 1;
55
+ }
56
+ }
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ const { verifyProtection } = require('./release-protection');
3
+
4
+ function list(api, endpoint, member) {
5
+ const items = [];
6
+ for (let page = 1; ; page += 1) {
7
+ const response = api(`${endpoint}${endpoint.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
8
+ const batch = member ? response[member] : response;
9
+ if (!Array.isArray(batch)) throw new Error(`GitHub returned an invalid list for ${endpoint}`);
10
+ items.push(...batch);
11
+ if (batch.length < 100) return items;
12
+ }
13
+ }
14
+
15
+ function verifyRelease({ api, slug, sha, tag, branch, checks, workflow }) {
16
+ if (!/^v\d+\.\d+\.\d+$/u.test(tag)) throw new Error('Expected a stable release tag');
17
+ verifyProtection({ api, slug, branch, checks });
18
+ const base = `repos/${slug}`;
19
+ const commit = api(`${base}/commits/${encodeURIComponent(sha)}`);
20
+ const comparison = api(`${base}/compare/${commit.sha}...${encodeURIComponent(branch)}`);
21
+ if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('Release commit must belong to the default branch');
22
+ const pulls = list(api, `${base}/commits/${commit.sha}/pulls`);
23
+ const pull = pulls.find(pr => pr.merged_at && pr.merge_commit_sha === commit.sha &&
24
+ pr.base.ref === branch && pr.base.repo.full_name === slug &&
25
+ pr.head.repo?.full_name === slug && pr.head.ref === `release/${tag}`);
26
+ if (!pull) throw new Error('Tag must point to a merged release PR');
27
+ const runs = list(api, `${base}/actions/workflows/${encodeURIComponent(workflow)}/runs?event=pull_request&head_sha=${pull.head.sha}`, 'workflow_runs');
28
+ const run = runs.sort((a, b) => b.run_number - a.run_number)[0];
29
+ if (!run || run.conclusion !== 'success') throw new Error('Release PR must have a successful CI run');
30
+ const jobs = list(api, `${base}/actions/runs/${run.id}/jobs?filter=latest`, 'jobs');
31
+ for (const name of checks) {
32
+ const matching = jobs.filter(job => job.name === name);
33
+ if (!matching.length || matching.some(job => job.conclusion !== 'success')) {
34
+ throw new Error(`Release PR requires successful ${name} check`);
35
+ }
36
+ }
37
+ }
38
+
39
+ module.exports = { verifyRelease };
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { spawnSync } = require('node:child_process');
4
+ const { projectConfig } = require('./project-config');
5
+ const { originRepository } = require('./release-core');
6
+ const { githubApi, requiredChecks, verifyProtection } = require('./release-protection');
7
+
8
+ try {
9
+ const cwd = process.cwd();
10
+ const config = projectConfig(cwd);
11
+ const remote = spawnSync('git', ['remote', 'get-url', config.git.remote], { encoding: 'utf8' });
12
+ if (remote.status !== 0) throw new Error(remote.stderr);
13
+ const { host, slug } = originRepository(remote.stdout.trim());
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) });
16
+ } catch (error) {
17
+ console.error(error.message);
18
+ process.exitCode = 1;
19
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+
6
+ const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8'));
7
+ const lockfile = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
8
+ const expectedTag = `v${manifest.version}`;
9
+ if (process.env.RELEASE_TAG !== expectedTag) {
10
+ throw new Error(`Release tag must be ${expectedTag}, got ${process.env.RELEASE_TAG}`);
11
+ }
12
+ if (lockfile.version !== manifest.version || lockfile.packages[''].version !== manifest.version) {
13
+ throw new Error('package-lock.json version must match package.json');
14
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { spawnSync, execFileSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+ const { projectConfig } = require('./project-config');
6
+ const { originRepository, inspectWorkflow } = require('./release-core');
7
+ const { githubApi, requiredChecks } = require('./release-protection');
8
+ const { verifyRelease } = require('./verify-release-core');
9
+
10
+ try {
11
+ // Preserve the standalone version command's contract and error messages.
12
+ execFileSync(process.execPath, [path.join(__dirname, 'verify-release-version.js')], { stdio: 'inherit' });
13
+ const cwd = process.cwd();
14
+ const config = projectConfig(cwd);
15
+ const remote = execFileSync('git', ['remote', 'get-url', config.git.remote], { encoding: 'utf8' }).trim();
16
+ const { host, slug } = originRepository(remote);
17
+ const api = githubApi((command, args, options) => spawnSync(command, args, { ...options, encoding: 'utf8' }), cwd, host);
18
+ const tag = process.env.RELEASE_TAG;
19
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
20
+ const publishWorkflow = inspectWorkflow(cwd, tag);
21
+ verifyRelease({ api, slug, sha, tag, branch: config.git.defaultBranch,
22
+ checks: requiredChecks(cwd), workflow: config.git.releaseTestWorkflow || path.basename(publishWorkflow.path) });
23
+ } catch (error) {
24
+ console.error(error.message);
25
+ process.exitCode = 1;
26
+ }