Compare commits

...

13 Commits

Author SHA1 Message Date
guokexin.02
e7ff09f28e test: exercise checksum failure gate 2026-07-20 20:57:46 +08:00
guokexin.02
b704a495de test: isolate staged publish approval 2026-07-20 19:18:16 +08:00
guokexin.02
72fe82d70d test: harden staged rehearsal gates 2026-07-20 18:40:33 +08:00
guokexin.02
683a721a76 test: prepare staged publishing rehearsal 2026-07-20 17:48:32 +08:00
guokexin.02
bbb3c505e0 ci: align release preflight runtime 2026-07-20 17:28:23 +08:00
guokexin.02
e50820bd11 ci: streamline secure release workflow 2026-07-20 16:33:25 +08:00
guokexin.02
ac1e09e46f ci: simplify release safeguards 2026-07-20 15:33:42 +08:00
guokexin.02
2bfde8d886 Merge remote-tracking branch 'origin/main' into ci/npm-secure-publishing 2026-07-20 15:32:57 +08:00
guokexin.02
964c571063 ci: sync secure publishing with current main 2026-07-20 14:32:24 +08:00
guokexin.02
4a523b12f2 ci: address secure publishing review feedback 2026-07-20 14:24:03 +08:00
guokexin.02
c6039a923c ci: sync secure publishing with latest main 2026-07-20 14:12:52 +08:00
guokexin.02
15e4175986 ci: gate GitHub and npm publishing together 2026-07-16 21:03:06 +08:00
guokexin.02
12b7f7a0cd ci: harden npm release publishing 2026-07-16 17:26:43 +08:00
10 changed files with 1148 additions and 70 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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"

View File

@@ -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",

View File

@@ -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.

View File

@@ -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(

View 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();

View 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), []);
});
});

View 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);
});
});

View File

@@ -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}"