mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
8 Commits
v1.0.78-be
...
test/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad25048d0 | ||
|
|
0897d84c5b | ||
|
|
a7262ef2fc | ||
|
|
dd8255d59d | ||
|
|
6aac6a1c4f | ||
|
|
f859de0490 | ||
|
|
4222f998d3 | ||
|
|
9b4077f3e9 |
52
.github/workflows/macos-release-rehearsal.yml
vendored
52
.github/workflows/macos-release-rehearsal.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: macOS Release Rehearsal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
check:
|
||||
description: Rehearsal check to run
|
||||
required: true
|
||||
default: preflight-rejects-mismatched-tag
|
||||
type: choice
|
||||
options:
|
||||
- preflight-rejects-mismatched-tag
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight-rejects-mismatched-tag:
|
||||
if: ${{ inputs.check == 'preflight-rejects-mismatched-tag' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Confirm mismatched tag is rejected
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set +e
|
||||
result="$(node scripts/release-preflight.js --tag v0.0.0-beta.999 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
(( status != 0 )) || { echo "Mismatched release tag was accepted." >&2; exit 1; }
|
||||
node - "$result" <<'NODE'
|
||||
const result = JSON.parse(process.argv[2]);
|
||||
if (result?.ok !== false || result?.error?.type !== "release_preflight") {
|
||||
throw new Error("preflight did not return the expected structured rejection");
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Record no-release boundary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## R1: preflight rejection"
|
||||
echo
|
||||
echo "The mismatched tag was rejected before any release operation."
|
||||
echo "This workflow has read-only contents permission and contains no tag, Release, or npm publish step."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
359
.github/workflows/release.yml
vendored
359
.github/workflows/release.yml
vendored
@@ -8,276 +8,97 @@ 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
|
||||
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
|
||||
- name: Validate tag and commit
|
||||
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" ]]; then
|
||||
git merge-base --is-ancestor "$head_sha" FETCH_HEAD || { echo "Tag ${TAG} is not contained in origin/main." >&2; exit 1; }
|
||||
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
|
||||
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:
|
||||
build-release:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
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 }}
|
||||
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: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: v2.17.1
|
||||
args: release --clean --skip=publish
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
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 }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- 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 }}
|
||||
- name: Include release checksums
|
||||
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/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
|
||||
- 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
|
||||
with:
|
||||
name: release-candidate-${{ github.run_id }}
|
||||
path: release-candidate/
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
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" --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]
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
@@ -285,100 +106,32 @@ jobs:
|
||||
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 }}
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
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
|
||||
(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"
|
||||
|
||||
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} |"
|
||||
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
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
|
||||
@@ -5,8 +5,7 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
- id: lark-cli
|
||||
binary: lark-cli
|
||||
- binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
@@ -20,27 +19,11 @@ 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
|
||||
formats: [zip]
|
||||
format: zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
|
||||
1
Makefile
1
Makefile
@@ -50,7 +50,6 @@ 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const { execFileSync } = require("child_process");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const VERSION = require("../package.json").version;
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
|
||||
@@ -37,26 +37,13 @@ const platform = PLATFORM_MAP[process.platform];
|
||||
const arch = ARCH_MAP[process.arch];
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const { archiveName, githubUrl: GITHUB_URL } = resolveReleaseAsset(
|
||||
VERSION,
|
||||
platform,
|
||||
arch
|
||||
);
|
||||
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 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,
|
||||
@@ -361,4 +348,4 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, resolveReleaseAsset, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };
|
||||
|
||||
@@ -9,36 +9,7 @@ const os = require("os");
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
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",
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
|
||||
|
||||
describe("getExpectedChecksum", () => {
|
||||
function makeTmpChecksums(content) {
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
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]*))?$/;
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function releaseChannelOf(value) {
|
||||
if (typeof value !== "string" || !RELEASE_VERSION_PATTERN.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.includes("-beta.") ? "beta" : "stable";
|
||||
function isStableVersion(value) {
|
||||
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
@@ -34,11 +31,11 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!releaseChannelOf(value)) {
|
||||
if (!isStableVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must be a Stable or Beta release version`,
|
||||
`${field} must be a stable release version in X.Y.Z form`,
|
||||
observed,
|
||||
"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.",
|
||||
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,15 +48,14 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
);
|
||||
}
|
||||
|
||||
const releaseChannel = releaseChannelOf(packageVersion);
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: { ...observed, releaseChannel } };
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !releaseChannelOf(tag.slice(1))) {
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use a Stable or Beta release form",
|
||||
"--tag must use the stable release form vX.Y.Z",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}; valid forms are vX.Y.Z and vX.Y.Z-beta.N.`,
|
||||
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,7 +67,7 @@ function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion, releaseChannel } };
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
@@ -86,7 +82,7 @@ function main() {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z[-beta.N]",
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
|
||||
@@ -35,80 +35,22 @@ describe("validateReleasePreflight", () => {
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("rejects non-stable or inconsistent package metadata", () => {
|
||||
const prerelease = metadata("1.2.3-beta.1");
|
||||
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));
|
||||
}
|
||||
@@ -117,22 +59,7 @@ describe("validateReleasePreflight", () => {
|
||||
it("rejects an invalid or mismatched release tag", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
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"]) {
|
||||
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/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 -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")
|
||||
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 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" => "write" },
|
||||
"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
|
||||
expect_equal(jobs.fetch("publish-npm").fetch("environment"), "npm-production", "npm publish environment")
|
||||
|
||||
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")
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
|
||||
const createHint = "verify --app-type is html, frontend or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
|
||||
|
||||
// AppsCreate creates a new app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
@@ -23,6 +23,7 @@ var AppsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
|
||||
`Example: lark-cli apps +create --name "工具页" --app-type frontend --description "纯前端工具"`,
|
||||
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
@@ -30,7 +31,7 @@ var AppsCreate = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "frontend", "full_stack"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -59,7 +60,7 @@ var AppsCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// --app-type is constrained to the lowercase enum (html / frontend / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
agent := envvars.AgentName()
|
||||
|
||||
@@ -363,3 +363,18 @@ func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCreate_AcceptsFrontend pins that --app-type frontend is a valid
|
||||
// enum value and flows through to the request body as "frontend" verbatim.
|
||||
func TestAppsCreate_AcceptsFrontend(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "frontend", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("frontend dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"app_type": "frontend"`) {
|
||||
t.Fatalf("expected app_type frontend in body, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.24-alpha.03a64f0"
|
||||
npmRegistry = "https://registry.npmmirror.com"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
@@ -86,6 +86,10 @@ var appTypePolicies = map[string]appTypePolicy{
|
||||
// no startup env vars to pull, no steering skills to sync, and no app sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
// frontend (vite-react, a buildable front-end app) is intentionally NOT
|
||||
// listed here: it takes the zero-value policy (install deps, pull env, sync
|
||||
// skills) like full_stack, since it needs a build step — it is not a static
|
||||
// HTML site and must not skip those steps.
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -438,6 +442,9 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
// --skip-install is appended per the app_type's policy (see appTypePolicy):
|
||||
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
|
||||
// install; others run it as usual.
|
||||
// appType is forwarded verbatim (including "frontend") — the CLI does not
|
||||
// translate the app type; mapping the app type to a concrete tech stack is the
|
||||
// downstream tool's responsibility.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
|
||||
at := appType
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Desc: "fuzzy match on app name"},
|
||||
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
|
||||
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type filter (html, frontend or full_stack)", Enum: []string{"html", "frontend", "full_stack"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
|
||||
// an app_id or a meta_token — the server resolves both. The server returns
|
||||
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
|
||||
// uppercase app_type values ("HTML", "FRONTEND", "FULL_STACK", "MODERN_HTML");
|
||||
// this function normalizes to lowercase. Returns an error when the API
|
||||
// is unavailable or the response is malformed — callers must not proceed
|
||||
// with a fallback type to avoid creating the wrong project scaffold.
|
||||
|
||||
@@ -63,22 +63,28 @@ lark-cli auth login --domain apps
|
||||
|
||||
新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。
|
||||
|
||||
**app_type 三类边界**(先判"要不要把数据存到服务端",再判"纯展示还是有交互"):
|
||||
|
||||
| 信号 | 判定 |
|
||||
|---|---|
|
||||
| 静态展示 / 单页 / PPT/deck / demo / 落地页 / 仪表盘 / UI mockup / 可交互原型 / 线框图 / 视觉探索 / 无后端状态 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
|
||||
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
|
||||
| 含数据库 / 后端持久化:登录 / 增删改查 / 报名·投票·站会存记录 / 多人协作 / 泛称"系统·工具"且明确要存数据 | `app_type=full_stack` |
|
||||
| 纯静态展示(给人"看"的物料,无 JS 交互):PPT/deck / demo / 落地页 / 海报 / UI mockup / 线框图 / 静态仪表盘 / 视觉探索 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
|
||||
| 有 JS 交互但无数据库(给人"用"的前端应用):可交互原型 / SPA / 表单校验 / 动态计算 / 调用外部 API / 泛称"工具·系统"但未明确要存数据 | `app_type=frontend`(**默认倾向**:用户未明确提出数据库需求时默认引导 frontend,不默认 full_stack) |
|
||||
| 类型模糊(尤其"要不要存数据"不清) | **追问**,话术偏向 frontend,例:"看起来是个前端应用,需要保存数据吗?";确认要存数据再转 full_stack,确认纯展示再转 html |
|
||||
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地开发,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
|
||||
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
|
||||
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
|
||||
| 修改已有 + 有云端偏好 | 云端会话;未表达偏好且非本地项目 → 默认本地;判不准先问 |
|
||||
|
||||
**类型升级**:`frontend` 应用后续需要数据库/后端能力时,本地 CLI 不提供类型升级;引导用户到云端会话(打开 `https://miaoda.feishu.cn/app/{app_id}`),用自然语言描述后端需求(如"给这个应用加登录和数据存储")即可触发升级,无需特殊指令。
|
||||
|
||||
## 发布态护栏
|
||||
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(仅 full_stack 应用):进应用编辑/开发态、管理与继续开发应用的入口。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
|
||||
- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html 和 full_stack 统一走 `+release-get`)。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(full_stack / frontend 应用):进应用编辑/开发态、管理与继续开发应用的入口,也是 frontend 升级为 full_stack 的入口(云端会话)。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
|
||||
- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html / frontend / full_stack 统一走 `+release-get`)。
|
||||
- html 应用的主链路是创意模式开发方式:按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 初始化仓库、在仓库内产出 HTML 及关联文件,并通过 git commit / git push / `+release-create` / `+release-get` 发布部署。任何 git 操作(clone / pull / push)报错时,先执行 `lark-cli apps +git-credential-init --app-id <app_id> --as user` 刷新本地 Git 凭证,再重试原 git 命令。如果刷新凭证也失败,**停止并向用户报告**:原始 git 错误、凭证刷新失败原因,以及是否可能是当前环境(操作系统、沙箱)限制导致(如 macOS Keychain 在沙箱中不可用、Linux 加密文件目录不可写等)。不要改走 `+html-publish`,也不要把 `+html-publish` 当作本地开发链路的 fallback。
|
||||
- 创意模式(html)应用的链接格式为 `https://{租户域名}/page/{meta_token}`,**开发态和发布态是同一个链接**(区别于 full_stack 应用两者分开)。此链接形似飞书文档链接。`+get --app-id <meta_token>` 可获取应用信息(含 `app_id`),`+get --app-id <app_id>` 可获取 `meta_token`。看到 `/page/xxx` 链接时,它是妙搭创意模式应用,不要当成飞书文档跳过。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--name`、`--app-type`。
|
||||
- app type 语义取值为 `html` / `full_stack`;CLI 会把输入归一成小写后校验。
|
||||
- app type 取值为小写 `html` / `frontend` / `full_stack`;框架按枚举精确校验(不做大小写归一),非法值直接报错。
|
||||
- 可选:`--description`、`--icon-url`。
|
||||
|
||||
## 示例
|
||||
@@ -17,6 +17,9 @@
|
||||
```bash
|
||||
lark-cli apps +create --name "客户调研问卷" --app-type html
|
||||
|
||||
lark-cli apps +create --name "JSON 格式化工具" --app-type frontend \
|
||||
--description "纯前端交互工具,无需数据库"
|
||||
|
||||
lark-cli apps +create --name "审批系统" --app-type full_stack \
|
||||
--description "部门审批系统,支持登录、提交申请、多级审批"
|
||||
|
||||
@@ -35,5 +38,5 @@ lark-cli apps +create --name "Demo" --app-type html --dry-run
|
||||
|
||||
创建后按用户路径继续:
|
||||
|
||||
- 本地应用开发(含 html 和 full_stack):读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
- 本地应用开发(含 html / frontend / full_stack):读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
- 云端 Agent 生成/迭代:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。
|
||||
|
||||
@@ -26,7 +26,7 @@ lark-cli apps +get --app-id app_xxx -q '.data.app.app_type'
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app_id` | string | 应用唯一标识 |
|
||||
| `app_type` | string | 应用类型(如 HTML、FULL_STACK、MODERN_HTML) |
|
||||
| `app_type` | string | 应用类型(如 HTML、FRONTEND、FULL_STACK、MODERN_HTML) |
|
||||
| `name` | string | 应用显示名称 |
|
||||
| `description` | string | 应用功能说明 |
|
||||
| `icon_url` | string | 应用图标 URL |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 支持 `--keyword` 按应用名模糊搜索。
|
||||
- `--ownership` 枚举:`all` / `mine` / `shared`(默认 `all` = 我创建的 + 共享给我的;`mine` = 仅我创建;`shared` = 仅共享给我)。
|
||||
- `--app-type` 枚举:`html` / `full_stack`。
|
||||
- `--app-type` 枚举:`html` / `frontend` / `full_stack`。
|
||||
- 分页:`--page-size` 默认 20,`--page-token` 传上一页 cursor。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# lark-apps 本地开发
|
||||
|
||||
适用:用户要把妙搭应用(full_stack 或 html)源码拉到本地,用本地 code agent/IDE 开发、调试数据库,再发布。
|
||||
适用:用户要把妙搭应用(full_stack、frontend 或 html)源码拉到本地,用本地 code agent/IDE 开发、再发布。其中调试数据库仅 full_stack 适用(frontend / html 无数据库)。
|
||||
|
||||
## 新建 vs 已有应用
|
||||
|
||||
@@ -36,6 +36,32 @@ git push origin sprint/default
|
||||
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
|
||||
```
|
||||
|
||||
### frontend
|
||||
|
||||
纯前端应用(vite-react,无数据库)。流程与 full_stack 基本一致——`+init` 装依赖、`npm run dev`、commit/push/release——差别是无 `+db-*` 调库步骤。后续需要数据库/后端能力时不在本地升级,按 SKILL.md「类型升级」引导到云端会话。
|
||||
|
||||
```bash
|
||||
# 新建 frontend 应用
|
||||
lark-cli apps +create --as user --name "JSON 格式化工具" --app-type frontend \
|
||||
--description "纯前端交互工具,无需数据库"
|
||||
|
||||
# 初始化本地仓库(--dir 取值见下方「领域规则」,勿照抄此处示例值)
|
||||
lark-cli apps +init --as user --app-id app_xxx --dir ./json-tool
|
||||
|
||||
# 进入仓库后按项目脚手架启动(vite-react)
|
||||
cd ./json-tool
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 开发完成后:提交本次改动 -> git push origin sprint/default -> +release-create
|
||||
git add <本次开发的文件>
|
||||
git commit -m "feat: ..."
|
||||
git push origin sprint/default
|
||||
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
|
||||
# 发布是异步的:用 +release-get 轮询到 status=finished 才算部署完成、拿到 online_url
|
||||
lark-cli apps +release-get --as user --app-id app_xxx --release-id <上一步返回的 release_id>
|
||||
```
|
||||
|
||||
### html
|
||||
|
||||
#### 首次开发(无 app,无代码)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 何时用
|
||||
|
||||
用于把应用的代码分支推进到发布流程(html 和 full_stack 统一走此入口)。
|
||||
用于把应用的代码分支推进到发布流程(html / frontend / full_stack 统一走此入口)。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
|
||||
Reference in New Issue
Block a user