mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
19 Commits
v1.0.78-be
...
v1.0.81-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e3612829 | ||
|
|
87583e796c | ||
|
|
6cda02460e | ||
|
|
0c7b36553e | ||
|
|
752e65f075 | ||
|
|
7eedaedcf9 | ||
|
|
e20e6d3c2a | ||
|
|
b18e9e2656 | ||
|
|
e845e5f3e4 | ||
|
|
fc60760114 | ||
|
|
2b68dca695 | ||
|
|
9b1e76e218 | ||
|
|
d8defec376 | ||
|
|
a1c7416735 | ||
|
|
6bbfd37617 | ||
|
|
aa3bf27cb1 | ||
|
|
2cbd5d69fe | ||
|
|
33d22835ab | ||
|
|
2f389980cf |
299
.github/workflows/release.yml
vendored
299
.github/workflows/release.yml
vendored
@@ -23,13 +23,25 @@ jobs:
|
||||
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'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Refuse to rebuild a published release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
- name: Validate protected release tag
|
||||
id: validate
|
||||
@@ -48,33 +60,49 @@ jobs:
|
||||
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; }
|
||||
git merge-base --is-ancestor "$head_sha" FETCH_HEAD || { echo "Tag ${TAG} is not contained in origin/main." >&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" "$GITHUB_OUTPUT" <<'NODE'
|
||||
node - "$preflight_file" "$head_sha" "$main_sha" "$source_in_main" "$GITHUB_OUTPUT" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const [file, sourceSha, output] = process.argv.slice(2);
|
||||
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: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
- 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
|
||||
@@ -83,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 }}
|
||||
@@ -99,13 +130,23 @@ jobs:
|
||||
done
|
||||
umask 077
|
||||
notary_key="$(mktemp "${RUNNER_TEMP}/macos-notary-key.XXXXXX")"
|
||||
printf '%s' "$MACOS_NOTARY_KEY" > "$notary_key"
|
||||
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@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
# v2.17.1 is the first tested version with this macOS notarization configuration.
|
||||
version: v2.17.1
|
||||
args: release --clean --skip=publish
|
||||
env:
|
||||
@@ -129,6 +170,7 @@ jobs:
|
||||
(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)"
|
||||
@@ -141,7 +183,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Upload release candidate
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: release-candidate-${{ github.run_id }}
|
||||
path: release-candidate/
|
||||
@@ -151,10 +193,11 @@ jobs:
|
||||
create-draft-release:
|
||||
needs: [preflight, build-sign-notarize]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -182,33 +225,77 @@ jobs:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$TAG" --json isDraft >/dev/null 2>&1; then
|
||||
if [[ "$(gh release view "$TAG" --json isDraft -q .isDraft)" != "true" ]]; then
|
||||
existing="$(mktemp -d "${RUNNER_TEMP}/published-release.XXXXXX")"
|
||||
trap 'rm -rf -- "$existing"' EXIT
|
||||
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"
|
||||
cmp --silent release-candidate/checksums.txt "$existing/checksums.txt" || { echo "Published Release checksums do not match the current candidate." >&2; exit 1; }
|
||||
(cd "$existing" && sha256sum --check checksums.txt)
|
||||
diff --brief \
|
||||
<(find release-candidate -maxdepth 1 -type f ! -name '*.tgz' -printf '%f\n' | sort) \
|
||||
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
|
||||
exit 0
|
||||
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" --target "$SOURCE_SHA" --title "$TAG" --draft)
|
||||
[[ "$PRERELEASE" != "true" ]] || args+=(--prerelease)
|
||||
gh release create "${args[@]}"
|
||||
gh release create "${args[@]}" --verify-tag --notes-file "$release_notes"
|
||||
verify_release_metadata "$(gh release view "$TAG" --json isDraft,isPrerelease,targetCommitish)"
|
||||
fi
|
||||
gh release upload "$TAG" release-candidate/*.tar.gz release-candidate/*.zip release-candidate/checksums.txt --clobber
|
||||
gh release edit "$TAG" --notes-file "$release_notes"
|
||||
gh release upload "$TAG" "${release_assets[@]}"
|
||||
diff --brief \
|
||||
<(find release-candidate -maxdepth 1 -type f ! -name '*.tgz' -printf '%f\n' | sort) \
|
||||
<(printf '%s\n' "${release_assets[@]##*/}" | sort) \
|
||||
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
|
||||
|
||||
verify-macos:
|
||||
needs: [preflight, create-draft-release]
|
||||
needs: [preflight, build-sign-notarize, create-draft-release]
|
||||
permissions:
|
||||
# Draft Release assets require repository write access to download.
|
||||
contents: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -218,48 +305,62 @@ jobs:
|
||||
- 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 }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MACOS_TEAM_ID: ${{ vars.MACOS_TEAM_ID }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
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"' EXIT
|
||||
gh release download "$TAG" --pattern "$archive" --pattern checksums.txt --dir "$work"
|
||||
awk -v archive="$archive" '$2 == archive { print }' "$work/checksums.txt" > "$work/checksum.txt"
|
||||
[[ "$(wc -l < "$work/checksum.txt" | tr -d '[:space:]')" == "1" ]] || { echo "checksums.txt must contain exactly one entry for ${archive}." >&2; exit 1; }
|
||||
(cd "$work" && shasum -a 256 -c checksum.txt)
|
||||
tar -xzf "$work/$archive" -C "$work"
|
||||
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)"
|
||||
grep -Eq '^Authority=Developer ID Application: .+' <<<"$details"
|
||||
grep -Fxq "TeamIdentifier=${MACOS_TEAM_ID}" <<<"$details"
|
||||
grep -Fq 'flags=0x10000(runtime)' <<<"$details"
|
||||
grep -Eq '^CodeDirectory .*flags=0x[0-9A-Fa-f]+[(][^)]*runtime[^)]*[)]' <<<"$details"
|
||||
grep -Eq '^Timestamp=.+' <<<"$details"
|
||||
spctl --assess --type execute --verbose=4 "$binary" 2>&1 | tee "$work/spctl.txt"
|
||||
grep -Fq 'source=Notarized Developer ID' "$work/spctl.txt"
|
||||
"$binary" --version | grep -Fq "$VERSION"
|
||||
codesign --verify --strict --verbose=4 --check-notarization -R='notarized' "$binary"
|
||||
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]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: 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: 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 }}
|
||||
@@ -268,6 +369,40 @@ jobs:
|
||||
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: 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 }}
|
||||
@@ -277,12 +412,16 @@ jobs:
|
||||
publish-npm:
|
||||
needs: [preflight, build-sign-notarize, publish-github]
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
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
|
||||
@@ -329,14 +468,59 @@ jobs:
|
||||
)"
|
||||
dist_tag=latest
|
||||
[[ "$CHANNEL" != "beta" ]] || dist_tag=beta
|
||||
if npm view "@larksuite/cli@${VERSION}" version --json >/dev/null 2>&1; then
|
||||
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" ]] || { echo "Existing npm version has different package integrity." >&2; exit 1; }
|
||||
current="$(npm view @larksuite/cli "dist-tags.${dist_tag}" --json | tr -d '"[:space:]')"
|
||||
[[ "$current" == "$VERSION" ]] || { echo "Existing npm version is not assigned to ${dist_tag}; repair registry state manually." >&2; exit 1; }
|
||||
else
|
||||
npm publish "$tgz" --access public --provenance --tag "$dist_tag"
|
||||
[[ "$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
|
||||
|
||||
retry-guidance:
|
||||
needs: [preflight, build-sign-notarize, create-draft-release, verify-macos, publish-github, publish-npm]
|
||||
@@ -368,15 +552,14 @@ jobs:
|
||||
echo "| verify-macos | ${VERIFY_RESULT} |"
|
||||
echo "| publish-github | ${GITHUB_RESULT} |"
|
||||
echo "| publish-npm | ${NPM_RESULT} |"
|
||||
cat <<'EOF'
|
||||
|
||||
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. 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.
|
||||
EOF
|
||||
printf '%s\n' \
|
||||
'' \
|
||||
'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 → 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"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
- id: lark-cli
|
||||
binary: lark-cli
|
||||
|
||||
2
Makefile
2
Makefile
@@ -52,7 +52,7 @@ script-test:
|
||||
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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.81-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.81-beta.2",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.81-beta.2",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -18,6 +18,34 @@ 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 = 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 } };
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -107,6 +135,6 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
module.exports = { validateReleasePreflight, validateReleaseSourcePolicy };
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
@@ -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 {
|
||||
@@ -137,3 +137,36 @@ describe("validateReleasePreflight", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 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/);
|
||||
});
|
||||
});
|
||||
|
||||
59
scripts/release-publish-policy.js
Normal file
59
scripts/release-publish-policy.js
Normal 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 };
|
||||
53
scripts/release-publish-policy.test.js
Normal file
53
scripts/release-publish-policy.test.js
Normal 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." },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,17 @@ 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 -ryaml <<'RUBY'
|
||||
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)
|
||||
@@ -39,6 +39,16 @@ def action_references(value)
|
||||
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)
|
||||
contract_error("#{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")
|
||||
|
||||
@@ -51,7 +61,7 @@ expected_needs = {
|
||||
"preflight" => nil,
|
||||
"build-sign-notarize" => "preflight",
|
||||
"create-draft-release" => %w[preflight build-sign-notarize],
|
||||
"verify-macos" => %w[preflight create-draft-release],
|
||||
"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],
|
||||
@@ -64,7 +74,7 @@ expected_permissions = {
|
||||
"preflight" => { "contents" => "read" },
|
||||
"build-sign-notarize" => { "contents" => "read" },
|
||||
"create-draft-release" => { "contents" => "write" },
|
||||
"verify-macos" => { "contents" => "write" },
|
||||
"verify-macos" => { "contents" => "read" },
|
||||
"publish-github" => { "contents" => "write" },
|
||||
"publish-npm" => { "contents" => "read", "id-token" => "write" },
|
||||
"retry-guidance" => { "contents" => "read" },
|
||||
@@ -72,7 +82,25 @@ expected_permissions = {
|
||||
expected_permissions.each do |job_name, permissions|
|
||||
expect_equal(jobs.fetch(job_name)["permissions"], permissions, "#{job_name} permissions")
|
||||
end
|
||||
expect_equal(jobs.fetch("publish-npm").fetch("environment"), "npm-production", "npm publish environment")
|
||||
|
||||
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")
|
||||
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",
|
||||
"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') }}"
|
||||
@@ -83,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
|
||||
@@ -109,24 +140,60 @@ 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" },
|
||||
{ "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" }
|
||||
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)
|
||||
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" }
|
||||
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" }
|
||||
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 }}",
|
||||
@@ -139,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
|
||||
|
||||
Reference in New Issue
Block a user