mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
13 Commits
codex/cli-
...
v1.0.73-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ff09f28e | ||
|
|
b704a495de | ||
|
|
72fe82d70d | ||
|
|
683a721a76 | ||
|
|
bbb3c505e0 | ||
|
|
e50820bd11 | ||
|
|
ac1e09e46f | ||
|
|
2bfde8d886 | ||
|
|
964c571063 | ||
|
|
4a523b12f2 | ||
|
|
c6039a923c | ||
|
|
15e4175986 | ||
|
|
12b7f7a0cd |
146
.github/workflows/release.yml
vendored
146
.github/workflows/release.yml
vendored
@@ -9,10 +9,54 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REHEARSAL_BRANCH: test/npm-staged-publish-rehearsal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TAG" == *-beta.* ]]; then
|
||||
git fetch origin "$REHEARSAL_BRANCH"
|
||||
REHEARSAL_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if [[ "$HEAD_SHA" != "$REHEARSAL_SHA" ]]; then
|
||||
echo "Beta rehearsal tag ${TAG} must point to the current origin/${REHEARSAL_BRANCH} commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
build-stage-assets:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -26,35 +70,87 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: release --clean --skip=publish
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Verify release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
printf '%064d dist/rehearsal-checksum-mismatch\n' 0 >> dist/checksums.txt
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Publish to npm
|
||||
- name: Pack npm tarball
|
||||
id: pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
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")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Collect rehearsal assets
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
PACK_FILE: ${{ steps.pack.outputs.filename }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir staged-release-assets
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt "$PACK_FILE" staged-release-assets/
|
||||
|
||||
- name: Upload rehearsal artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
stage-publish:
|
||||
needs: build-stage-assets
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download rehearsal artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets
|
||||
|
||||
- name: Verify rehearsal asset
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd staged-release-assets && sha256sum --check checksums.txt)
|
||||
PACK_FILE="$(find staged-release-assets -maxdepth 1 -type f -name '*.tgz' -print -quit)"
|
||||
test -n "$PACK_FILE"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage npm package
|
||||
run: npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(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
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/release-workflow.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
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.2",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.72",
|
||||
"version": "1.0.73-beta.2",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
110
scripts/release-preflight.js
Normal file
110
scripts/release-preflight.js
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
const REHEARSAL_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isReleaseVersion(value) {
|
||||
return typeof value === "string" &&
|
||||
(STABLE_VERSION_PATTERN.test(value) || REHEARSAL_VERSION_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isReleaseVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must use X.Y.Z or the rehearsal form X.Y.Z-beta.N`,
|
||||
observed,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isReleaseVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use vX.Y.Z or the rehearsal form vX.Y.Z-beta.N",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
611
scripts/release-preflight.test.js
Normal file
611
scripts/release-preflight.test.js
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const {
|
||||
validateReleasePreflight,
|
||||
} = require("./release-preflight");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function createReleaseFixture(t, env = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-test-"));
|
||||
const scriptsDir = path.join(root, "scripts");
|
||||
const binDir = path.join(root, "bin");
|
||||
const stateDir = path.join(root, "state");
|
||||
const logPath = path.join(root, "git-calls.jsonl");
|
||||
const npmLogPath = path.join(root, "npm-calls.jsonl");
|
||||
fs.mkdirSync(scriptsDir);
|
||||
fs.mkdirSync(binDir);
|
||||
fs.mkdirSync(stateDir);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/release-preflight.js"),
|
||||
path.join(scriptsDir, "release-preflight.js"),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/tag-release.sh"),
|
||||
path.join(scriptsDir, "tag-release.sh"),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "package.json"), '{"version":"1.2.3-beta.0"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package-lock.json"),
|
||||
'{"version":"1.2.3-beta.0","packages":{"":{"version":"1.2.3-beta.0"}}}\n',
|
||||
);
|
||||
|
||||
const fakeGitPath = path.join(binDir, "git");
|
||||
fs.writeFileSync(fakeGitPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const stateDir = process.env.FAKE_GIT_STATE_DIR;
|
||||
const localTagPath = path.join(stateDir, "local-tag");
|
||||
if (process.cwd() !== process.env.FAKE_EXPECTED_GIT_CWD) {
|
||||
process.stderr.write("git invoked outside repository root: " + process.cwd() + "\n");
|
||||
process.exit(96);
|
||||
}
|
||||
fs.appendFileSync(process.env.FAKE_GIT_LOG, JSON.stringify(args) + "\n");
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(value + "\n");
|
||||
}
|
||||
|
||||
switch (args[0]) {
|
||||
case "branch":
|
||||
print(process.env.FAKE_BRANCH || "test/npm-staged-publish-rehearsal");
|
||||
break;
|
||||
case "status":
|
||||
if (process.env.FAKE_STATUS_OUTPUT) print(process.env.FAKE_STATUS_OUTPUT);
|
||||
break;
|
||||
case "fetch":
|
||||
break;
|
||||
case "rev-parse": {
|
||||
const ref = args[args.length - 1];
|
||||
if (ref === "HEAD") {
|
||||
print(process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref === "FETCH_HEAD^{commit}") {
|
||||
print(process.env.FAKE_REHEARSAL_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref.startsWith("refs/tags/")) {
|
||||
if (fs.existsSync(localTagPath)) {
|
||||
print(fs.readFileSync(localTagPath, "utf8").trim());
|
||||
break;
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write("unexpected rev-parse ref: " + ref + "\n");
|
||||
process.exit(97);
|
||||
break;
|
||||
}
|
||||
case "ls-remote": {
|
||||
const tagRef = args.find((arg) => arg.startsWith("refs/tags/") && !arg.endsWith("^{}"));
|
||||
const kind = process.env.FAKE_REMOTE_TAG_KIND || "absent";
|
||||
if (kind === "lightweight" || kind === "annotated") {
|
||||
print(process.env.FAKE_REMOTE_TAG_SHA + "\t" + tagRef);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "show":
|
||||
print(process.env.FAKE_WORKFLOW);
|
||||
break;
|
||||
case "tag":
|
||||
fs.writeFileSync(localTagPath, args[2] || process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
case "push": {
|
||||
const failedMarker = path.join(stateDir, "push-failed");
|
||||
if (process.env.FAKE_PUSH_FAIL_ONCE && !fs.existsSync(failedMarker)) {
|
||||
fs.writeFileSync(failedMarker, "1");
|
||||
process.exit(Number(process.env.FAKE_PUSH_FAIL_ONCE));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write("unexpected git command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(fakeGitPath, 0o755);
|
||||
|
||||
const fakeNpmPath = path.join(binDir, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\n");
|
||||
if (args[0] !== "view") {
|
||||
process.stderr.write("unexpected npm command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
const output = process.env.FAKE_NPM_VIEW_OUTPUT || "npm error code E404\nnpm error 404 Not Found";
|
||||
(Number(process.env.FAKE_NPM_VIEW_STATUS || "1") === 0 ? process.stdout : process.stderr).write(output + "\n");
|
||||
process.exit(Number(process.env.FAKE_NPM_VIEW_STATUS || "1"));
|
||||
`);
|
||||
fs.chmodSync(fakeNpmPath, 0o755);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
return {
|
||||
root,
|
||||
stateDir,
|
||||
logPath,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
LANG: "C",
|
||||
LC_ALL: "C",
|
||||
FAKE_GIT_LOG: logPath,
|
||||
FAKE_NPM_LOG: npmLogPath,
|
||||
FAKE_GIT_STATE_DIR: stateDir,
|
||||
FAKE_EXPECTED_GIT_CWD: fs.realpathSync(root),
|
||||
FAKE_HEAD_SHA: "aaaaaaaa",
|
||||
FAKE_REHEARSAL_SHA: "aaaaaaaa",
|
||||
FAKE_WORKFLOW: "args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
...env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runTagRelease(fixture, options = {}) {
|
||||
const { cwd = fixture.root, args = [], input = "" } = options;
|
||||
return spawnSync("bash", [path.join(fixture.root, "scripts/tag-release.sh"), ...args], {
|
||||
cwd,
|
||||
env: fixture.env,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function readGitCalls(fixture) {
|
||||
if (!fs.existsSync(fixture.logPath)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readFileSync(fixture.logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function assertNoTagOperations(calls) {
|
||||
const tagOperations = calls.filter((args) =>
|
||||
args[0] === "ls-remote" ||
|
||||
args[0] === "tag" ||
|
||||
args[0] === "push" ||
|
||||
(args[0] === "rev-parse" && args.some((arg) => arg.startsWith("refs/tags/"))),
|
||||
);
|
||||
assert.deepEqual(tagOperations, []);
|
||||
}
|
||||
|
||||
function assertNoTagWrites(calls) {
|
||||
assert.equal(calls.some((args) => args[0] === "tag" || args[0] === "push"), false);
|
||||
}
|
||||
|
||||
function validInputs(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertStructuredError(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
assert.ok(result.error.message.length > 0);
|
||||
assert.equal(typeof result.error.observed, "object");
|
||||
assert.equal(typeof result.error.hint, "string");
|
||||
assert.ok(result.error.hint.length > 0);
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable and beta rehearsal versions", () => {
|
||||
for (const version of ["1.2.3", "1.2.3-beta.0"]) {
|
||||
const { packageJson, packageLockJson } = validInputs(version);
|
||||
|
||||
assert.deepEqual(validateReleasePreflight(packageJson, packageLockJson), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: null,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, `v${version}`),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: version,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prerelease forms other than beta rehearsal versions", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3-rc.1");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects build metadata package versions with the stable release contract", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3+build.7");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid and missing package or lock SemVer values", () => {
|
||||
const invalid = validInputs();
|
||||
invalid.packageJson.version = "01.2.3";
|
||||
const missing = validInputs();
|
||||
delete missing.packageLockJson.packages[""].version;
|
||||
|
||||
for (const result of [
|
||||
validateReleasePreflight(invalid.packageJson, invalid.packageLockJson),
|
||||
validateReleasePreflight(missing.packageJson, missing.packageLockJson),
|
||||
]) {
|
||||
assertStructuredError(result);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a top-level package-lock version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.4",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a package-lock root package version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.4",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid and mismatched tags", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
|
||||
for (const tag of ["1.2.3", "v01.2.3", "v1.2.4"]) {
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson, tag);
|
||||
assertStructuredError(result);
|
||||
assert.equal(result.error.observed.tag, tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("release configuration", () => {
|
||||
it("writes success to stdout and structured failures to stderr", () => {
|
||||
const scriptPath = path.join(repoRoot, "scripts/release-preflight.js");
|
||||
const packageVersion = require(path.join(repoRoot, "package.json")).version;
|
||||
const success = spawnSync(process.execPath, [scriptPath, "--tag", `v${packageVersion}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const failure = spawnSync(process.execPath, [scriptPath, "--tag", "invalid"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(success.status, 0);
|
||||
assert.equal(success.stderr, "");
|
||||
assert.deepEqual(JSON.parse(success.stdout), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion,
|
||||
lockVersion: packageVersion,
|
||||
lockRootVersion: packageVersion,
|
||||
tagVersion: packageVersion,
|
||||
},
|
||||
});
|
||||
assert.equal(failure.status, 1);
|
||||
assert.equal(failure.stdout, "");
|
||||
assertStructuredError(JSON.parse(failure.stderr));
|
||||
});
|
||||
|
||||
it("keeps package metadata synchronized without changing the Node engine", () => {
|
||||
const packageJson = require(path.join(repoRoot, "package.json"));
|
||||
const packageLockJson = require(path.join(repoRoot, "package-lock.json"));
|
||||
|
||||
assert.equal(packageJson.scripts["release:check"], "node scripts/release-preflight.js");
|
||||
assert.equal(packageJson.engines.node, ">=16");
|
||||
assert.equal(packageLockJson.version, packageJson.version);
|
||||
assert.equal(packageLockJson.packages[""].version, packageJson.version);
|
||||
});
|
||||
|
||||
it("runs every release gate before any tag query, creation, or push", () => {
|
||||
const script = fs.readFileSync(path.join(repoRoot, "scripts/tag-release.sh"), "utf8");
|
||||
const preflight = script.indexOf('node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"');
|
||||
const requiredGates = [
|
||||
'CURRENT_BRANCH=$(git branch --show-current)',
|
||||
'git status --porcelain',
|
||||
'git fetch origin "${REHEARSAL_BRANCH}"',
|
||||
'git rev-parse "FETCH_HEAD^{commit}"',
|
||||
'git show "${HEAD_SHA}:.github/workflows/release.yml"',
|
||||
'npm view "@larksuite/cli@${VERSION}" version',
|
||||
];
|
||||
const tagOperations = [
|
||||
'git rev-parse -q --verify "refs/tags/${TAG}"',
|
||||
'git ls-remote --tags origin "refs/tags/${TAG}"',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
];
|
||||
|
||||
assert.ok(preflight >= 0, "release preflight invocation is missing");
|
||||
assertInOrder(script, [
|
||||
'REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"',
|
||||
'cd "${REPO_ROOT}"',
|
||||
'node "${SCRIPT_DIR}/release-preflight.js"',
|
||||
]);
|
||||
assert.equal(script.includes("require('${REPO_ROOT}/package.json')"), false);
|
||||
for (const gate of requiredGates) {
|
||||
const index = script.indexOf(gate);
|
||||
assert.ok(index >= 0, `required release gate is missing: ${gate}`);
|
||||
assert.ok(index < script.indexOf(tagOperations[0]), `${gate} must run before tag queries`);
|
||||
}
|
||||
for (const operation of tagOperations) {
|
||||
const index = script.indexOf(operation);
|
||||
assert.ok(index >= 0, `tag operation is missing: ${operation}`);
|
||||
assert.ok(preflight < index, `preflight must run before: ${operation}`);
|
||||
}
|
||||
assertInOrder(script, [
|
||||
'if [ "${PUSH_TAG}" != true ]',
|
||||
'read -r CONFIRM_TAG',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tag-release.sh behavior", () => {
|
||||
it("runs repository checks from the script repository when invoked elsewhere", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-cwd-"));
|
||||
t.after(() => fs.rmSync(outside, { recursive: true, force: true }));
|
||||
|
||||
const result = runTagRelease(fixture, { cwd: outside });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("rejects a non-rehearsal branch before querying or modifying tags", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_BRANCH: "feature/release" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be created from test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects a dirty working tree before tag operations", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_STATUS_OUTPUT: " M README.md" });
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /working tree must be clean/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects HEAD that differs from the fetched rehearsal branch", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_REHEARSAL_SHA: "bbbbbbbb" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /HEAD must exactly match origin\/test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("compares HEAD with the exact fetched rehearsal commit", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "fetch origin test/npm-staged-publish-rehearsal"));
|
||||
assert.ok(calls.some((args) => args.join(" ") === "rev-parse FETCH_HEAD^{commit}"));
|
||||
assert.equal(calls.some((args) => args.includes("origin/test/npm-staged-publish-rehearsal")), false);
|
||||
});
|
||||
|
||||
it("fails when the local tag already exists", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.stateDir, "local-tag"), "bbbbbbbb");
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /local tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "ls-remote"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
});
|
||||
|
||||
it("fails when a lightweight or annotated remote tag already exists", (t) => {
|
||||
for (const kind of ["lightweight", "annotated"]) {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_REMOTE_TAG_KIND: kind,
|
||||
FAKE_REMOTE_TAG_SHA: "aaaaaaaa",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1, `${kind}: ${result.stderr}`);
|
||||
assert.match(result.stderr, /remote tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "tag"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
}
|
||||
});
|
||||
|
||||
it("check mode completes without creating or pushing a tag", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /No tag was created or pushed/);
|
||||
assertNoTagWrites(calls);
|
||||
});
|
||||
|
||||
it("rejects a production version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"1.2.3"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.root, "package-lock.json"),
|
||||
'{"version":"1.2.3","packages":{"":{"version":"1.2.3"}}}\n',
|
||||
);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /require an X\.Y\.Z-beta\.N version/);
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
|
||||
it("rejects a workflow that can publish live", (t) => {
|
||||
for (const workflow of [
|
||||
"args: release --clean --skip=publish\nrun: npm publish --access public",
|
||||
"args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta\nrun: gh release create v1.2.3-beta.0",
|
||||
"args: release --clean --skip=publish\npermissions:\n contents: write\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
"args: release --clean --skip=publish\nenv:\n GITHUB_TOKEN: ${{ github.token }}\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
]) {
|
||||
const fixture = createReleaseFixture(t, { FAKE_WORKFLOW: workflow });
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be stage-only/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when npm cannot prove that the version is unused", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_OUTPUT: "npm error code ETIMEDOUT",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /npm version lookup failed/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("rejects an existing npm version", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_STATUS: "0",
|
||||
FAKE_NPM_VIEW_OUTPUT: "1.2.3-beta.0",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /already exists on npm/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("requires the full tag confirmation in push mode", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, { args: ["--push"], input: "no\n" });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /confirmation did not exactly match/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("pushes only the exact confirmed tag ref", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, {
|
||||
args: ["--push"],
|
||||
input: "v1.2.3-beta.0\n",
|
||||
});
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "tag v1.2.3-beta.0 aaaaaaaa"));
|
||||
assert.ok(calls.some((args) =>
|
||||
args.join(" ") === "push origin refs/tags/v1.2.3-beta.0:refs/tags/v1.2.3-beta.0"));
|
||||
assert.equal(
|
||||
calls.some((args) => args[0] === "push" && args.includes("--tags")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an invalid package version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"01.2.3"}\n');
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, "");
|
||||
assertStructuredError(JSON.parse(result.stderr));
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
});
|
||||
168
scripts/release-workflow.test.js
Normal file
168
scripts/release-workflow.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/release.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const previewWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/pkg-pr-new.yml"),
|
||||
"utf8",
|
||||
);
|
||||
function topLevelBlock(source, name) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`^${name}:\\n([\\s\\S]*?)(?=^[A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing top-level ${name} block`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function jobBlock(source, name) {
|
||||
const jobs = topLevelBlock(source, "jobs");
|
||||
const match = jobs.match(
|
||||
new RegExp(
|
||||
`^ ${name}:\\n([\\s\\S]*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing ${name} job`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing workflow fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `workflow fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionLines(job) {
|
||||
const match = job.match(/^ permissions:\n((?: .+\n)+)/m);
|
||||
assert.ok(match, "missing job permissions");
|
||||
return match[1].trim().split("\n").map((line) => line.trim()).sort();
|
||||
}
|
||||
|
||||
describe("release workflow contract", () => {
|
||||
it("has only the version-tag production trigger", () => {
|
||||
const trigger = topLevelBlock(releaseWorkflow, "on");
|
||||
|
||||
assert.match(trigger, /^on:\n push:\n tags:\n - 'v\*'\n+$/);
|
||||
for (const forbidden of [
|
||||
"workflow_dispatch:",
|
||||
"workflow_run:",
|
||||
"pull_request:",
|
||||
"pull_request_target:",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs preflight before every release side effect", () => {
|
||||
const preflight = jobBlock(releaseWorkflow, "preflight");
|
||||
|
||||
assert.deepEqual(permissionLines(preflight), ["contents: read"]);
|
||||
assertInOrder(preflight, [
|
||||
"actions/checkout@",
|
||||
"fetch-depth: 0",
|
||||
"actions/setup-node@",
|
||||
"node-version: '22.14.0'",
|
||||
"node scripts/release-preflight.js --tag \"$TAG\"",
|
||||
"git rev-parse --verify 'HEAD^{commit}'",
|
||||
"git rev-parse --verify \"refs/tags/${TAG}^{commit}\"",
|
||||
'if [[ "$TAG" == *-beta.* ]]',
|
||||
'git fetch origin "$REHEARSAL_BRANCH"',
|
||||
"git rev-parse --verify 'FETCH_HEAD^{commit}'",
|
||||
"git fetch origin main",
|
||||
'git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"',
|
||||
]);
|
||||
assert.equal(preflight.includes("gh release"), false);
|
||||
assert.equal(preflight.includes("npm publish"), false);
|
||||
});
|
||||
|
||||
it("builds a verified staging asset before approval", () => {
|
||||
const build = jobBlock(releaseWorkflow, "build-stage-assets");
|
||||
|
||||
assert.match(build, /needs: preflight/);
|
||||
assert.deepEqual(permissionLines(build), ["contents: read"]);
|
||||
assert.doesNotMatch(build, /^ environment:/m);
|
||||
assert.match(build, /actions\/setup-go@[0-9a-f]{40}/);
|
||||
assert.match(build, /actions\/setup-python@[0-9a-f]{40}/);
|
||||
assert.match(
|
||||
build,
|
||||
/actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/,
|
||||
);
|
||||
assert.match(build, /node-version: '22.14.0'/);
|
||||
assert.match(build, /registry-url: 'https:\/\/registry\.npmjs\.org'/);
|
||||
assert.match(build, /package-manager-cache: false/);
|
||||
assert.match(build, /npm install --global npm@11\.16\.0/);
|
||||
assert.match(build, /goreleaser\/goreleaser-action@[0-9a-f]{40}/);
|
||||
assert.match(build, /args: release --clean --skip=publish/);
|
||||
assertInOrder(build, [
|
||||
"actions/setup-go@",
|
||||
"actions/setup-python@",
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"goreleaser/goreleaser-action@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"cp dist/checksums.txt checksums.txt",
|
||||
"npm pack --ignore-scripts --json",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
"actions/upload-artifact@",
|
||||
]);
|
||||
assert.equal(build.includes("npm stage publish"), false);
|
||||
});
|
||||
|
||||
it("limits the protected job to verifying and staging the prepared npm asset", () => {
|
||||
const publish = jobBlock(releaseWorkflow, "stage-publish");
|
||||
|
||||
assert.match(publish, /needs: build-stage-assets/);
|
||||
assert.deepEqual(permissionLines(publish), ["id-token: write"]);
|
||||
assert.match(publish, /^ environment: npm-production$/m);
|
||||
assert.doesNotMatch(publish, /actions\/checkout@/);
|
||||
assert.doesNotMatch(publish, /goreleaser\/goreleaser-action@/);
|
||||
assert.doesNotMatch(publish, /GITHUB_TOKEN:/);
|
||||
assertInOrder(publish, [
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"actions/download-artifact@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
'npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta',
|
||||
]);
|
||||
for (const forbidden of [
|
||||
"gh release download",
|
||||
"npm view",
|
||||
"LOCAL_INTEGRITY",
|
||||
"REMOTE_INTEGRITY",
|
||||
"secrets.NPM_TOKEN",
|
||||
"NODE_AUTH_TOKEN",
|
||||
"GITHUB_TOKEN:",
|
||||
"gh release create",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
assert.equal(/(^|\s)npm publish(?:\s|$)/m.test(publish), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview isolation", () => {
|
||||
it("keeps preview publishing away from production credentials and registry", () => {
|
||||
assert.equal(previewWorkflow.includes("id-token: write"), false);
|
||||
assert.equal(previewWorkflow.includes("npm publish"), false);
|
||||
assert.equal(previewWorkflow.includes("registry.npmjs.org"), false);
|
||||
assert.equal(previewWorkflow.includes("secrets.NPM_TOKEN"), false);
|
||||
assert.equal(previewWorkflow.includes("NODE_AUTH_TOKEN"), false);
|
||||
});
|
||||
});
|
||||
@@ -3,49 +3,102 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
TAG="v${VERSION}"
|
||||
REHEARSAL_BRANCH="test/npm-staged-publish-rehearsal"
|
||||
PUSH_TAG=false
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "--push" ]; then
|
||||
PUSH_TAG=true
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
echo "Usage: $0 [--push]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
|
||||
echo "Error: rehearsal releases require an X.Y.Z-beta.N version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "${REHEARSAL_BRANCH}" ]; then
|
||||
echo "Error: rehearsal tags must be created from ${REHEARSAL_BRANCH}; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Error: the working tree must be clean before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git fetch origin "${REHEARSAL_BRANCH}"
|
||||
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_REHEARSAL_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_REHEARSAL_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/${REHEARSAL_BRANCH} before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKFLOW=$(git show "${HEAD_SHA}:.github/workflows/release.yml")
|
||||
if ! grep -Fq 'args: release --clean --skip=publish' <<<"${WORKFLOW}" ||
|
||||
! grep -Eq 'npm stage publish .*--tag beta' <<<"${WORKFLOW}" ||
|
||||
grep -Eq '(^|[[:space:]])npm publish([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'gh[[:space:]]+release([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'contents:[[:space:]]*write' <<<"${WORKFLOW}" ||
|
||||
grep -Fq 'GITHUB_TOKEN:' <<<"${WORKFLOW}"; then
|
||||
echo "Error: the tagged workflow must be stage-only, read-only for repository contents, and must not create a GitHub Release or publish npm live." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
NPM_VIEW_OUTPUT=$(npm view "@larksuite/cli@${VERSION}" version --registry=https://registry.npmjs.org/ 2>&1)
|
||||
NPM_VIEW_STATUS=$?
|
||||
set -e
|
||||
if [ "${NPM_VIEW_STATUS}" -eq 0 ]; then
|
||||
echo "Error: @larksuite/cli@${VERSION} already exists on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq 'E404|404 Not Found' <<<"${NPM_VIEW_OUTPUT}"; then
|
||||
echo "Error: npm version lookup failed; refusing to assume the version is unused." >&2
|
||||
echo "${NPM_VIEW_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${PUSH_TAG}" != true ]; then
|
||||
echo "Checks passed. No tag was created or pushed."
|
||||
echo "Run '$0 --push' only after reviewing the commit and workflow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Branch: ${CURRENT_BRANCH}"
|
||||
echo "Commit: ${HEAD_SHA}"
|
||||
printf 'Type %s to create and push this tag: ' "${TAG}"
|
||||
read -r CONFIRM_TAG
|
||||
if [ "${CONFIRM_TAG}" != "${TAG}" ]; then
|
||||
echo "Error: confirmation did not exactly match ${TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
|
||||
Reference in New Issue
Block a user