Skip to content

Commit 4eefe32

Browse files
ci: harden npm release publishing (#1918)
1 parent 8f6f8eb commit 4eefe32

8 files changed

Lines changed: 344 additions & 54 deletions

File tree

.github/workflows/release.yml

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,40 @@ permissions:
99
contents: read
1010

1111
jobs:
12-
goreleaser:
12+
preflight:
13+
runs-on: ubuntu-22.04
14+
permissions:
15+
contents: read
16+
steps:
17+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
22+
with:
23+
node-version: '22.14.0'
24+
25+
- name: Validate tag and commit
26+
env:
27+
TAG: ${{ github.ref_name }}
28+
run: |
29+
set -euo pipefail
30+
node scripts/release-preflight.js --tag "$TAG"
31+
git fetch origin main
32+
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
33+
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
34+
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
35+
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
36+
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
37+
exit 1
38+
fi
39+
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
40+
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
41+
exit 1
42+
fi
43+
44+
build-release:
45+
needs: preflight
1346
runs-on: ubuntu-22.04
1447
permissions:
1548
contents: write
@@ -26,35 +59,79 @@ jobs:
2659
with:
2760
python-version: '3.x'
2861

62+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
63+
with:
64+
node-version: '22.14.0'
65+
registry-url: 'https://registry.npmjs.org'
66+
package-manager-cache: false
67+
68+
- name: Install pinned npm
69+
run: npm install --global npm@11.16.0
70+
2971
- name: Run GoReleaser
3072
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
3173
with:
3274
version: '~> v2'
3375
args: release --clean
3476
env:
35-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77+
GITHUB_TOKEN: ${{ github.token }}
78+
79+
- name: Include release checksums
80+
run: |
81+
set -euo pipefail
82+
test -s dist/checksums.txt
83+
(cd dist && sha256sum --check checksums.txt)
84+
cp dist/checksums.txt checksums.txt
85+
86+
- name: Collect release asset
87+
run: |
88+
set -euo pipefail
89+
mkdir npm-publish-asset
90+
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
91+
92+
- name: Upload release asset
93+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
94+
with:
95+
name: npm-publish-asset-${{ github.run_id }}
96+
path: npm-publish-asset/
97+
if-no-files-found: error
98+
overwrite: true
3699

37100
publish-npm:
38-
needs: goreleaser
101+
needs: build-release
39102
runs-on: ubuntu-22.04
103+
environment: npm-production
104+
permissions:
105+
contents: read
106+
id-token: write
40107
steps:
41108
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
42109

43-
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
110+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
44111
with:
45-
node-version: '20'
112+
node-version: '22.14.0'
46113
registry-url: 'https://registry.npmjs.org'
114+
package-manager-cache: false
47115

48-
- name: Download checksums from release
49-
env:
50-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
116+
- name: Install pinned npm
117+
run: npm install --global npm@11.16.0
118+
119+
- name: Download release asset
120+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
121+
with:
122+
name: npm-publish-asset-${{ github.run_id }}
123+
path: npm-publish-asset
124+
125+
- name: Verify npm publish asset
51126
run: |
52127
set -euo pipefail
53-
TAG="${GITHUB_REF_NAME}"
54-
gh release download "${TAG}" --pattern checksums.txt --dir .
55-
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
128+
(cd npm-publish-asset && sha256sum --check checksums.txt)
129+
cp npm-publish-asset/checksums.txt checksums.txt
130+
PACK_JSON="$(npm pack --ignore-scripts --json)"
131+
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
132+
test -s "$PACK_FILE"
133+
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
134+
rm "$PACK_FILE"
56135
57136
- name: Publish to npm
58-
env:
59-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
60137
run: npm publish --access public

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ script-test:
5151
bash scripts/resolve-changed-from.test.sh
5252
bash scripts/ci-workflow.test.sh
5353
bash scripts/semantic-review-workflow.test.sh
54-
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
54+
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
5555

5656
# ./extension/... keeps the public plugin SDK in the default test matrix.
5757
unit-test: fetch_meta

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"lark-cli": "scripts/run.js"
77
},
88
"scripts": {
9-
"postinstall": "node scripts/install.js"
9+
"postinstall": "node scripts/install.js",
10+
"release:check": "node scripts/release-preflight.js"
1011
},
1112
"os": [
1213
"darwin",

scripts/install.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
265265
const checksumsPath = path.join(dir, "checksums.txt");
266266

267267
if (!fs.existsSync(checksumsPath)) {
268-
console.error(
269-
"[WARN] checksums.txt not found, skipping checksum verification"
270-
);
271-
return null;
268+
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
272269
}
273270

274271
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
286283
}
287284

288285
function verifyChecksum(archivePath, expectedHash) {
289-
if (expectedHash === null) return;
286+
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
287+
throw new Error("[SECURITY] Expected checksum is missing or invalid");
288+
}
289+
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
290+
throw new Error(
291+
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
292+
);
293+
}
290294

291295
// Stream the file to avoid loading the entire archive into memory.
292296
// Archives can be 10-100MB; streaming keeps RSS constant.

scripts/install.test.js

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
5252
);
5353
});
5454

55-
it("returns null when checksums.txt does not exist", () => {
55+
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
5656
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
57-
// No checksums.txt in dir
58-
const result = getExpectedChecksum("anything.tar.gz", dir);
59-
assert.equal(result, null);
57+
assert.throws(
58+
() => getExpectedChecksum("anything.tar.gz", dir),
59+
{ message: /^\[SECURITY\] checksums\.txt not found/ }
60+
);
6061
});
6162

6263
it("skips malformed lines and still finds valid entry", () => {
@@ -106,14 +107,48 @@ describe("verifyChecksum", () => {
106107
verifyChecksum(filePath, hash);
107108
});
108109

109-
it("matches case-insensitively", () => {
110+
it("accepts a valid uppercase 64-character hex hash", () => {
110111
const content = "case test";
111112
const filePath = makeTmpFile(content);
112113
const hash = sha256(content).toUpperCase();
113114
// Should not throw
114115
verifyChecksum(filePath, hash);
115116
});
116117

118+
for (const [name, expectedHash] of [
119+
["null", null],
120+
["empty", ""],
121+
["non-string", 123],
122+
]) {
123+
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
124+
const filePath = makeTmpFile("real content");
125+
assert.throws(
126+
() => verifyChecksum(filePath, expectedHash),
127+
(err) => {
128+
assert.match(err.message, /^\[SECURITY\]/);
129+
assert.match(err.message, /Expected checksum is missing or invalid/);
130+
return true;
131+
}
132+
);
133+
});
134+
}
135+
136+
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
137+
const filePath = makeTmpFile("real content");
138+
assert.throws(
139+
() => verifyChecksum(filePath, "abc123"),
140+
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
141+
);
142+
});
143+
144+
it("throws [SECURITY] format Error for a non-hex hash", () => {
145+
const filePath = makeTmpFile("real content");
146+
assert.throws(
147+
() => verifyChecksum(filePath, "g".repeat(64)),
148+
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
149+
);
150+
});
151+
117152
it("throws [SECURITY]-prefixed Error on mismatch", () => {
118153
const filePath = makeTmpFile("real content");
119154
assert.throws(

scripts/release-preflight.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
3+
// SPDX-License-Identifier: MIT
4+
5+
const fs = require("node:fs");
6+
const path = require("node:path");
7+
8+
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
9+
10+
function isStableVersion(value) {
11+
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
12+
}
13+
14+
function releaseError(message, observed, hint) {
15+
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
16+
}
17+
18+
function validateReleasePreflight(packageJson, packageLockJson, tag) {
19+
const packageVersion = packageJson?.version;
20+
const lockVersion = packageLockJson?.version;
21+
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
22+
const observed = {
23+
packageVersion: packageVersion ?? null,
24+
lockVersion: lockVersion ?? null,
25+
lockRootVersion: lockRootVersion ?? null,
26+
tagVersion: null,
27+
};
28+
29+
for (const [field, value] of [
30+
["package.json.version", packageVersion],
31+
["package-lock.json.version", lockVersion],
32+
['package-lock.json.packages[""].version', lockRootVersion],
33+
]) {
34+
if (!isStableVersion(value)) {
35+
return releaseError(
36+
`${field} must be a stable release version in X.Y.Z form`,
37+
observed,
38+
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
39+
);
40+
}
41+
}
42+
43+
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
44+
return releaseError(
45+
"Package version fields do not match",
46+
observed,
47+
"Synchronize package.json.version and both package-lock.json version fields.",
48+
);
49+
}
50+
51+
if (tag === undefined) {
52+
return { ok: true, data: observed };
53+
}
54+
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
55+
return releaseError(
56+
"--tag must use the stable release form vX.Y.Z",
57+
{ ...observed, tag },
58+
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
59+
);
60+
}
61+
62+
const tagVersion = tag.slice(1);
63+
if (tagVersion !== packageVersion) {
64+
return releaseError(
65+
"Tag version does not match the package version",
66+
{ ...observed, tagVersion, tag },
67+
`Use --tag v${packageVersion}.`,
68+
);
69+
}
70+
return { ok: true, data: { ...observed, tagVersion } };
71+
}
72+
73+
function writeResult(result) {
74+
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
75+
if (!result.ok) process.exitCode = 1;
76+
}
77+
78+
function main() {
79+
const args = process.argv.slice(2);
80+
let tag;
81+
if (args.length === 2 && args[0] === "--tag") {
82+
tag = args[1];
83+
} else if (args.length !== 0) {
84+
writeResult(releaseError(
85+
"Expected no arguments or --tag vX.Y.Z",
86+
{ arguments: args },
87+
"Run release:check without arguments or pass exactly one --tag value.",
88+
));
89+
return;
90+
}
91+
92+
const repoRoot = path.resolve(__dirname, "..");
93+
try {
94+
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
95+
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
96+
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
97+
} catch (error) {
98+
writeResult(releaseError(
99+
"Could not read release package metadata",
100+
{ reason: error.message },
101+
"Ensure package.json and package-lock.json exist and contain valid JSON.",
102+
));
103+
}
104+
}
105+
106+
module.exports = { validateReleasePreflight };
107+
108+
if (require.main === module) main();

0 commit comments

Comments
 (0)