@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,326 @@
1
+ CHECK-PROJECT: EXPECTED PROJECT ARCHITECTURE
2
+
3
+ Purpose and scope
4
+ One project pins its Node/npm toolchain, installs the peacekeeper Codex
5
+ lifecycle hooks, and runs reproducible PR checks. Published npm packages also
6
+ verify a release before publishing the tested version.
7
+
8
+ check-project reads local files at the Git root. It makes no network requests,
9
+ runs no project scripts, and changes no files. Failure exits with status 1.
10
+ Independent failures are reported together. Missing or unreadable prerequisites
11
+ can block dependent checks; the reported failures are not an exhaustive list
12
+ of problems behind those prerequisites. Fix them and run the command again.
13
+ --explain prints this entire document without loading or checking a project.
14
+
15
+ Project configuration
16
+ A valid peacekeeper.config.json is required at the Git root, along with
17
+ package.json. The normal configuration schema still applies.
18
+
19
+ Select the PR workflow by filename, in this order:
20
+ projectChecks.testWorkflow -> git.releaseTestWorkflow -> test.yml
21
+ Select required job names in this order:
22
+ projectChecks.requiredJobs -> git.releaseRequiredChecks -> ["test"]
23
+ For published packages, git.releaseRequiredChecks is mandatory and its names
24
+ are always added to the required jobs. Set git.releaseTestWorkflow to the
25
+ selected PR workflow for release verification. Published projects cannot
26
+ select different workflows for project checks and release verification.
27
+
28
+ Example configuration fields (merge into the full project configuration):
29
+ "git": {
30
+ "releaseTestWorkflow": "package.yml",
31
+ "releaseRequiredChecks": ["test"]
32
+ },
33
+ "projectChecks": {
34
+ "testWorkflow": "package.yml",
35
+ "requiredJobs": ["test"]
36
+ }
37
+ projectChecks is optional; when present it requires both testWorkflow and
38
+ requiredJobs. Omit it when release settings already select the desired jobs.
39
+
40
+ Toolchain: package.json and all Node-based CI jobs
41
+ Pin exact versions in package.json with:
42
+ volta pin node@<exact-version> npm@<exact-version>
43
+ Both volta.node and volta.npm must be exact semantic versions. engines.node
44
+ must be a valid range satisfied by volta.node. Remove .nvmrc even when its
45
+ version matches volta.node; package.json volta pins select Node and npm.
46
+
47
+ Install Volta locally and ensure its shims are first in PATH (volta setup).
48
+ In each CI job using setup-node, Volta, or direct Node/npm/npx/Volta commands:
49
+ - Check out the repository before one volta-cli/action setup step.
50
+ - Checkout and Volta setup must be unconditional and must not allow failure.
51
+ - Do not set node-version, npm-version, or package-json-path on Volta setup.
52
+ - Run Node/npm/npx commands only after Volta setup.
53
+ - Remove actions/setup-node, nvm use/install, volta install/pin/run, and
54
+ global npm install commands from CI. package.json selects the toolchain.
55
+
56
+ Minimal setup steps:
57
+ - uses: actions/checkout@v5
58
+ - uses: volta-cli/action@v5
59
+ - run: npm ci
60
+ - run: npm test
61
+
62
+ Codex lifecycle hooks
63
+ .codex/hooks.json must contain the current peacekeeper hooks exactly once
64
+ for each lifecycle event, in their expected order, without group conditions.
65
+ Unrelated hooks are allowed. Repair with:
66
+ peacekeeper install-hooks
67
+ Hook installation does not itself grant Codex trust.
68
+
69
+ All GitHub Actions workflows
70
+ Every .yml/.yaml file under .github/workflows must parse and declare jobs.
71
+ Where present, actions/checkout and volta-cli/action must use stable version
72
+ tags at major 5 or newer (v5, v5.0, v5.0.0, or newer). These are compatibility
73
+ floors, not latest-version recommendations. SHA and branch refs cannot be
74
+ verified offline and are rejected for these actions.
75
+
76
+ Runner labels must not contain moving *-latest images. Choose versioned
77
+ images suitable for the project, such as ubuntu-24.04. Direct labels, label
78
+ objects, and matrix.<key> references including matrix include are inspected.
79
+ Other dynamic runner expressions are not resolved.
80
+
81
+ Required PR workflow and jobs
82
+ The selected workflow must declare pull_request. PR filters must not use
83
+ paths, paths-ignore, branches-ignore, or types. If branches is specified, it
84
+ must include git.defaultBranch literally.
85
+
86
+ Each required name must match exactly one static job display name (name), or
87
+ the job ID if name is absent. Expressions in required names are rejected.
88
+ Required jobs and all their needs dependencies must use local steps, must
89
+ not allow failure, and must not have conditions that skip required PR checks.
90
+ Accepted job conditions are absent, success(), ${{ success() }}, or exactly:
91
+ ${{ github.event_name == 'pull_request' }}
92
+
93
+ Each required test job must run unconditional npm ci before an unconditional
94
+ supported validation command, with neither step allowed to fail. Commands
95
+ must be direct run steps, exactly one of:
96
+ npm test
97
+ npm run test
98
+ npm run test:<name> (name contains letters, digits, _ or -)
99
+ npm pack --dry-run
100
+ npm pack --dry-run --json
101
+ Shell wrappers, combined commands, and reusable workflows cannot establish
102
+ these requirements. Conditional reporting steps after tests are allowed.
103
+ An unconditional step either omits if or sets it to success() or
104
+ ${{ success() }}.
105
+
106
+ Matrix example: tests on three platforms and one static required gate:
107
+ jobs:
108
+ matrix-tests:
109
+ strategy:
110
+ matrix:
111
+ os: [ubuntu-24.04, macos-15, windows-2025]
112
+ runs-on: ${{ matrix.os }}
113
+ steps:
114
+ - uses: actions/checkout@v5
115
+ - uses: volta-cli/action@v5
116
+ - run: npm ci
117
+ - run: npm test
118
+ test:
119
+ needs: matrix-tests
120
+ runs-on: ubuntu-24.04
121
+ steps:
122
+ - uses: actions/checkout@v5
123
+ - uses: volta-cli/action@v5
124
+ - run: npm ci
125
+ - run: npm pack --dry-run
126
+ Configure the required name as "test". Its default success condition means it
127
+ only passes after the entire matrix succeeds; it also validates the package.
128
+ Keep this gate name static and unique. Matrix-generated check names alone do
129
+ not provide the single exact match required by release verification. Actual
130
+ GitHub check contexts and branch protection still need online verification.
131
+
132
+ If any matrix leg fails or is cancelled, the dependent test gate is normally
133
+ skipped, not failed. verify-release requires each configured release job to
134
+ conclude success, so a skipped gate blocks publication (even if the overall
135
+ workflow conclusion were success).
136
+
137
+ GitHub branch protection treats skipped jobs as acceptable status checks.
138
+ Requiring only the gate therefore does NOT guarantee a failing matrix blocks
139
+ merging. Also require every matrix test check in GitHub branch protection or
140
+ its active ruleset, using the exact check names observed in a PR run. Keep
141
+ those requirements aligned when matrix membership changes. They supplement
142
+ the static "test" name in git.releaseRequiredChecks; do not put dynamic matrix
143
+ names in that setting. Alternatively, use separate static required jobs for
144
+ each platform and list all of them in git.releaseRequiredChecks. A gate using
145
+ always() to fail explicitly on failed dependencies is not currently accepted
146
+ by check-project's required-job condition rules.
147
+
148
+ Publication: packages without private: true
149
+ package.json version must be exact and stable (no prerelease). Commit a
150
+ package-lock.json or npm-shrinkwrap.json; existing lockfile version fields
151
+ must match the package version.
152
+
153
+ Exactly one workflow must contain a job with ID publish. It must have a
154
+ static display name and a step whose run is exactly npm publish. The workflow
155
+ must accept the current v<package-version> tag on push. The publish job must
156
+ either run in a workflow restricted to v-prefixed tag pushes, or use exactly:
157
+ if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
158
+ The publish job must not allow failure.
159
+
160
+ In publish steps, run unconditional npm ci, then unconditional verify-release,
161
+ then npm publish. Neither install nor verification may allow failure.
162
+ Accepted direct verification commands:
163
+ peacekeeper verify-release
164
+ npm exec --no -- peacekeeper verify-release
165
+ node bin/peacekeeper.js verify-release
166
+ node node_modules/@jakobm/peacekeeper/bin/peacekeeper.js verify-release
167
+ The node paths may also start with ./.
168
+
169
+ Verification must receive these environment inputs, directly on the step or
170
+ inherited from the job/workflow:
171
+ RELEASE_TAG: ${{ github.ref_name }}
172
+ GH_TOKEN: ${{ secrets.RELEASE_POLICY_TOKEN }}
173
+ Set job permissions (or workflow permissions if the job has none) to include:
174
+ contents: read
175
+ id-token: write
176
+ contents: write is also accepted. The same toolchain rules apply to publishing.
177
+
178
+ private: true skips publication checks, but not toolchain, hooks, or PR checks.
179
+
180
+ Online release policy and GitHub token setup
181
+ These are online installation requirements; check-project only verifies the
182
+ local declarations above. Use verify-release-protection to check the branch
183
+ policy and verify-release before publishing to check the release itself.
184
+
185
+ Configure the default branch from git.defaultBranch with every exact job name
186
+ in git.releaseRequiredChecks mandatory and "Require branches to be up to date
187
+ before merging" enabled. Choose either:
188
+ - Classic branch protection with strict status checks and enforcement for
189
+ administrators as well (no administrator bypass).
190
+ - Active branch rulesets requiring strict status checks, without any bypass
191
+ actors. Organization-inherited rulesets are included in verification.
192
+ Merge queues are not currently an alternative to this strict policy. For the
193
+ matrix example, also require its matrix checks as described above.
194
+
195
+ Create a fine-grained GitHub PAT or GitHub App installation token with access
196
+ to this repository and these repository permissions:
197
+ Administration: read
198
+ Contents: read
199
+ Pull requests: read
200
+ Actions: read
201
+ For repository rulesets, Administration: write is additionally needed because
202
+ GitHub only exposes bypass actors to callers with write access to the ruleset.
203
+ For organization-inherited rulesets, repository Administration permission
204
+ alone does not establish write access to the organization-owned ruleset.
205
+ Consult GitHub's organization ruleset endpoint documentation for supported
206
+ token types and organization permissions (the fine-grained token section):
207
+ https://docs.github.com/en/rest/orgs/rules#get-an-organization-repository-ruleset
208
+ The token must also have write access to the contributing ruleset for GitHub
209
+ to expose bypass actors. Missing bypass information causes verification to
210
+ fail; confirm access with verify-release-protection.
211
+
212
+ Recommended Actions secret name: RELEASE_POLICY_TOKEN. A maintainer with
213
+ permission to manage Actions secrets can save it with:
214
+ gh secret set RELEASE_POLICY_TOKEN --repo OWNER/REPO
215
+ Paste the token at the interactive prompt. Map the secret to GH_TOKEN on the
216
+ verification step. This GitHub token verifies policy and CI; npm publishing
217
+ uses a separate OIDC identity. The ordinary workflow GITHUB_TOKEN cannot gain
218
+ Administration permissions through workflow YAML. Rotate the policy token
219
+ when it expires, or arrange short-lived GitHub App tokens in your workflow.
220
+ Fork PRs do not receive repository secrets; do not expose this token by
221
+ running fork code with pull_request_target.
222
+
223
+ verify-release-protection uses GITHUB_BASE_REF, or git.defaultBranch outside
224
+ PR CI. It can be run before tests in a required trusted PR job. verify-release
225
+ checks matching package/tag/lockfile versions, that the tagged commit belongs
226
+ to the default branch and is the merge commit of a same-repository merged
227
+ release/v<version> PR, and the latest selected PR workflow run and required
228
+ jobs. The workflow and all matching required jobs must conclude success.
229
+ A manually created tag without that release PR is rejected. API access errors
230
+ and unverifiable protection settings stop verification. Use the release
231
+ command to prepare the release PR and tag through the supported workflow.
232
+
233
+ Complete npm trusted-publishing example with Volta
234
+ Choose one layout; keep exactly one job with ID publish across all workflows:
235
+ - Keep publishing in the existing package.yml alongside PR checks. Merge
236
+ the publish job below into that file, retain its pull_request trigger,
237
+ add push.tags: ['v*'], and set the publish job condition exactly to:
238
+ if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
239
+ Keep git.releaseTestWorkflow and the npm trusted-publisher filename set
240
+ to package.yml. Do not also create a publish job in another workflow.
241
+ - Move publishing to a separate publish.yml as shown below. Remove the old
242
+ publish job from package.yml (and its tag trigger if no other job needs
243
+ it). Keep PR checks in package.yml and git.releaseTestWorkflow pointing
244
+ there. Set the npm trusted-publisher filename to publish.yml.
245
+
246
+ The complete example below uses the second layout for a public npm package.
247
+ Adapt an existing publish.yml instead of adding a duplicate publish job. Install
248
+ @jakobm/peacekeeper as a project development dependency so npm ci makes the
249
+ verification command available. The GitHub-hosted Ubuntu runner provides gh.
250
+
251
+ Pin Node >=22.14.0 and npm >=11.5.1 in package.json for npm trusted publishing;
252
+ for example, exact pins 22.14.0 and 11.21.0 meet those minimums. Do not install
253
+ another npm globally in CI. In the package's npmjs.com settings, add a GitHub
254
+ Actions trusted publisher with the exact owner, repository and workflow
255
+ filename publish.yml for the separate layout, or package.yml for the combined
256
+ layout (filename only). Enable direct npm publish in its
257
+ allowed actions; staged publishing alone does not support this workflow.
258
+ If you configure an npm trusted-publisher environment name, add the matching
259
+ environment to the publish job. Use GitHub-hosted runners; npm does not
260
+ currently support self-hosted runners for trusted publishing.
261
+
262
+ Set package.json publication settings for this public npm package:
263
+ "publishConfig": {
264
+ "registry": "https://registry.npmjs.org/",
265
+ "access": "public"
266
+ }
267
+ Use the package's intended visibility instead if publishing a private scoped
268
+ npm package. npm package visibility is separate from package.json private:
269
+ true, which prevents npm publication altogether.
270
+
271
+ Complete .github/workflows/publish.yml:
272
+ name: Publish
273
+ on:
274
+ push:
275
+ tags: ['v*']
276
+ jobs:
277
+ publish:
278
+ runs-on: ubuntu-24.04
279
+ permissions:
280
+ contents: read
281
+ id-token: write
282
+ steps:
283
+ - uses: actions/checkout@v5
284
+ - uses: volta-cli/action@v5
285
+ with:
286
+ registry-url: https://registry.npmjs.org
287
+ - run: npm ci
288
+ - name: Verify release
289
+ env:
290
+ GH_TOKEN: ${{ secrets.RELEASE_POLICY_TOKEN }}
291
+ RELEASE_TAG: ${{ github.ref_name }}
292
+ run: npm exec --no -- peacekeeper verify-release
293
+ - run: npm publish
294
+
295
+ Volta selects the pinned tools and its registry-url input configures npm's
296
+ registry, replacing that setup-node responsibility. publishConfig also pins
297
+ the publication target. npm publish detects GitHub's OIDC environment and
298
+ exchanges its identity for a short-lived npm credential; id-token: write
299
+ enables this and the npm trusted-publisher entry authorizes the workflow.
300
+ No NPM_TOKEN, npm login, or secret NODE_AUTH_TOKEN is needed to publish.
301
+ Do not carry an old npm publishing token into this workflow. If npm ci needs
302
+ private dependencies, configure their read authentication separately; OIDC
303
+ publishing does not authenticate dependency installation. Add any necessary
304
+ build step before publication, keeping npm publish as its own direct step.
305
+
306
+ check-project verifies supported local workflow structure, not the npm OIDC
307
+ minimum versions, trusted-publisher registration, package visibility, registry
308
+ credentials, or actual publishing access. Verify those external settings too.
309
+ Upstream references for provider-specific setup and changing prerequisites:
310
+ https://docs.npmjs.com/trusted-publishers
311
+ https://github.com/volta-cli/action#readme
312
+ https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
313
+
314
+ What a passing check does and does not establish
315
+ Passing means the supported local configuration matches these conventions.
316
+ It does not prove tests ran or passed, inspect test-script behavior, validate
317
+ installed dependency versions, or check Git remotes and ignored files.
318
+ It cannot confirm actual GitHub branch protection, token/secret access,
319
+ successful workflow runs, or npm trusted publisher configuration. Those need
320
+ online verification; verify-release handles the release checks it supports.
321
+
322
+ Using this check in the test chain
323
+ Add "pretest": "peacekeeper check-project" to package.json, prepending it
324
+ to any existing pretest command. This also runs before cached test receipts
325
+ can skip the test runner. Putting the check only in tests.commands does not
326
+ guarantee it runs when a cached receipt is reused.
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { isDeepStrictEqual } = require('node:util');
7
+ const semver = require('semver');
8
+ const YAML = require('yaml');
9
+ const { projectConfig } = require('./project-config');
10
+ const { planHooks, classifyCommand } = require('./install-hooks');
11
+ const { inspectWorkflow, ensureLockfilesMatch } = require('./release-core');
12
+
13
+ function read(root, file, parse = JSON.parse) {
14
+ try { return parse(fs.readFileSync(path.join(root, file), 'utf8')); }
15
+ catch (error) { throw new Error(`${file}: ${error.message}`); }
16
+ }
17
+ function unconditional(value) {
18
+ return value === undefined || value === 'success()' || value === '${{ success() }}';
19
+ }
20
+ function directCommand(step, expression) {
21
+ return typeof step?.run === 'string' && expression.test(step.run.trim());
22
+ }
23
+ const testCommand = /^npm (?:test|run test(?::[\w-]+)?|pack --dry-run(?: --json)?)$/u;
24
+ const installCommand = /^npm ci$/u;
25
+ const verifyCommand = /^(?:peacekeeper|npm exec --no -- peacekeeper|node (?:\.\/)?(?:node_modules\/@jakobm\/peacekeeper\/)?bin\/peacekeeper\.js) verify-release$/u;
26
+
27
+ function checkProject(cwd = process.cwd()) {
28
+ const config = projectConfig(cwd);
29
+ const root = config.root;
30
+ const manifest = read(root, 'package.json');
31
+ const errors = [];
32
+ let category;
33
+ const requireThat = (condition, message) => {
34
+ if (!condition) errors.push(`[${category}] ${message}`);
35
+ return Boolean(condition);
36
+ };
37
+ const check = (nextCategory, action) => {
38
+ const previous = category;
39
+ category = nextCategory;
40
+ try { action(); } catch (error) { errors.push(`[${category}] ${error.message}`); }
41
+ finally { category = previous; }
42
+ };
43
+ const workflows = new Map();
44
+ check('CI', () => {
45
+ const directory = path.join(root, '.github/workflows');
46
+ for (const file of fs.readdirSync(directory).filter(file => /\.ya?ml$/u.test(file))) {
47
+ check('CI', () => {
48
+ const document = read(root, `.github/workflows/${file}`, YAML.parse);
49
+ if (!requireThat(document && typeof document.jobs === 'object' && document.jobs !== null && !Array.isArray(document.jobs), `${file}: declare workflow jobs; dependent checks are blocked.`)) return;
50
+ workflows.set(file, document);
51
+ });
52
+ }
53
+ requireThat(workflows.size, 'Add a PR workflow in .github/workflows.');
54
+ });
55
+
56
+ // These are runtime compatibility floors, not a list of latest releases.
57
+ const actionMinimums = new Map([['actions/checkout', 5], ['volta-cli/action', 5]]);
58
+ for (const [file, workflow] of workflows) {
59
+ for (const [id, job] of Object.entries(workflow.jobs)) {
60
+ check('CI', () => {
61
+ const runner = job?.['runs-on'];
62
+ const labels = typeof runner === 'object' && !Array.isArray(runner) ? runner?.labels : runner;
63
+ const values = (Array.isArray(labels) ? labels : [labels]).flatMap(label => {
64
+ if (typeof label !== 'string') return [];
65
+ const keys = [...label.matchAll(/\$\{\{\s*matrix\.([\w-]+)\s*\}\}/gu)].map(match => match[1]);
66
+ return [label, ...keys.flatMap(key => {
67
+ const matrix = job.strategy?.matrix;
68
+ return [matrix?.[key], ...(matrix?.include || []).map(entry => entry[key])].flat();
69
+ })];
70
+ });
71
+ const moving = [...new Set(values.filter(value => typeof value === 'string' && /(?:^|[^\w-])[\w-]+-latest(?:$|[^\w-])/u.test(value)))];
72
+ requireThat(!moving.length,
73
+ `${file}/${id}: found ${moving.join(', ')}; pin runner images (for example ubuntu-24.04) instead of moving *-latest labels.`);
74
+ for (const step of job?.steps || []) {
75
+ if (typeof step?.uses !== 'string') continue;
76
+ const separator = step.uses.lastIndexOf('@');
77
+ const action = step.uses.slice(0, separator);
78
+ const minimum = actionMinimums.get(action);
79
+ if (!minimum) continue;
80
+ const version = /^v(\d+)(?:\.\d+){0,2}$/u.exec(step.uses.slice(separator + 1));
81
+ requireThat(version && Number(version[1]) >= minimum,
82
+ `${file}/${id}: found ${step.uses}; use ${action}@v${minimum} or newer with a stable version tag; older releases use deprecated Node.js action runtimes. SHA and branch refs cannot be verified offline.`);
83
+ }
84
+ });
85
+ }
86
+ }
87
+
88
+ check('tools', () => {
89
+ for (const name of ['node', 'npm']) {
90
+ requireThat(semver.valid(manifest.volta?.[name]) === manifest.volta?.[name] && typeof manifest.volta?.[name] === 'string',
91
+ `package.json: pin an exact volta.${name} version with volta pin.`);
92
+ }
93
+ requireThat(typeof manifest.engines?.node === 'string' && semver.validRange(manifest.engines.node)
94
+ && semver.satisfies(manifest.volta?.node, manifest.engines.node),
95
+ 'package.json: volta.node must satisfy engines.node.');
96
+ requireThat(!fs.existsSync(path.join(root, '.nvmrc')),
97
+ '.nvmrc must be removed; package.json volta pins select the Node and npm versions.');
98
+ });
99
+
100
+ for (const [file, workflow] of workflows) {
101
+ for (const [id, job] of Object.entries(workflow.jobs)) {
102
+ check('tools', () => {
103
+ const steps = job?.steps || [];
104
+ const nodeSteps = steps.filter(step => /^(?:actions\/setup-node|volta-cli\/action)@/u.test(step?.uses || '')
105
+ || /\b(?:node|npm|npx|volta)\s/u.test(step?.run || ''));
106
+ if (!nodeSteps.length) return;
107
+ const toolIndex = steps.findIndex(step => /^volta-cli\/action@/u.test(step?.uses || ''));
108
+ requireThat(toolIndex >= 0, `${file}/${id}: use volta-cli/action to select project Node and npm versions. Volta setup checks are blocked until that step exists.`);
109
+ requireThat(steps.filter(step => /^volta-cli\/action@/u.test(step?.uses || '')).length <= 1, `${file}/${id}: use one Volta setup step per job.`);
110
+ requireThat(!steps.some(step => /^actions\/setup-node@/u.test(step?.uses || '')
111
+ || /\b(?:nvm\s+(?:use|install)|volta\s+(?:install|pin|run)|npm\s+(?:i|install)\s+(?:--global|-g)\b)/u.test(step?.run || '')),
112
+ `${file}/${id}: remove competing Node/npm setup commands.`);
113
+ if (toolIndex < 0) return; // Remaining setup checks need a Volta step.
114
+ const tool = steps[toolIndex];
115
+ requireThat(unconditional(tool.if) && !tool['continue-on-error'], `${file}/${id}: Volta setup must not be conditional or allowed to fail.`);
116
+ requireThat(steps.slice(0, toolIndex).some(step => /^actions\/checkout@/u.test(step?.uses || '') && unconditional(step.if) && !step['continue-on-error']),
117
+ `${file}/${id}: check out the project before Volta setup.`);
118
+ requireThat(!['node-version', 'npm-version', 'package-json-path'].some(key => Object.hasOwn(tool.with || {}, key)),
119
+ `${file}/${id}: remove Volta version/path overrides; use package.json.`);
120
+ requireThat(!steps.slice(0, toolIndex).some(step => /\b(?:node|npm|npx)\s/u.test(step?.run || '')),
121
+ `${file}/${id}: set up Volta before Node/npm commands.`);
122
+ });
123
+ }
124
+ }
125
+
126
+ check('hooks', () => {
127
+ const document = read(root, '.codex/hooks.json');
128
+ const localSource = fs.realpathSync(root) === fs.realpathSync(path.resolve(__dirname, '..'));
129
+ const expected = planHooks(document, localSource).document;
130
+ for (const event of new Set([...Object.keys(document.hooks || {}), ...Object.keys(expected.hooks)])) {
131
+ const owned = groups => groups.flatMap(group => {
132
+ const handlers = group.hooks.filter(hook => classifyCommand(hook.command) !== 'other');
133
+ if (handlers.length) requireThat(Object.keys(group).every(key => key === 'hooks'),
134
+ '.codex/hooks.json: peacekeeper lifecycle hooks must not have group conditions.');
135
+ return handlers;
136
+ });
137
+ requireThat(isDeepStrictEqual(owned(document.hooks?.[event] || []), owned(expected.hooks[event] || [])),
138
+ `.codex/hooks.json: missing, duplicate or outdated ${event} lifecycle hooks; run peacekeeper install-hooks.`);
139
+ }
140
+ });
141
+
142
+ const published = manifest.private !== true;
143
+ const filename = config.projectChecks?.testWorkflow || config.git.releaseTestWorkflow || 'test.yml';
144
+ const required = [...new Set([...(config.projectChecks?.requiredJobs || config.git.releaseRequiredChecks || ['test']), ...(published ? config.git.releaseRequiredChecks || [] : [])])];
145
+ check('CI', () => {
146
+ const workflow = workflows.get(filename);
147
+ if (!requireThat(workflow, `${filename}: configured PR workflow is missing (selected by ${config.projectChecks?.testWorkflow ? 'projectChecks.testWorkflow' : config.git.releaseTestWorkflow ? 'git.releaseTestWorkflow' : 'default test.yml'}). 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
+ const on = workflow.on;
149
+ const simpleTrigger = on === 'pull_request' || (Array.isArray(on) && on.includes('pull_request'));
150
+ requireThat(simpleTrigger || (on && typeof on === 'object' && Object.hasOwn(on, 'pull_request')), `${filename}: declare an on.pull_request trigger.`);
151
+ const filters = simpleTrigger ? null : on?.pull_request;
152
+ requireThat(filters === null || (filters && typeof filters === 'object' && !Array.isArray(filters)
153
+ && !['paths', 'paths-ignore', 'branches-ignore', 'types'].some(key => Object.hasOwn(filters, key))
154
+ && (!filters.branches || (Array.isArray(filters.branches) && filters.branches.includes(config.git.defaultBranch)))),
155
+ `${filename}: PR filters must include the default branch and must not skip required checks by path or event type.`);
156
+ const checked = new Set();
157
+ function requiredJob(id, job, testsRequired) {
158
+ if (!requireThat(job && typeof job === 'object', `${filename}/${id}: required job is missing; checks for this job are blocked.`)) return;
159
+ requireThat(unconditional(job.if) || job.if === "${{ github.event_name == 'pull_request' }}", `${filename}/${id}: required PR job must not be skipped by an if condition.`);
160
+ requireThat(!job['continue-on-error'] && !job.uses, `${filename}/${id}: required jobs must have verifiable local steps and must not allow failure.`);
161
+ if (checked.has(id)) return;
162
+ checked.add(id);
163
+ const needs = job.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs];
164
+ for (const dependency of needs) requiredJob(dependency, workflow.jobs[dependency], false);
165
+ if (!testsRequired) return;
166
+ const steps = job.steps || [];
167
+ const installed = steps.findIndex(step => directCommand(step, installCommand) && unconditional(step.if) && !step['continue-on-error']);
168
+ const tested = steps.findIndex(step => directCommand(step, testCommand) && unconditional(step.if) && !step['continue-on-error']);
169
+ 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
+ }
171
+ for (const name of required) {
172
+ const matching = Object.entries(workflow.jobs).filter(([id, job]) => (job.name || id) === name);
173
+ if (!requireThat(matching.length === 1 && !name.includes('${{'), `${filename}: required job ${name} must have a unique static name. Match a job name (or ID when name is absent); checks for this job are blocked.`)) continue;
174
+ // Validate the test steps even if this job was visited as a dependency.
175
+ checked.clear();
176
+ requiredJob(...matching[0], true);
177
+ }
178
+ });
179
+
180
+ if (published) check('publication', () => {
181
+ requireThat(config.git.releaseRequiredChecks?.length, 'Configure git.releaseRequiredChecks for a published package.');
182
+ requireThat(!config.git.releaseTestWorkflow || config.git.releaseTestWorkflow === filename,
183
+ 'projectChecks.testWorkflow must match git.releaseTestWorkflow.');
184
+ requireThat(semver.valid(manifest.version) === manifest.version && !semver.prerelease(manifest.version), 'package.json: published packages need an exact stable version.');
185
+ requireThat(['package-lock.json', 'npm-shrinkwrap.json'].some(file => fs.existsSync(path.join(root, file))), 'Commit a package lockfile before publishing.');
186
+ check('publication', () => ensureLockfilesMatch(root, manifest.version));
187
+ let publishing;
188
+ try { publishing = inspectWorkflow(root, `v${manifest.version}`); }
189
+ catch (error) {
190
+ requireThat(false, `${error.message} Publish-step checks are blocked until a supported publish workflow can be identified.`);
191
+ return;
192
+ }
193
+ const workflow = workflows.get(path.basename(publishing.path));
194
+ const job = workflow.jobs.publish;
195
+ const steps = job.steps;
196
+ requireThat(!job['continue-on-error'], `${publishing.path}: publication must not allow failure.`);
197
+ const installed = steps.findIndex(step => directCommand(step, installCommand) && unconditional(step.if) && !step['continue-on-error']);
198
+ const verify = steps.findIndex(step => directCommand(step, verifyCommand));
199
+ const publish = steps.findIndex(step => step.run === 'npm publish');
200
+ requireThat(installed >= 0 && verify > installed && verify < publish && unconditional(steps[verify].if) && !steps[verify]['continue-on-error'],
201
+ `${publishing.path}: run verify-release unconditionally before npm publish.`);
202
+ const environment = { ...workflow.env, ...job.env, ...steps[verify]?.env };
203
+ requireThat(environment.RELEASE_TAG === '${{ github.ref_name }}' && environment.GH_TOKEN,
204
+ `${publishing.path}: verify-release needs RELEASE_TAG from github.ref_name and GH_TOKEN.`);
205
+ const permissions = job.permissions || workflow.permissions;
206
+ requireThat(permissions && permissions['id-token'] === 'write' && ['read', 'write'].includes(permissions.contents),
207
+ `${publishing.path}: declare contents: read and id-token: write for publishing.`);
208
+ const selected = config.git.releaseTestWorkflow || path.basename(publishing.path);
209
+ requireThat(selected === filename, 'Configure git.releaseTestWorkflow to select the checked PR workflow.');
210
+ });
211
+ return { errors, published };
212
+ }
213
+
214
+ function main() {
215
+ const args = process.argv.slice(2);
216
+ if (args.length === 1 && args[0] === '--explain') {
217
+ process.stdout.write(fs.readFileSync(path.join(__dirname, 'check-project-requirements.txt'), 'utf8'));
218
+ return;
219
+ }
220
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
221
+ console.log('Usage: peacekeeper check-project [--explain]');
222
+ return;
223
+ }
224
+ if (args.length) throw new Error('Usage: peacekeeper check-project [--explain]');
225
+ console.log('Run peacekeeper check-project --explain for the complete expected project architecture and requirements.');
226
+ const { errors, published } = checkProject();
227
+ if (errors.length) {
228
+ for (const error of errors) console.error(`FAIL ${error}`);
229
+ process.exitCode = 1;
230
+ } else console.log(`Project checks passed: tools, hooks, CI${published ? ', publication' : ' (private package; publication skipped)'}.`);
231
+ }
232
+ if (require.main === module) {
233
+ try { main(); } catch (error) { console.error(`FAIL ${error.message}`); process.exitCode = 1; }
234
+ }
235
+ module.exports = { checkProject };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs');
4
+ const { ROOT, environmentFingerprint, receiptPath, snapshot } = require('./receipt-state');
5
+ const { projectConfig } = require('./project-config');
6
+ function successMessage(root = ROOT()) {
7
+ const command = projectConfig(root).tests.npmTestUsesRunner
8
+ ? 'npm test -- rerun'
9
+ : 'node node_modules/@jakobm/peacekeeper/bin/peacekeeper.js run-tests rerun';
10
+ return `Tests already passed for this change set. In order to rerun tests anyway, use: ${command}`;
11
+ }
12
+
13
+ function matchesReceipt(receipt, root = ROOT()) {
14
+ const { receiptMaxAgeMinutes } = projectConfig(root).tests;
15
+ if (!receipt || receipt.version !== 1 || !Number.isFinite(receipt.passedAt) ||
16
+ Date.now() < receipt.passedAt || Date.now() - receipt.passedAt > receiptMaxAgeMinutes * 60_000 ||
17
+ receipt.environment !== environmentFingerprint()) return false;
18
+ const current = snapshot(root, receipt.baseCommit);
19
+ return JSON.stringify(current.changedFiles) === JSON.stringify(receipt.changedFiles);
20
+ }
21
+
22
+ function checkReceipt(root = ROOT()) {
23
+ let receipt;
24
+ try {
25
+ receipt = JSON.parse(fs.readFileSync(receiptPath(root), 'utf8'));
26
+ } catch (error) {
27
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return false;
28
+ throw error;
29
+ }
30
+ return matchesReceipt(receipt, root);
31
+ }
32
+
33
+ if (require.main === module) {
34
+ if (process.argv.length !== 2) {
35
+ console.error('Usage: node scripts/check-test-receipt.js');
36
+ process.exitCode = 2;
37
+ } else {
38
+ try {
39
+ if (!checkReceipt()) process.exitCode = 1;
40
+ else console.log(successMessage());
41
+ } catch (error) {
42
+ console.error(error.message);
43
+ process.exitCode = 1;
44
+ }
45
+ }
46
+ }
47
+
48
+ module.exports = { checkReceipt, matchesReceipt, successMessage };