Compare commits

..

8 Commits

Author SHA1 Message Date
guokexin.02
fdfac79ba7 Revert "test: prepare beta release rehearsal"
This reverts commit 16e3612829.
2026-07-31 12:36:59 +08:00
guokexin.02
16e3612829 test: prepare beta release rehearsal 2026-07-31 11:52:09 +08:00
guokexin.02
87583e796c fix: harden release publication safeguards 2026-07-31 11:45:05 +08:00
guokexin.02
6cda02460e Revert "test: prepare beta release rehearsal"
This reverts commit 0c7b36553e.
2026-07-31 11:15:59 +08:00
guokexin.02
0c7b36553e test: prepare beta release rehearsal 2026-07-31 11:02:31 +08:00
guokexin.02
752e65f075 fix: tighten release workflow verification 2026-07-31 11:01:06 +08:00
guokexin.02
7eedaedcf9 fix: harden release draft verification 2026-07-31 10:57:30 +08:00
guokexin.02
e20e6d3c2a chore: restore release version 2026-07-30 20:44:41 +08:00
7 changed files with 81 additions and 42 deletions

View File

@@ -30,6 +30,7 @@ jobs:
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
package-manager-cache: false
- name: Refuse to rebuild a published release
env:
@@ -110,6 +111,9 @@ jobs:
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Fetch build metadata
run: python3 scripts/fetch_meta.py
- name: Prepare Apple notarization key
env:
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
@@ -217,7 +221,6 @@ jobs:
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: |
@@ -284,11 +287,6 @@ jobs:
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) \
@@ -318,7 +316,6 @@ jobs:
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
@@ -334,13 +331,13 @@ jobs:
[[ -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 '^CodeDirectory .*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"
escaped_version="${VERSION//./\\.}"
"$binary" --version | grep -Eq "(^|[^0-9A-Za-z.])${escaped_version}([^0-9A-Za-z.]|$)"
publish-github:
needs: [preflight, create-draft-release, verify-macos]
@@ -354,6 +351,11 @@ jobs:
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: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -381,6 +383,26 @@ jobs:
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: Refuse GitHub publication if npm channel is newer
env:
CHANNEL: ${{ needs.preflight.outputs.channel }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
dist_tag=latest
[[ "$CHANNEL" != "beta" ]] || dist_tag=beta
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 before GitHub publication." >&2
exit 1
fi
[[ -n "$current" && "$current" != "null" ]] || exit 0
node - "$VERSION" "$current" "$dist_tag" <<'NODE'
const { compareReleaseVersions } = require("./scripts/release-publish-policy");
const [version, current, distTag] = process.argv.slice(2);
if (compareReleaseVersions(current, version) >= 0) {
throw new Error(`npm ${distTag} already points to ${current}; retain no GitHub Release for ${version}.`);
}
NODE
- name: Publish verified Draft Release
env:
GH_TOKEN: ${{ github.token }}
@@ -532,12 +554,12 @@ jobs:
echo "| publish-npm | ${NPM_RESULT} |"
printf '%s\n' \
'' \
'Select the recovery action from the failed-step diagnosis:' \
'Select the recovery action from the failed-step diagnosis. If a Draft or public Release already exists, use **Re-run failed jobs**, not **Re-run all jobs**:' \
'' \
'- **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.'
'- **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 → delete the Draft, then retry build.' \
'- **verify-macos:** runner or network failure → retry only the failed matrix child. Checksum, signing, notarization, or runtime failure → delete the Draft, then retry build.' \
'- **publish-github:** GitHub publish network failure → retry GitHub publish. If the npm channel is already newer, delete the retained Draft Release and publish a higher version. Install issue → delete the Draft, then 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. If the npm channel is already newer, delete the public GitHub Release and publish a higher version.'
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,9 +1,5 @@
version: 2
before:
hooks:
- python3 scripts/fetch_meta.py
builds:
- id: lark-cli
binary: lark-cli

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.79-beta.3",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.79-beta.3",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -33,8 +33,8 @@ function validateReleaseSourcePolicy(releaseChannel, sourceSha, mainSha, sourceI
}
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."
const warning = sourceInMain
? "Beta release tag points to a commit already contained in origin/main; check whether an unintended beta version was merged into main."
: null;
return { ok: true, data: { warning } };
}

View File

@@ -163,8 +163,8 @@ describe("validateReleaseSourcePolicy", () => {
);
});
it("warns but accepts a beta release tag pointing to the current main head", () => {
const result = validateReleaseSourcePolicy("beta", mainSha, mainSha, true);
it("warns but accepts a beta release tag pointing to a commit already in main", () => {
const result = validateReleaseSourcePolicy("beta", betaSha, mainSha, true);
assert.equal(result.ok, true);
assert.match(result.data.warning, /unintended beta version was merged into main/);

View File

@@ -10,13 +10,13 @@ ruby -ropen3 -ryaml <<'RUBY'
workflow = YAML.load_file(".github/workflows/release.yml")
goreleaser = YAML.load_file(".goreleaser.yml")
def fail(message)
def contract_error(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}")
contract_error("#{description}; expected #{expected.inspect}, got #{actual.inspect}")
end
def scalar_values(value)
@@ -45,7 +45,7 @@ jobs.each do |job_name, job|
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?
contract_error("#{job_name}/#{step["name"]} has invalid bash syntax: #{stderr}") unless status.success?
end
end
@@ -95,7 +95,7 @@ expected_timeouts.each do |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")
contract_error("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",
@@ -111,7 +111,10 @@ 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")
contract_error("retry guidance must write to the GitHub step summary") unless retry_step.fetch("run").include?("GITHUB_STEP_SUMMARY")
contract_error("retry guidance must direct recoveries to failed-job retries") unless retry_step.fetch("run").include?("Re-run failed jobs")
contract_error("retry guidance must explain Draft cleanup before a rebuild") unless retry_step.fetch("run").include?("delete the Draft, then retry build")
contract_error("retry guidance must explain public Release cleanup after npm policy rejection") unless retry_step.fetch("run").include?("delete the public GitHub Release")
signing_references = %w[
secrets.MACOS_SIGN_P12
@@ -137,6 +140,12 @@ jobs.each do |job_name, job|
)
end
build_steps = jobs.fetch("build-sign-notarize").fetch("steps")
fetch_metadata_index = build_steps.index { |step| step["name"] == "Fetch build metadata" }
prepare_key_index = build_steps.index { |step| step["name"] == "Prepare Apple notarization key" }
contract_error("build metadata must be fetched before Apple credentials are prepared") unless fetch_metadata_index && prepare_key_index && fetch_metadata_index < prepare_key_index
contract_error("build metadata must be fetched outside GoReleaser hooks") if goreleaser.dig("before", "hooks")&.include?("python3 scripts/fetch_meta.py")
macos = jobs.fetch("verify-macos")
expect_equal(macos.fetch("strategy").fetch("matrix").fetch("include"), [
{ "runner" => "macos-15-intel", "arch" => "amd64" },
@@ -146,34 +155,45 @@ expect_equal(macos.fetch("runs-on"), "${{ matrix.runner }}", "macOS matrix runne
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'")
contract_error("verify-macos must download the build candidate artifact") unless macos_download_step&.fetch("uses", nil)&.start_with?("actions/download-artifact@")
contract_error("verify-macos must not download mutable Draft Release assets") if macos_verify_run&.include?("gh release download")
contract_error("verify-macos must verify notarization through codesign") unless macos_verify_run&.include?("--check-notarization -R='notarized'")
contract_error("verify-macos must check Developer ID authority") unless macos_verify_run&.include?("^Authority=Developer ID Application: .+")
contract_error("verify-macos must check the expected Team ID") unless macos_verify_run&.include?("TeamIdentifier=${MACOS_TEAM_ID}")
contract_error("verify-macos must detect hardened runtime in CodeDirectory metadata") unless macos_verify_run&.include?("^CodeDirectory .*flags=0x")
contract_error("verify-macos must check the signing timestamp") unless macos_verify_run&.include?("^Timestamp=.+")
contract_error("verify-macos must match the complete release version") unless macos_verify_run&.include?("escaped_version")
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 target the validated source commit") unless draft_run&.include?("--target \"$SOURCE_SHA\"")
fail("Draft Release creation must require the existing remote tag") unless draft_run&.include?("--verify-tag")
contract_error("Draft Release creation must write generated release notes") unless draft_run&.include?("--notes-file")
contract_error("Draft Release reuse must validate target commit and prerelease state") unless draft_run&.include?("targetCommitish") && draft_run.include?("isPrerelease")
contract_error("Draft Release creation must target the validated source commit") unless draft_run&.include?("--target \"$SOURCE_SHA\"")
contract_error("Draft Release creation must require the existing remote tag") unless draft_run&.include?("--verify-tag")
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")
contract_error("GitHub publication must verify Draft assets against the candidate") unless github_check&.fetch("run", nil)&.include?("release-candidate/checksums.txt")
github_npm_guard = github_steps.find { |step| step["name"] == "Refuse GitHub publication if npm channel is newer" }
contract_error("GitHub publication must refuse a version behind the npm channel") unless github_npm_guard&.fetch("run", nil)&.include?("compareReleaseVersions")
contract_error("GitHub publication must query the matching npm dist-tag") unless github_npm_guard&.fetch("run", nil)&.include?("dist-tags.${dist_tag}")
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"
contract_error("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"')
contract_error("publish-npm must explicitly pass the candidate tarball as a local path") unless publish_step&.fetch("run", nil).include?('npm publish "./$tgz"')
contract_error("publish-npm must publish provenance under the selected channel tag") unless publish_step&.fetch("run", nil).include?('--provenance --tag "$dist_tag"')
contract_error("beta releases must publish under the beta dist-tag") unless publish_step&.fetch("run", nil).include?('[[ "$CHANNEL" != "beta" ]] || dist_tag=beta')
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})
contract_error("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("enabled"), '{{ isEnvSet "MACOS_SIGN_P12" }}', "macOS notarization enablement")
expect_equal(macos_notarize.fetch("ids"), ["lark-cli"], "notarized build IDs")
expect_equal(macos_notarize.fetch("sign"), {
"certificate" => "{{ .Env.MACOS_SIGN_P12 }}",
@@ -186,6 +206,7 @@ expect_equal(macos_notarize.fetch("notarize"), {
"wait" => true,
"timeout" => "20m",
}, "macOS notarization inputs")
contract_error("GoReleaser must build a darwin release artifact") unless goreleaser.fetch("builds").any? { |build| build.fetch("goos").include?("darwin") }
puts "release workflow contract passed"
RUBY