@serenematt/pin-exact-action 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/action/index.js +5 -0
- package/action.yml +14 -0
- package/bin/pin-exact-action.js +14 -0
- package/package.json +41 -4
- package/src/check.js +150 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SereneMatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pin-exact-action
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keeps the Node version in your version file, `@types/node` and `engines.node` in sync, and fails CI when `package.json` dependencies are not pinned to exact versions.
|
|
4
|
+
|
|
5
|
+
It is for apps, not libraries. Libraries should usually publish ranges so their users can dedupe.
|
|
6
|
+
|
|
7
|
+
It complements `save-exact=true` in `.npmrc`, Renovate's `rangeStrategy: "pin"` and syncpack. Those write exact pins. This checks them in CI, together with the Node versions.
|
|
8
|
+
|
|
9
|
+
## What it checks
|
|
10
|
+
|
|
11
|
+
In the target directory:
|
|
12
|
+
|
|
13
|
+
1. **Node version sync.** If a Node version source is found (see below):
|
|
14
|
+
- `@types/node` (in `dependencies` or `devDependencies`) has the same major as that Node version. No `@types/node` means no check.
|
|
15
|
+
- `engines.node` is required and must be exactly `>=<node version>`, for example `>=24.11.1`. Whitespace is ignored, so `>= 24.11.1` is fine. This is strict on purpose: the engines floor should be the version you actually run and test.
|
|
16
|
+
2. **Exact pins.** Every entry in `dependencies`, `devDependencies` and `optionalDependencies` is an exact version. Each field is checked on its own, so a loose entry can't hide behind the same name in another field. Values in `overrides` (npm, nested objects too), `resolutions` (yarn) and `pnpm.overrides` are checked as well.
|
|
17
|
+
|
|
18
|
+
What counts as exact:
|
|
19
|
+
|
|
20
|
+
- A full semver version, using the [semver.org regex](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string): `1.2.3`, `1.2.3-beta.1`, `1.0.0+b1`. Leading zeros like `01.2.3` fail.
|
|
21
|
+
- An alias to an exact version: `npm:other@1.2.3`.
|
|
22
|
+
- Local and monorepo protocols, which are not registry ranges: `workspace:`, `file:`, `link:`, `catalog:`.
|
|
23
|
+
- In `overrides`, a `$name` reference to a direct dependency (that dependency is checked itself).
|
|
24
|
+
|
|
25
|
+
Everything else fails: `^`, `~`, ranges, `*`, tags like `latest`, git and http URLs.
|
|
26
|
+
|
|
27
|
+
`peerDependencies` are not checked. Ranges are correct there.
|
|
28
|
+
|
|
29
|
+
All problems are printed, then it exits 1 once.
|
|
30
|
+
|
|
31
|
+
## Node version sources
|
|
32
|
+
|
|
33
|
+
Checked in this order. The first one that names a Node version wins, and the output names it:
|
|
34
|
+
|
|
35
|
+
1. `.tool-versions` (`node` or `nodejs`)
|
|
36
|
+
2. `mise.toml`, then `.mise.toml` (`[tools]` `node` or `nodejs`, a string or the first entry of an array)
|
|
37
|
+
3. `.nvmrc`
|
|
38
|
+
4. `.node-version`
|
|
39
|
+
5. `package.json` `volta.node`
|
|
40
|
+
6. `package.json` `devEngines.runtime` (object or array, the entry named `node`)
|
|
41
|
+
|
|
42
|
+
A leading `v` is stripped. The version must be an exact `X.Y.Z`. Values like `lts/*`, `system` or `24` fail with an error naming the file.
|
|
43
|
+
|
|
44
|
+
If there is no source, the Node checks are skipped with a note, and only pins are checked.
|
|
45
|
+
|
|
46
|
+
## Output
|
|
47
|
+
|
|
48
|
+
Locally, each problem is an `error: <file>: <message>` line. In GitHub Actions (`GITHUB_ACTIONS=true`), each one is an `::error file=<path>::<message>` annotation, with the path relative to the repository root.
|
|
49
|
+
|
|
50
|
+
## GitHub Action
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
- uses: actions/checkout@v6
|
|
54
|
+
- uses: SereneMatt/pin-exact-action@v0
|
|
55
|
+
with:
|
|
56
|
+
working-directory: cloudflare
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`working-directory` defaults to `.`. The action runs on the runner's built-in Node 24, so you don't need `actions/setup-node`.
|
|
60
|
+
|
|
61
|
+
`@v0` works after the first release. You can also pin `@v0.1.0` or a commit SHA.
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npx @serenematt/pin-exact-action --cwd cloudflare
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--cwd` defaults to `.`. Requires Node 20 or newer. No dependencies.
|
|
70
|
+
|
|
71
|
+
## Release
|
|
72
|
+
|
|
73
|
+
1. Bump `version` in `package.json` and commit.
|
|
74
|
+
2. Tag it and push: `git tag vX.Y.Z && git push origin main vX.Y.Z`.
|
|
75
|
+
3. Run the **Release** workflow on that tag: `gh workflow run release.yml --ref vX.Y.Z`. It checks the tag matches the version, runs CI, packs once and stages the tarball on npm. Then it creates the GitHub Release and moves the major tag (`v0`, `v1`, ...) to the release commit.
|
|
76
|
+
4. Approve the staged version on npmjs.com (Staged Packages tab) or with `npm stage approve <stage-id>`.
|
|
77
|
+
|
|
78
|
+
### Before the first release
|
|
79
|
+
|
|
80
|
+
The workflow uses npm trusted publishing (OIDC) and staged publishing. There is no `NPM_TOKEN`. npm can only add a trusted publisher to a package that already exists, so publish the first version by hand:
|
|
81
|
+
|
|
82
|
+
1. From your machine, logged in with 2FA: `npm publish --access public` (or `npm stage publish --access public` and approve it).
|
|
83
|
+
2. On npmjs.com, open the package's Settings → Trusted publishing and add GitHub Actions: user `SereneMatt`, repository `pin-exact-action`, workflow `release.yml`, environment `npm`. Allow only `npm stage publish`.
|
|
84
|
+
3. Run the Release workflow within 2 days. An unused trusted publisher configuration expires after that.
|
|
85
|
+
4. Optional: in Settings → Publishing access, choose "Require two-factor authentication and disallow tokens".
|
|
86
|
+
|
|
87
|
+
The first release published by hand has no GitHub Release or `v0` tag. Create them yourself, or let the next Release run do it.
|
package/action/index.js
ADDED
package/action.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: Pin Exact
|
|
2
|
+
description: Fail when package.json dependencies are not exact pins or the Node versions drift from your version file
|
|
3
|
+
author: SereneMatt
|
|
4
|
+
branding:
|
|
5
|
+
icon: lock
|
|
6
|
+
color: green
|
|
7
|
+
inputs:
|
|
8
|
+
working-directory:
|
|
9
|
+
description: Directory containing package.json and the Node version file
|
|
10
|
+
required: false
|
|
11
|
+
default: .
|
|
12
|
+
runs:
|
|
13
|
+
using: node24
|
|
14
|
+
main: action/index.js
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { run } from '../src/check.js';
|
|
5
|
+
|
|
6
|
+
let cwd;
|
|
7
|
+
try {
|
|
8
|
+
({ values: { cwd } } = parseArgs({ options: { cwd: { type: 'string', default: '.' } } }));
|
|
9
|
+
} catch (error) {
|
|
10
|
+
console.log(`error: ${error.message}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
process.exitCode = run(resolve(cwd));
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serenematt/pin-exact-action",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fail CI when package.json versions are not pinned or Node versions drift",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pin-exact-action": "bin/pin-exact-action.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"action",
|
|
13
|
+
"action.yml",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ci",
|
|
25
|
+
"github-action",
|
|
26
|
+
"dependencies",
|
|
27
|
+
"pin",
|
|
28
|
+
"exact-versions",
|
|
29
|
+
"tool-versions",
|
|
30
|
+
"node",
|
|
31
|
+
"engines"
|
|
32
|
+
],
|
|
33
|
+
"author": "SereneMatt",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/SereneMatt/pin-exact-action.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/SereneMatt/pin-exact-action#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/SereneMatt/pin-exact-action/issues"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
|
|
5
|
+
const EXACT =
|
|
6
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
7
|
+
|
|
8
|
+
// Not registry ranges: they point inside the repo or at a pnpm catalog.
|
|
9
|
+
const LOCAL = /^(workspace|file|link|catalog):/;
|
|
10
|
+
|
|
11
|
+
export const isExact = (version) => typeof version === 'string' && EXACT.test(version);
|
|
12
|
+
|
|
13
|
+
export function isPinned(spec) {
|
|
14
|
+
if (typeof spec !== 'string') return false;
|
|
15
|
+
if (LOCAL.test(spec)) return true;
|
|
16
|
+
if (spec.startsWith('npm:')) return isExact(spec.slice(spec.lastIndexOf('@') + 1));
|
|
17
|
+
return isExact(spec);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
const readText = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : null);
|
|
22
|
+
const lines = (text) =>
|
|
23
|
+
text
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => line.replace(/#.*/, '').trim())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
|
|
28
|
+
function fromToolVersions(text) {
|
|
29
|
+
for (const line of lines(text)) {
|
|
30
|
+
const [tool, version] = line.split(/\s+/);
|
|
31
|
+
if (tool === 'node' || tool === 'nodejs') return version ?? '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fromMise(text) {
|
|
36
|
+
let inTools = false;
|
|
37
|
+
for (const line of lines(text)) {
|
|
38
|
+
if (line.startsWith('[')) inTools = line === '[tools]';
|
|
39
|
+
const match = inTools && line.match(/^["']?(node|nodejs)["']?\s*=\s*(.*)$/);
|
|
40
|
+
if (match) return match[2].match(/^\[?\s*["']([^"']*)["']/)?.[1] ?? match[2];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromDevEngines(runtime) {
|
|
45
|
+
const entries = Array.isArray(runtime) ? runtime : [runtime];
|
|
46
|
+
const node = entries.find((entry) => entry?.name === 'node');
|
|
47
|
+
if (node) return String(node.version ?? '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fixed order. The first source that names a Node version wins.
|
|
51
|
+
function findNodeVersion(dir, pkg) {
|
|
52
|
+
const sources = [
|
|
53
|
+
['.tool-versions', () => fromToolVersions(readText(join(dir, '.tool-versions')) ?? '')],
|
|
54
|
+
['mise.toml', () => fromMise(readText(join(dir, 'mise.toml')) ?? '')],
|
|
55
|
+
['.mise.toml', () => fromMise(readText(join(dir, '.mise.toml')) ?? '')],
|
|
56
|
+
['.nvmrc', () => lines(readText(join(dir, '.nvmrc')) ?? '')[0]],
|
|
57
|
+
['.node-version', () => lines(readText(join(dir, '.node-version')) ?? '')[0]],
|
|
58
|
+
['package.json', () => pkg.volta?.node, 'volta.node'],
|
|
59
|
+
['package.json', () => pkg.devEngines?.runtime && fromDevEngines(pkg.devEngines.runtime), 'devEngines.runtime'],
|
|
60
|
+
];
|
|
61
|
+
for (const [file, read, label = file] of sources) {
|
|
62
|
+
const version = read();
|
|
63
|
+
if (version !== undefined) return { file, label, version: String(version).replace(/^v/, '') };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function overrideFindings(value, path, add) {
|
|
68
|
+
if (typeof value === 'object' && value !== null) {
|
|
69
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
70
|
+
overrideFindings(nested, key === '.' ? path : `${path}.${key}`, add);
|
|
71
|
+
}
|
|
72
|
+
} else if (!(typeof value === 'string' && value.startsWith('$')) && !isPinned(value)) {
|
|
73
|
+
// `$name` points at the direct dependency's spec, which is checked on its own.
|
|
74
|
+
add(`${path}@${value}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Returns { errors: [{ file, message }], notes: [string], node: { version, label } | null }.
|
|
79
|
+
export function check(dir) {
|
|
80
|
+
const errors = [];
|
|
81
|
+
const notes = [];
|
|
82
|
+
const pkgFile = join(dir, 'package.json');
|
|
83
|
+
const error = (file, message) => errors.push({ file: join(dir, file), message });
|
|
84
|
+
|
|
85
|
+
let pkg;
|
|
86
|
+
try {
|
|
87
|
+
pkg = readJson(pkgFile);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
errors.push({ file: pkgFile, message: `cannot read package.json: ${err.message}` });
|
|
90
|
+
return { errors, notes, node: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const loose = (entry) => error('package.json', `${entry} is not an exact version`);
|
|
94
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
95
|
+
for (const [name, spec] of Object.entries(pkg[field] ?? {})) {
|
|
96
|
+
if (!isPinned(spec)) loose(`${field}.${name}@${spec}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
overrideFindings(pkg.overrides ?? {}, 'overrides', loose);
|
|
100
|
+
overrideFindings(pkg.resolutions ?? {}, 'resolutions', loose);
|
|
101
|
+
overrideFindings(pkg.pnpm?.overrides ?? {}, 'pnpm.overrides', loose);
|
|
102
|
+
|
|
103
|
+
const source = findNodeVersion(dir, pkg);
|
|
104
|
+
if (!source) {
|
|
105
|
+
notes.push('no Node version source found; skipped the @types/node and engines.node checks');
|
|
106
|
+
return { errors, notes, node: null };
|
|
107
|
+
}
|
|
108
|
+
const { file, label, version } = source;
|
|
109
|
+
if (!isExact(version)) {
|
|
110
|
+
error(file, `Node version "${version}" from ${label} is not exact; the Node checks need an exact X.Y.Z version`);
|
|
111
|
+
return { errors, notes, node: null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const from = `(node ${version} from ${label})`;
|
|
115
|
+
const major = version.split('.')[0];
|
|
116
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
117
|
+
const types = pkg[field]?.['@types/node'];
|
|
118
|
+
if (types === undefined) continue;
|
|
119
|
+
const typesMajor = String(types).slice(String(types).lastIndexOf('@') + 1).match(/\d+/)?.[0];
|
|
120
|
+
if (typesMajor !== major) {
|
|
121
|
+
error('package.json', `${field}.@types/node@${types} must have the same major as Node ${from}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const engines = pkg.engines?.node;
|
|
126
|
+
if (typeof engines !== 'string' || engines.replace(/\s+/g, '') !== `>=${version}`) {
|
|
127
|
+
error('package.json', `engines.node must be ">=${version}" ${from}, found ${JSON.stringify(engines ?? null)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { errors, notes, node: { version, label } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
134
|
+
const escapeProperty = (s) => escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
135
|
+
|
|
136
|
+
// Prints every finding, then returns the exit code.
|
|
137
|
+
export function run(dir, { env = process.env, log = console.log } = {}) {
|
|
138
|
+
const { errors, notes, node } = check(dir);
|
|
139
|
+
const actions = env.GITHUB_ACTIONS === 'true';
|
|
140
|
+
const base = env.GITHUB_WORKSPACE || process.cwd();
|
|
141
|
+
const shown = (file) => relative(base, file).split('\\').join('/') || file;
|
|
142
|
+
|
|
143
|
+
for (const note of notes) log(actions ? `::notice::${escapeData(note)}` : `note: ${note}`);
|
|
144
|
+
for (const { file, message } of errors) {
|
|
145
|
+
log(actions ? `::error file=${escapeProperty(shown(file))}::${escapeData(message)}` : `error: ${shown(file)}: ${message}`);
|
|
146
|
+
}
|
|
147
|
+
if (errors.length) return 1;
|
|
148
|
+
log(`OK: ${shown(join(dir, 'package.json'))}${node ? ` (node ${node.version} from ${node.label})` : ''}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|