Compare commits

...

29 Commits

Author SHA1 Message Date
guokexin.02
4fb9961e1d fix: derive draft release target from tag 2026-07-30 19:22:56 +08:00
guokexin.02
f2f1f4c7e2 test: prepare beta.4 release rehearsal 2026-07-30 19:14:39 +08:00
guokexin.02
10c4161414 test: trigger second queue probe 2026-07-30 19:11:10 +08:00
guokexin.02
e76ec57294 test: add npm publication queue probe 2026-07-30 19:10:36 +08:00
guokexin.02
af1fd4f810 test: rehearse draft release resumption 2026-07-30 19:08:01 +08:00
guokexin.02
b18e9e2656 fix: serialize npm publishing and resume draft uploads 2026-07-30 16:43:16 +08:00
guokexin.02
e845e5f3e4 test: prepare v1.0.79-beta.1 release rehearsal 2026-07-30 16:19:36 +08:00
guokexin.02
fc60760114 fix: fail closed on npm release lookup errors 2026-07-30 16:18:47 +08:00
guokexin.02
2b68dca695 Revert "ci: add macOS release secret encoder"
This reverts commit 9b1e76e218.
2026-07-30 14:11:44 +08:00
guokexin.02
9b1e76e218 ci: add macOS release secret encoder 2026-07-30 14:10:30 +08:00
guokexin.02
d8defec376 ci: harden notarized release publication 2026-07-30 12:57:34 +08:00
guokexin.02
a1c7416735 fix: restore release retry guidance 2026-07-29 15:43:57 +08:00
guokexin.02
6bbfd37617 test: prepare beta 15 release rehearsal 2026-07-29 15:31:23 +08:00
guokexin.02
aa3bf27cb1 ci: allow beta releases from feature branches 2026-07-29 15:18:55 +08:00
guokexin.02
2cbd5d69fe test: restore release checks after beta rehearsal 2026-07-29 11:39:28 +08:00
guokexin.02
33d22835ab test: prepare beta 14 release rehearsal 2026-07-29 11:22:26 +08:00
guokexin.02
2f389980cf fix: harden macos release verification 2026-07-29 11:19:23 +08:00
guokexin.02
cdf432cb13 ci: restore npm approval and add retry guidance 2026-07-28 16:51:31 +08:00
guokexin.02
5d18b4ebce ci: simplify release trust checks 2026-07-28 15:59:52 +08:00
guokexin.02
e8a464e2d8 test: guard release workflow contract 2026-07-28 14:53:35 +08:00
guokexin.02
fd5f934d8d ci: publish verified release candidates 2026-07-28 14:53:35 +08:00
guokexin.02
f5559fc888 ci: verify notarized macos release assets 2026-07-28 14:53:35 +08:00
guokexin.02
ae4282b214 ci: harden notarized release candidate build 2026-07-28 14:53:35 +08:00
guokexin.02
1a731f448d ci: build notarized release candidates 2026-07-28 14:53:35 +08:00
guokexin.02
3b26fdd549 fix: harden release candidate validation 2026-07-28 14:53:35 +08:00
guokexin.02
9f7146026d fix: bind artifact verification to candidate manifest 2026-07-28 14:53:35 +08:00
guokexin.02
44a4d96a8b ci: validate release candidate integrity 2026-07-28 14:53:35 +08:00
guokexin.02
7b51b5d228 ci: configure macos notarization 2026-07-28 14:53:35 +08:00
guokexin.02
74f64972a4 ci: support beta release versions 2026-07-28 14:53:35 +08:00
12 changed files with 999 additions and 93 deletions

View File

@@ -8,130 +8,536 @@ on:
permissions:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
source_sha: ${{ steps.validate.outputs.source_sha }}
version: ${{ steps.validate.outputs.version }}
channel: ${{ steps.validate.outputs.channel }}
prerelease: ${{ steps.validate.outputs.prerelease }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
- name: Refuse to rebuild a published release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_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 ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
if gh release view "$TAG" --json isDraft -q .isDraft 2>/dev/null | grep -Fxq false; then
echo "Release ${TAG} is already public. Do not re-run the workflow; publish a new version instead." >&2
exit 1
fi
build-release:
- name: Validate protected release tag
id: validate
env:
REF_PROTECTED: ${{ github.ref_protected }}
REPOSITORY: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
[[ "$REPOSITORY" == "larksuite/cli" ]] || { echo "Release tags are accepted only from larksuite/cli." >&2; exit 1; }
[[ "$REF_PROTECTED" == "true" ]] || { echo "Release tag ${TAG} must be protected by a repository ruleset." >&2; exit 1; }
preflight_file="${RUNNER_TEMP}/release-preflight.json"
node scripts/release-preflight.js --tag "$TAG" > "$preflight_file"
git fetch --no-tags origin main
head_sha="$(git rev-parse --verify 'HEAD^{commit}')"
tag_sha="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
[[ "$tag_sha" == "$head_sha" ]] || { echo "Tag ${TAG} does not resolve to checked-out HEAD." >&2; exit 1; }
main_sha="$(git rev-parse FETCH_HEAD)"
source_in_main=false
if git merge-base --is-ancestor "$head_sha" "$main_sha"; then
source_in_main=true
fi
node - "$preflight_file" "$head_sha" "$main_sha" "$source_in_main" "$GITHUB_OUTPUT" <<'NODE'
const fs = require("node:fs");
const { validateReleaseSourcePolicy } = require("./scripts/release-preflight");
const [file, sourceSha, mainSha, sourceInMain, output] = process.argv.slice(2);
const result = JSON.parse(fs.readFileSync(file, "utf8"));
if (result?.ok !== true || !["stable", "beta"].includes(result.data?.releaseChannel)) {
throw new Error("release preflight returned an invalid success payload");
}
const channel = result.data.releaseChannel;
const sourcePolicy = validateReleaseSourcePolicy(channel, sourceSha, mainSha, sourceInMain === "true");
if (!sourcePolicy.ok) {
throw new Error(sourcePolicy.error.message);
}
if (sourcePolicy.data.warning) {
console.log(`::warning title=Unexpected beta source::${sourcePolicy.data.warning}`);
}
fs.appendFileSync(output, `source_sha=${sourceSha}\nversion=${result.data.tagVersion}\nchannel=${channel}\nprerelease=${channel === "beta"}\n`);
NODE
build-sign-notarize:
needs: preflight
runs-on: ubuntu-22.04
timeout-minutes: 45
# This is the single approval boundary for the release. It grants access to
# Apple signing material before the candidate is built.
environment: npm-production
permissions:
contents: write
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
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: Prepare Apple notarization key
env:
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ vars.MACOS_NOTARY_KEY_ID }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_TEAM_ID: ${{ vars.MACOS_TEAM_ID }}
run: |
set -euo pipefail
set +x
for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_KEY MACOS_TEAM_ID MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do
[[ -n "${!name:-}" ]] || { echo "Required Apple release input ${name} is not configured." >&2; exit 1; }
done
umask 077
notary_key="$(mktemp "${RUNNER_TEMP}/macos-notary-key.XXXXXX")"
if ! printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$notary_key"; then
rm -f -- "$notary_key"
echo "MACOS_NOTARY_KEY must be base64-encoded P8 content." >&2
exit 1
fi
chmod 0600 "$notary_key"
grep -Fxq -- '-----BEGIN PRIVATE KEY-----' "$notary_key" || {
rm -f -- "$notary_key"
echo "MACOS_NOTARY_KEY did not decode to a P8 private key." >&2
exit 1
}
printf 'MACOS_NOTARY_KEY_PATH=%s\n' "$notary_key" >> "$GITHUB_ENV"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: '~> v2'
args: release --clean
# v2.17.1 is the first tested version with this macOS notarization configuration.
version: v2.17.1
args: release --clean --skip=publish
env:
GITHUB_TOKEN: ${{ github.token }}
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ vars.MACOS_NOTARY_KEY_ID }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
- name: Include release checksums
- name: Clean up Apple notarization key
if: ${{ always() }}
run: |
set -euo pipefail
set +x
[[ -z "${MACOS_NOTARY_KEY_PATH:-}" ]] || rm -f -- "$MACOS_NOTARY_KEY_PATH"
- name: Build release candidate
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
mkdir release-candidate
cp dist/*.tar.gz dist/*.zip dist/checksums.txt release-candidate/
cp dist/CHANGELOG.md release-candidate/CHANGELOG.md
cp dist/checksums.txt checksums.txt
npm install --global npm@11.16.0
pack_json="$(npm pack --ignore-scripts --json --pack-destination release-candidate)"
node - "$pack_json" "$VERSION" <<'NODE'
const [payload, version] = process.argv.slice(2);
const packs = JSON.parse(payload);
if (!Array.isArray(packs) || packs.length !== 1 || packs[0]?.name !== "@larksuite/cli" || packs[0]?.version !== version || !/^[^/\\]+\.tgz$/.test(packs[0]?.filename || "")) {
throw new Error("npm pack did not produce the expected release package");
}
NODE
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- name: Upload release candidate
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
name: release-candidate-${{ github.run_id }}
path: release-candidate/
if-no-files-found: error
overwrite: true
publish-npm:
needs: build-release
create-draft-release:
needs: [preflight, build-sign-notarize]
runs-on: ubuntu-22.04
environment: npm-production
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Create or reuse Draft Release
env:
GH_TOKEN: ${{ github.token }}
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
REHEARSAL_PARTIAL_DRAFT: true
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
release_notes=release-candidate/CHANGELOG.md
release_assets=(release-candidate/*.tar.gz release-candidate/*.zip release-candidate/checksums.txt)
release_error="$(mktemp "${RUNNER_TEMP}/release-view.XXXXXX")"
trap 'rm -f -- "$release_error"' EXIT
verify_release_metadata() {
node -e '
const [metadata, sourceSha, prerelease] = process.argv.slice(1);
const release = JSON.parse(metadata);
if (release.isDraft !== true || release.targetCommitish !== sourceSha || release.isPrerelease !== (prerelease === "true")) {
throw new Error("Release metadata does not match this release candidate.");
}
' "$1" "$SOURCE_SHA" "$PRERELEASE"
}
if metadata="$(gh release view "$TAG" --json isDraft,isPrerelease,targetCommitish 2>"$release_error")"; then
verify_release_metadata "$metadata"
existing="$(mktemp -d "${RUNNER_TEMP}/draft-release.XXXXXX")"
trap 'rm -f -- "$release_error"; rm -rf -- "$existing"' EXIT
expected_names=("${release_assets[@]##*/}")
mapfile -t existing_names < <(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
for name in "${existing_names[@]}"; do
printf '%s\n' "${expected_names[@]}" | grep -Fxq "$name" || {
echo "Draft Release contains unexpected asset ${name}." >&2
exit 1
}
done
if (( ${#existing_names[@]} > 0 )); then
gh release download "$TAG" --dir "$existing"
for name in "${existing_names[@]}"; do
if [[ "$name" == "checksums.txt" ]]; then
cmp --silent release-candidate/checksums.txt "$existing/$name" || { echo "Draft Release checksums do not match the current candidate." >&2; exit 1; }
continue
fi
checksum_line="$(awk -v name="$name" '$2 == name { print }' release-candidate/checksums.txt)"
[[ "$(printf '%s\n' "$checksum_line" | sed '/^$/d' | wc -l | tr -d '[:space:]')" == "1" ]] || { echo "Candidate has no unique checksum for ${name}." >&2; exit 1; }
printf '%s\n' "$checksum_line" | (cd "$existing" && sha256sum --check -)
done
fi
missing_assets=()
for asset in "${release_assets[@]}"; do
name="${asset##*/}"
printf '%s\n' "${existing_names[@]}" | grep -Fxq "$name" || missing_assets+=("$asset")
done
if (( ${#missing_assets[@]} > 0 )); then
gh release upload "$TAG" "${missing_assets[@]}"
fi
diff --brief \
<(printf '%s\n' "${expected_names[@]}" | sort) \
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
gh release edit "$TAG" --notes-file "$release_notes"
exit 0
else
if ! grep -Eqi 'HTTP 404|release not found' "$release_error"; then
cat "$release_error" >&2
echo "Could not determine whether the Release already exists." >&2
exit 1
fi
args=("$TAG" --title "$TAG" --draft)
[[ "$PRERELEASE" != "true" ]] || args+=(--prerelease)
gh release create "${args[@]}" --notes-file "$release_notes"
verify_release_metadata "$(gh release view "$TAG" --json isDraft,isPrerelease,targetCommitish)"
fi
gh release edit "$TAG" --notes-file "$release_notes"
if [[ "${REHEARSAL_PARTIAL_DRAFT:-}" == "true" && "$GITHUB_RUN_ATTEMPT" == "1" ]]; then
gh release upload "$TAG" "${release_assets[0]}"
echo "S2 rehearsal: partial Draft uploaded; re-run failed jobs to resume." >&2
exit 1
fi
gh release upload "$TAG" "${release_assets[@]}"
diff --brief \
<(printf '%s\n' "${release_assets[@]##*/}" | sort) \
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
verify-macos:
needs: [preflight, build-sign-notarize, create-draft-release]
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
arch: amd64
- runner: macos-15
arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
steps:
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Verify notarized macOS binary
env:
ARCH: ${{ matrix.arch }}
MACOS_TEAM_ID: ${{ vars.MACOS_TEAM_ID }}
REHEARSAL_EXPECTED_TEAM_ID: TEST_INVALID_TEAM
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
[[ -n "$MACOS_TEAM_ID" ]] || { echo "MACOS_TEAM_ID is not configured." >&2; exit 1; }
archive="lark-cli-${VERSION}-darwin-${ARCH}.tar.gz"
awk -v archive="$archive" '$2 == archive { print }' release-candidate/checksums.txt > checksum.txt
[[ "$(wc -l < checksum.txt | tr -d '[:space:]')" == "1" ]] || { echo "checksums.txt must contain exactly one entry for ${archive}." >&2; exit 1; }
(cd release-candidate && shasum -a 256 -c ../checksum.txt)
work="$(mktemp -d "${RUNNER_TEMP}/macos-release.XXXXXX")"
trap 'rm -rf -- "$work" checksum.txt' EXIT
tar -xzf "release-candidate/$archive" -C "$work"
binary="$work/lark-cli"
[[ -f "$binary" && ! -L "$binary" ]] || { echo "Archive did not contain a regular lark-cli binary." >&2; exit 1; }
codesign --verify --strict --verbose=4 "$binary"
details="$(codesign -dv --verbose=4 "$binary" 2>&1)"
[[ -z "${REHEARSAL_EXPECTED_TEAM_ID:-}" ]] || MACOS_TEAM_ID="$REHEARSAL_EXPECTED_TEAM_ID"
grep -Eq '^Authority=Developer ID Application: .+' <<<"$details"
grep -Fxq "TeamIdentifier=${MACOS_TEAM_ID}" <<<"$details"
grep -Eq '^flags=0x[0-9A-Fa-f]+\(.*runtime.*\)' <<<"$details"
grep -Eq '^Timestamp=.+' <<<"$details"
codesign --verify --strict --verbose=4 --check-notarization -R='notarized' "$binary"
"$binary" --version | grep -Fq "$VERSION"
publish-github:
needs: [preflight, create-draft-release, verify-macos]
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Verify Draft assets match the candidate
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
existing="$(mktemp -d "${RUNNER_TEMP}/draft-release.XXXXXX")"
trap 'rm -rf -- "$existing"' EXIT
gh release download "$TAG" --dir "$existing"
cmp --silent release-candidate/checksums.txt "$existing/checksums.txt" || { echo "Draft Release checksums do not match the verified candidate." >&2; exit 1; }
(cd "$existing" && sha256sum --check checksums.txt)
diff --brief \
<(find release-candidate -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name checksums.txt \) -printf '%f\n' | sort) \
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
- name: Publish verified Draft Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: gh release edit "$TAG" --draft=false
publish-npm:
needs: [preflight, build-sign-notarize, publish-github]
runs-on: ubuntu-22.04
timeout-minutes: 15
concurrency:
group: npm-release-${{ needs.preflight.outputs.channel }}
queue: max
cancel-in-progress: false
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- 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 release asset
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
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'
rm "$PACK_FILE"
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Publish or verify npm package
env:
CHANNEL: ${{ needs.preflight.outputs.channel }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob
packages=(release-candidate/*.tgz)
(( ${#packages[@]} == 1 )) || { echo "Expected exactly one npm package." >&2; exit 1; }
tgz="${packages[0]}"
tar -xOzf "$tgz" package/checksums.txt > "${RUNNER_TEMP}/checksums.txt"
cmp --silent release-candidate/checksums.txt "${RUNNER_TEMP}/checksums.txt" || { echo "npm package checksums do not match the release candidate." >&2; exit 1; }
integrity="$(node - "$tgz" <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const hash = crypto.createHash("sha512");
hash.update(fs.readFileSync(process.argv[2]));
process.stdout.write(`sha512-${hash.digest("base64")}`);
NODE
)"
dist_tag=latest
[[ "$CHANNEL" != "beta" ]] || dist_tag=beta
version_exists=false
integrity_matches=false
version_error="$(mktemp "${RUNNER_TEMP}/npm-version.XXXXXX")"
trap 'rm -f -- "$version_error"' EXIT
if npm view "@larksuite/cli@${VERSION}" version --json >/dev/null 2>"$version_error"; then
version_exists=true
published="$(npm view "@larksuite/cli@${VERSION}" dist.integrity --json | tr -d '"[:space:]')"
[[ "$published" != "$integrity" ]] || integrity_matches=true
elif ! node - "$version_error" <<'NODE'
const fs = require("node:fs");
const { isNpmVersionMissing } = require("./scripts/release-publish-policy");
process.exit(isNpmVersionMissing(fs.readFileSync(process.argv[2], "utf8")) ? 0 : 1);
NODE
then
cat "$version_error" >&2
echo "Could not determine whether npm version ${VERSION} already exists." >&2
exit 1
fi
if ! current="$(npm view @larksuite/cli "dist-tags.${dist_tag}" --json | tr -d '"[:space:]')"; then
echo "Could not determine the npm ${dist_tag} dist-tag." >&2
exit 1
fi
[[ -n "$current" && "$current" != "null" ]] || current=null
decision="$(node - "$VERSION" "$version_exists" "$integrity_matches" "$current" <<'NODE'
const { decideNpmPublish } = require("./scripts/release-publish-policy");
const [version, versionExists, integrityMatches, channelVersion] = process.argv.slice(2);
process.stdout.write(JSON.stringify(decideNpmPublish({
version,
versionExists: versionExists === "true",
integrityMatches: integrityMatches === "true",
channelVersion: channelVersion === "null" ? null : channelVersion,
})));
NODE
)"
action="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).action)' "$decision")"
case "$action" in
publish)
npm publish "./$tgz" --access public --provenance --tag "$dist_tag"
;;
advance-tag)
npm dist-tag add "@larksuite/cli@${VERSION}" "$dist_tag"
;;
verify)
echo "npm ${dist_tag} already points to ${VERSION} or a newer verified version."
;;
reject)
node -e 'console.error(JSON.parse(process.argv[1]).reason); process.exit(1)' "$decision"
;;
*)
echo "Unexpected npm publish decision: ${action}" >&2
exit 1
;;
esac
- name: Publish to npm
run: npm publish --access public
retry-guidance:
needs: [preflight, build-sign-notarize, create-draft-release, verify-macos, publish-github, publish-npm]
if: ${{ always() && (needs.preflight.result == 'failure' || needs.build-sign-notarize.result == 'failure' || needs.create-draft-release.result == 'failure' || needs.verify-macos.result == 'failure' || needs.publish-github.result == 'failure' || needs.publish-npm.result == 'failure') }}
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- name: Write retry guidance
env:
PREFLIGHT_RESULT: ${{ needs.preflight.result }}
BUILD_RESULT: ${{ needs.build-sign-notarize.result }}
DRAFT_RESULT: ${{ needs.create-draft-release.result }}
VERIFY_RESULT: ${{ needs.verify-macos.result }}
GITHUB_RESULT: ${{ needs.publish-github.result }}
NPM_RESULT: ${{ needs.publish-npm.result }}
run: |
set -euo pipefail
{
echo "## Release retry guidance"
echo
echo "This job only records recovery guidance; it does not retry or publish anything."
echo
echo "| Job | Result |"
echo "| --- | --- |"
echo "| preflight | ${PREFLIGHT_RESULT} |"
echo "| build-sign-notarize | ${BUILD_RESULT} |"
echo "| create-draft-release | ${DRAFT_RESULT} |"
echo "| verify-macos | ${VERIFY_RESULT} |"
echo "| publish-github | ${GITHUB_RESULT} |"
echo "| publish-npm | ${NPM_RESULT} |"
printf '%s\n' \
'' \
'Select the recovery action from the failed-step diagnosis:' \
'' \
'- **preflight:** network or fetch failure → retry preflight. Version/tag validation failure → correct it, then create a new tag.' \
'- **build-sign-notarize:** transient build/service failure → retry build. Code or release configuration issue → correct it, then create a new tag.' \
'- **create-draft-release:** GitHub Draft Release API/upload failure → retry draft; a partial Draft is verified and only missing assets are uploaded. Release-candidate inconsistency → retry build.' \
'- **verify-macos:** runner or network failure → retry only the failed matrix child. Checksum, signing, notarization, or runtime failure → retry build.' \
'- **publish-github:** GitHub publish network failure → retry GitHub publish. Install issue → retry build. Tag/assets inconsistency → stop and publish a new version.' \
'- **publish-npm:** network failure or uncertain publish outcome → retry npm only after verifying whether that version already exists. Integrity mismatch → publish a new version.'
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -5,7 +5,8 @@ before:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
- id: lark-cli
binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -19,11 +20,27 @@ builds:
- arm64
- riscv64
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids:
- lark-cli
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY_PATH }}"
wait: true
timeout: 20m
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
format: zip
formats: [zip]
files:
- README.md
- LICENSE

View File

@@ -50,8 +50,9 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/release-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/release-publish-policy.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.78",
"version": "1.0.79-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.79-beta.2",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.79-beta.2",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -7,7 +7,7 @@ const { execFileSync } = require("child_process");
const os = require("os");
const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
@@ -37,13 +37,26 @@ const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const { archiveName, githubUrl: GITHUB_URL } = resolveReleaseAsset(
VERSION,
platform,
arch
);
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
function resolveReleaseAsset(version, platformName, archName) {
const extension = platformName === "windows" ? ".zip" : ".tar.gz";
const resolvedArchiveName =
`${NAME}-${version}-${platformName}-${archName}${extension}`;
return {
archiveName: resolvedArchiveName,
githubUrl:
`https://github.com/${REPO}/releases/download/v${version}/${resolvedArchiveName}`,
};
}
// Build the ordered list of binary mirror URLs to try. Resolution rules:
// 1. npm_config_registry — when the user has set a non-default
// registry (npmmirror clone, corp Verdaccio,
@@ -348,4 +361,4 @@ if (require.main === module) {
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, resolveReleaseAsset, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };

View File

@@ -9,7 +9,36 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
const {
getExpectedChecksum,
verifyChecksum,
assertAllowedHost,
resolveMirrorUrls,
resolveReleaseAsset,
isCurlVersionSupported,
} = require("./install.js");
describe("resolveReleaseAsset", () => {
it("preserves a beta package version in tag and archive paths", () => {
const asset = resolveReleaseAsset(
"1.2.0-beta.1",
"linux",
"amd64"
);
assert.deepEqual(asset, {
archiveName: "lark-cli-1.2.0-beta.1-linux-amd64.tar.gz",
githubUrl:
"https://github.com/larksuite/cli/releases/download/v1.2.0-beta.1/lark-cli-1.2.0-beta.1-linux-amd64.tar.gz",
});
assert.deepEqual(
resolveMirrorUrls({}, asset.archiveName, "1.2.0-beta.1"),
[
"https://registry.npmmirror.com/-/binary/lark-cli/v1.2.0-beta.1/lark-cli-1.2.0-beta.1-linux-amd64.tar.gz",
]
);
});
});
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {

View File

@@ -5,16 +5,47 @@
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 RELEASE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
function releaseChannelOf(value) {
if (typeof value !== "string" || !RELEASE_VERSION_PATTERN.test(value)) {
return null;
}
return value.includes("-beta.") ? "beta" : "stable";
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleaseSourcePolicy(releaseChannel, sourceSha, mainSha, sourceInMain) {
const observed = { releaseChannel, sourceSha, mainSha, sourceInMain };
if (releaseChannel === "stable") {
if (!sourceInMain) {
return releaseError(
"Stable release tag must be contained in origin/main",
observed,
"Create the stable release tag from a commit that is already contained in origin/main.",
);
}
return { ok: true, data: { warning: null } };
}
if (releaseChannel === "beta") {
const warning = sourceSha === mainSha
? "Beta release tag points to the current origin/main HEAD; check whether an unintended beta version was merged into main."
: null;
return { ok: true, data: { warning } };
}
return releaseError(
"Release channel must be stable or beta",
observed,
"Use a validated stable or beta package version before applying the source policy.",
);
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
@@ -31,11 +62,11 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
if (!releaseChannelOf(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
`${field} must be a Stable or Beta release version`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
"Use the same stable X.Y.Z or beta X.Y.Z-beta.N version in all package fields; other prerelease labels and build metadata are not allowed.",
);
}
}
@@ -48,14 +79,15 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
);
}
const releaseChannel = releaseChannelOf(packageVersion);
if (tag === undefined) {
return { ok: true, data: observed };
return { ok: true, data: { ...observed, releaseChannel } };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
if (typeof tag !== "string" || !tag.startsWith("v") || !releaseChannelOf(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
"--tag must use a Stable or Beta release form",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
`Use --tag v${packageVersion}; valid forms are vX.Y.Z and vX.Y.Z-beta.N.`,
);
}
@@ -67,7 +99,7 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
return { ok: true, data: { ...observed, tagVersion, releaseChannel } };
}
function writeResult(result) {
@@ -82,7 +114,7 @@ function main() {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
"Expected no arguments or --tag vX.Y.Z[-beta.N]",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
@@ -103,6 +135,6 @@ function main() {
}
}
module.exports = { validateReleasePreflight };
module.exports = { validateReleasePreflight, validateReleaseSourcePolicy };
if (require.main === module) main();

View File

@@ -4,7 +4,7 @@
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
const { validateReleasePreflight, validateReleaseSourcePolicy } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
@@ -35,22 +35,80 @@ describe("validateReleasePreflight", () => {
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
releaseChannel: "stable",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
it("accepts matching beta package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata("1.2.3-beta.4");
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3-beta.4"),
{
ok: true,
data: {
packageVersion: "1.2.3-beta.4",
lockVersion: "1.2.3-beta.4",
lockRootVersion: "1.2.3-beta.4",
tagVersion: "1.2.3-beta.4",
releaseChannel: "beta",
},
},
);
});
it("derives the release channel when no tag is provided", () => {
const { packageJson, packageLockJson } = metadata("1.2.3-beta.0");
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson),
{
ok: true,
data: {
packageVersion: "1.2.3-beta.0",
lockVersion: "1.2.3-beta.0",
lockRootVersion: "1.2.3-beta.0",
tagVersion: null,
releaseChannel: "beta",
},
},
);
});
it("rejects unsupported or invalid package versions with an actionable hint", () => {
for (const version of [
"1.2.3-alpha.1",
"1.2.3-rc.1",
"1.2.3-beta",
"1.2.3-beta.01",
"1.2.3+build.1",
"1.2.3-beta.1+build.1",
"01.2.3",
"1.02.3",
"1.2.03",
]) {
const { packageJson, packageLockJson } = metadata(version);
const result = validateReleasePreflight(packageJson, packageLockJson);
assertRejected(result);
assert.match(result.error.hint, /stable X\.Y\.Z or beta X\.Y\.Z-beta\.N/i);
}
});
it("rejects inconsistent package metadata", () => {
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
const channelMismatch = metadata("1.2.3-beta.1");
channelMismatch.packageLockJson.version = "1.2.3";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
channelMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
@@ -59,8 +117,56 @@ describe("validateReleasePreflight", () => {
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
for (const tag of [
"1.2.3",
"v1.2.3-alpha.1",
"v1.2.3-beta.01",
"v1.2.3+build.1",
"v1.2.3-beta.1",
"v1.2.4",
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
it("rejects a beta tag that does not match beta package metadata", () => {
const { packageJson, packageLockJson } = metadata("1.2.3-beta.2");
for (const tag of ["v1.2.3-beta.1", "v1.2.3"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});
describe("validateReleaseSourcePolicy", () => {
const mainSha = "a".repeat(40);
const betaSha = "b".repeat(40);
it("accepts a stable release commit contained in main", () => {
assert.deepEqual(
validateReleaseSourcePolicy("stable", betaSha, mainSha, true),
{ ok: true, data: { warning: null } },
);
});
it("rejects a stable release commit outside main", () => {
const result = validateReleaseSourcePolicy("stable", betaSha, mainSha, false);
assertRejected(result);
assert.match(result.error.message, /contained in origin\/main/);
});
it("accepts a beta release from a separate branch without a warning", () => {
assert.deepEqual(
validateReleaseSourcePolicy("beta", betaSha, mainSha, false),
{ ok: true, data: { warning: null } },
);
});
it("warns but accepts a beta release tag pointing to the current main head", () => {
const result = validateReleaseSourcePolicy("beta", mainSha, mainSha, true);
assert.equal(result.ok, true);
assert.match(result.data.warning, /unintended beta version was merged into main/);
});
});

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const RELEASE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?$/;
function parseReleaseVersion(version) {
const match = typeof version === "string" ? RELEASE_VERSION_PATTERN.exec(version) : null;
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
beta: match[4] === undefined ? null : Number(match[4]),
};
}
function compareReleaseVersions(left, right) {
const a = parseReleaseVersion(left);
const b = parseReleaseVersion(right);
if (!a || !b) throw new Error("Both versions must be stable or beta release versions.");
for (const field of ["major", "minor", "patch"]) {
if (a[field] !== b[field]) return a[field] < b[field] ? -1 : 1;
}
if (a.beta === b.beta) return 0;
if (a.beta === null) return 1;
if (b.beta === null) return -1;
return a.beta < b.beta ? -1 : 1;
}
function isNpmVersionMissing(errorOutput) {
return typeof errorOutput === "string" && /(?:^|\s)E404(?:\s|$)/m.test(errorOutput);
}
function decideNpmPublish({ version, versionExists, integrityMatches, channelVersion }) {
if (!parseReleaseVersion(version)) {
throw new Error("version must be a stable or beta release version.");
}
if (channelVersion !== null && !parseReleaseVersion(channelVersion)) {
throw new Error("channelVersion must be null or a stable or beta release version.");
}
if (!versionExists) {
if (channelVersion !== null && compareReleaseVersions(channelVersion, version) >= 0) {
return { action: "reject", reason: "The npm channel already points to this or a newer version." };
}
return { action: "publish" };
}
if (!integrityMatches) {
return { action: "reject", reason: "The existing npm version has different package integrity." };
}
if (channelVersion === null || compareReleaseVersions(channelVersion, version) < 0) {
return { action: "advance-tag" };
}
return { action: "verify" };
}
module.exports = { compareReleaseVersions, decideNpmPublish, isNpmVersionMissing };

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { compareReleaseVersions, decideNpmPublish, isNpmVersionMissing } = require("./release-publish-policy");
describe("compareReleaseVersions", () => {
it("orders beta versions before their stable release", () => {
assert.equal(compareReleaseVersions("1.2.3-beta.4", "1.2.3"), -1);
assert.equal(compareReleaseVersions("1.2.4-beta.0", "1.2.3"), 1);
});
});
describe("isNpmVersionMissing", () => {
it("accepts only npm's explicit not-found error", () => {
assert.equal(isNpmVersionMissing("npm error code E404\nnpm error 404 Not Found"), true);
assert.equal(isNpmVersionMissing("npm error code ETIMEDOUT"), false);
assert.equal(isNpmVersionMissing(""), false);
});
});
describe("decideNpmPublish", () => {
it("publishes a missing version only when it cannot move a channel backwards", () => {
assert.deepEqual(
decideNpmPublish({ version: "1.2.3", versionExists: false, integrityMatches: false, channelVersion: "1.2.2" }),
{ action: "publish" },
);
assert.deepEqual(
decideNpmPublish({ version: "1.2.3", versionExists: false, integrityMatches: false, channelVersion: "1.2.4" }),
{ action: "reject", reason: "The npm channel already points to this or a newer version." },
);
});
it("keeps an already advanced channel and advances only an older channel", () => {
assert.deepEqual(
decideNpmPublish({ version: "1.2.3-beta.4", versionExists: true, integrityMatches: true, channelVersion: "1.2.3-beta.5" }),
{ action: "verify" },
);
assert.deepEqual(
decideNpmPublish({ version: "1.2.3-beta.4", versionExists: true, integrityMatches: true, channelVersion: "1.2.3-beta.3" }),
{ action: "advance-tag" },
);
});
it("rejects an existing version with different integrity", () => {
assert.deepEqual(
decideNpmPublish({ version: "1.2.3", versionExists: true, integrityMatches: false, channelVersion: "1.2.3" }),
{ action: "reject", reason: "The existing npm version has different package integrity." },
);
});
});

190
scripts/release-workflow.test.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
# This verifies the release workflow's declarative contract. The shell commands
# inside individual steps are exercised by the beta release rehearsal instead.
ruby -ropen3 -ryaml <<'RUBY'
workflow = YAML.load_file(".github/workflows/release.yml")
goreleaser = YAML.load_file(".goreleaser.yml")
def fail(message)
abort("release workflow contract: #{message}")
end
def expect_equal(actual, expected, description)
return if actual == expected
fail("#{description}; expected #{expected.inspect}, got #{actual.inspect}")
end
def scalar_values(value)
case value
when Hash then value.values.flat_map { |item| scalar_values(item) }
when Array then value.flat_map { |item| scalar_values(item) }
else [value]
end
end
def action_references(value)
case value
when Hash
value.flat_map { |key, item| key == "uses" ? [item] : action_references(item) }
when Array
value.flat_map { |item| action_references(item) }
else
[]
end
end
jobs = workflow.fetch("jobs")
jobs.each do |job_name, job|
job.fetch("steps", []).each do |step|
run = step["run"]
next unless run.is_a?(String)
_stdout, stderr, status = Open3.capture3("bash", "-n", stdin_data: run)
fail("#{job_name}/#{step["name"]} has invalid bash syntax: #{stderr}") unless status.success?
end
end
expected_jobs = %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm retry-guidance]
expect_equal(jobs.keys.sort, expected_jobs.sort, "release jobs")
expect_equal(workflow.fetch("concurrency"), {
"group" => "release-${{ github.ref_name }}",
"cancel-in-progress" => false,
}, "release concurrency")
expected_needs = {
"preflight" => nil,
"build-sign-notarize" => "preflight",
"create-draft-release" => %w[preflight build-sign-notarize],
"verify-macos" => %w[preflight build-sign-notarize create-draft-release],
"publish-github" => %w[preflight create-draft-release verify-macos],
"publish-npm" => %w[preflight build-sign-notarize publish-github],
"retry-guidance" => %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm],
}
expected_needs.each do |job_name, needs|
expect_equal(jobs.fetch(job_name)["needs"], needs, "#{job_name} dependencies")
end
expected_permissions = {
"preflight" => { "contents" => "read" },
"build-sign-notarize" => { "contents" => "read" },
"create-draft-release" => { "contents" => "write" },
"verify-macos" => { "contents" => "read" },
"publish-github" => { "contents" => "write" },
"publish-npm" => { "contents" => "read", "id-token" => "write" },
"retry-guidance" => { "contents" => "read" },
}
expected_permissions.each do |job_name, permissions|
expect_equal(jobs.fetch(job_name)["permissions"], permissions, "#{job_name} permissions")
end
expected_timeouts = {
"build-sign-notarize" => 45,
"create-draft-release" => 15,
"verify-macos" => 20,
"publish-github" => 15,
"publish-npm" => 15,
}
expected_timeouts.each do |job_name, timeout|
expect_equal(jobs.fetch(job_name)["timeout-minutes"], timeout, "#{job_name} timeout")
end
expect_equal(jobs.fetch("build-sign-notarize").fetch("environment"), "npm-production", "signing approval environment")
fail("publish-npm must not request a second Environment approval") if jobs.fetch("publish-npm").key?("environment")
expect_equal(jobs.fetch("publish-npm").fetch("concurrency"), {
"group" => "npm-release-${{ needs.preflight.outputs.channel }}",
"queue" => "max",
"cancel-in-progress" => false,
}, "npm publication concurrency")
retry_guidance = jobs.fetch("retry-guidance")
retry_condition = "${{ always() && (needs.preflight.result == 'failure' || needs.build-sign-notarize.result == 'failure' || needs.create-draft-release.result == 'failure' || needs.verify-macos.result == 'failure' || needs.publish-github.result == 'failure' || needs.publish-npm.result == 'failure') }}"
expect_equal(retry_guidance.fetch("if"), retry_condition, "retry guidance failure condition")
expect_equal(retry_guidance.fetch("runs-on"), "ubuntu-22.04", "retry guidance runner")
retry_steps = retry_guidance.fetch("steps")
expect_equal(retry_steps.length, 1, "number of retry guidance steps")
retry_step = retry_steps.first
expect_equal(retry_step.fetch("name"), "Write retry guidance", "retry guidance step name")
fail("retry guidance must write to the GitHub step summary") unless retry_step.fetch("run").include?("GITHUB_STEP_SUMMARY")
signing_references = %w[
secrets.MACOS_SIGN_P12
secrets.MACOS_SIGN_PASSWORD
secrets.MACOS_NOTARY_KEY
vars.MACOS_NOTARY_KEY_ID
vars.MACOS_NOTARY_ISSUER_ID
]
team_reference = "vars.MACOS_TEAM_ID"
jobs.each do |job_name, job|
references = scalar_values(job).grep(String).flat_map do |value|
(signing_references + [team_reference]).select { |reference| value.include?(reference) }
end.uniq.sort
expected_references = case job_name
when "build-sign-notarize" then signing_references + [team_reference]
when "verify-macos" then [team_reference]
else []
end
expect_equal(
references,
expected_references.sort,
"#{job_name} Apple credential scope",
)
end
macos = jobs.fetch("verify-macos")
expect_equal(macos.fetch("strategy").fetch("matrix").fetch("include"), [
{ "runner" => "macos-15-intel", "arch" => "amd64" },
{ "runner" => "macos-15", "arch" => "arm64" },
], "macOS verification matrix")
expect_equal(macos.fetch("runs-on"), "${{ matrix.runner }}", "macOS matrix runner")
macos_verify_step = macos.fetch("steps").find { |step| step["name"] == "Verify notarized macOS binary" }
macos_verify_run = macos_verify_step&.fetch("run", nil)
macos_download_step = macos.fetch("steps").find { |step| step["name"] == "Download release candidate" }
fail("verify-macos must download the build candidate artifact") unless macos_download_step&.fetch("uses", nil)&.start_with?("actions/download-artifact@")
fail("verify-macos must not download mutable Draft Release assets") if macos_verify_run&.include?("gh release download")
fail("verify-macos must verify notarization through codesign") unless macos_verify_run&.include?("--check-notarization -R='notarized'")
draft_step = jobs.fetch("create-draft-release").fetch("steps").find { |step| step["name"] == "Create or reuse Draft Release" }
draft_run = draft_step&.fetch("run", nil)
fail("Draft Release creation must write generated release notes") unless draft_run&.include?("--notes-file")
fail("Draft Release reuse must validate target commit and prerelease state") unless draft_run&.include?("targetCommitish") && draft_run.include?("isPrerelease")
fail("Draft Release creation must derive its target from the existing tag") if draft_run&.include?("--target")
github_steps = jobs.fetch("publish-github").fetch("steps")
github_check = github_steps.find { |step| step["name"] == "Verify Draft assets match the candidate" }
fail("GitHub publication must verify Draft assets against the candidate") unless github_check&.fetch("run", nil)&.include?("release-candidate/checksums.txt")
npm_steps = jobs.fetch("publish-npm").fetch("steps")
pinned_npm = npm_steps.find { |step| step["name"] == "Install pinned npm" }
fail("publish-npm must install npm 11.16.0 for trusted publishing") unless pinned_npm&.fetch("run", nil) == "npm install --global npm@11.16.0"
publish_step = npm_steps.find { |step| step["name"] == "Publish or verify npm package" }
fail("publish-npm must explicitly pass the candidate tarball as a local path") unless publish_step&.fetch("run", nil).include?('npm publish "./$tgz"')
action_references(workflow).each do |reference|
fail("action is not pinned to a full commit SHA: #{reference}") unless reference.match?(%r{\A[^@]+@[0-9a-f]{40}\z})
end
notarize = goreleaser.fetch("notarize").fetch("macos")
expect_equal(notarize.length, 1, "number of macOS notarization configurations")
macos_notarize = notarize.first
expect_equal(macos_notarize.fetch("ids"), ["lark-cli"], "notarized build IDs")
expect_equal(macos_notarize.fetch("sign"), {
"certificate" => "{{ .Env.MACOS_SIGN_P12 }}",
"password" => "{{ .Env.MACOS_SIGN_PASSWORD }}",
}, "macOS signing inputs")
expect_equal(macos_notarize.fetch("notarize"), {
"issuer_id" => "{{ .Env.MACOS_NOTARY_ISSUER_ID }}",
"key_id" => "{{ .Env.MACOS_NOTARY_KEY_ID }}",
"key" => "{{ .Env.MACOS_NOTARY_KEY_PATH }}",
"wait" => true,
"timeout" => "20m",
}, "macOS notarization inputs")
puts "release workflow contract passed"
RUBY