@jakobm/peacekeeper 0.0.0-stage → 0.12.2
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,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 };
|