mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
385 lines
18 KiB
YAML
385 lines
18 KiB
YAML
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
|
|
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
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
|
with:
|
|
node-version: '22.14.0'
|
|
|
|
- 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; }
|
|
if [[ ! "$TAG" =~ ^v1\.0\.78-beta\.[1-7]$ ]]; then
|
|
git merge-base --is-ancestor "$head_sha" FETCH_HEAD || { echo "Tag ${TAG} is not contained in origin/main." >&2; exit 1; }
|
|
fi
|
|
|
|
node - "$preflight_file" "$head_sha" "$GITHUB_OUTPUT" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const [file, sourceSha, 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;
|
|
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
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
with:
|
|
go-version: '1.23'
|
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
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: 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 }}
|
|
TAG: ${{ github.ref_name }}
|
|
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")"
|
|
printf '%s' "$MACOS_NOTARY_KEY" > "$notary_key"
|
|
chmod 0600 "$notary_key"
|
|
printf 'MACOS_NOTARY_KEY_PATH=%s\n' "$notary_key" >> "$GITHUB_ENV"
|
|
|
|
- name: Run GoReleaser
|
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
|
with:
|
|
version: v2.17.1
|
|
args: release --clean --skip=publish
|
|
env:
|
|
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
|
|
MACOS_NOTARY_KEY_ID: ${{ github.ref_name == 'v1.0.78-beta.5' && '0000000000' || vars.MACOS_NOTARY_KEY_ID }}
|
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
|
|
|
- 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
|
|
(cd dist && sha256sum --check checksums.txt)
|
|
mkdir release-candidate
|
|
cp dist/*.tar.gz dist/*.zip dist/checksums.txt release-candidate/
|
|
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: Upload release candidate
|
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
with:
|
|
name: release-candidate-${{ github.run_id }}
|
|
path: release-candidate/
|
|
if-no-files-found: error
|
|
overwrite: true
|
|
|
|
create-draft-release:
|
|
needs: [preflight, build-sign-notarize]
|
|
runs-on: ubuntu-22.04
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
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 }}
|
|
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
|
|
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
|
|
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
|
|
fi
|
|
else
|
|
args=("$TAG" --target "$SOURCE_SHA" --title "$TAG" --draft)
|
|
[[ "$PRERELEASE" != "true" ]] || args+=(--prerelease)
|
|
gh release create "${args[@]}"
|
|
fi
|
|
gh release upload "$TAG" release-candidate/*.tar.gz release-candidate/*.zip release-candidate/checksums.txt --clobber
|
|
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)
|
|
|
|
verify-macos:
|
|
needs: [preflight, create-draft-release]
|
|
permissions:
|
|
# Draft Release assets require repository write access to download.
|
|
contents: write
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- runner: macos-15-intel
|
|
arch: amd64
|
|
- runner: macos-15
|
|
arch: arm64
|
|
runs-on: ${{ matrix.runner }}
|
|
steps:
|
|
- 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"
|
|
work="$(mktemp -d "${RUNNER_TEMP}/macos-release.XXXXXX")"
|
|
trap 'rm -rf -- "$work"' EXIT
|
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --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"
|
|
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 '^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"
|
|
|
|
publish-github:
|
|
needs: [preflight, create-draft-release, verify-macos]
|
|
runs-on: ubuntu-22.04
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
ref: ${{ needs.preflight.outputs.source_sha }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- 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: 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
|
|
environment: npm-production
|
|
permissions:
|
|
contents: read
|
|
id-token: write
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
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: 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
|
|
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
|
|
if npm view "@larksuite/cli@${VERSION}" version --json >/dev/null 2>&1; then
|
|
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"
|
|
fi
|
|
|
|
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. 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"
|