mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4dfe1b7dc0
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities. Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication. Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
664 lines
29 KiB
YAML
664 lines
29 KiB
YAML
name: CI
|
|
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
types: [opened, synchronize, reopened, edited]
|
|
workflow_dispatch:
|
|
|
|
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
|
# newest run for a pull request; push and manual runs use a unique run ID.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
|
|
jobs:
|
|
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
|
fast-gate:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Build
|
|
run: go build ./...
|
|
- name: Vet
|
|
run: go vet ./...
|
|
- name: Check formatting
|
|
run: |
|
|
unformatted=$(gofmt -l .)
|
|
if [ -n "$unformatted" ]; then
|
|
echo "$unformatted"
|
|
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
|
|
exit 1
|
|
fi
|
|
- name: Check go.mod tidiness
|
|
run: |
|
|
go mod tidy
|
|
if ! git diff --quiet go.mod go.sum; then
|
|
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
|
git diff go.mod go.sum
|
|
exit 1
|
|
fi
|
|
|
|
plugin-integration:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
# No fetch_meta: the git-archive clean tree must embed only the
|
|
# committed meta_data stub (reproduces the bare-module customer state).
|
|
- name: Run plugin-integration L4 tests
|
|
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
|
|
|
sidecar-integration:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- name: Run sidecar tag build + HMAC round-trip
|
|
run: make sidecar-test
|
|
|
|
extended-integration:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Build both editions and verify identity
|
|
run: |
|
|
set -euo pipefail
|
|
go build -o /tmp/lark-cli-standard .
|
|
go build -tags extended -o /tmp/lark-cli-extended .
|
|
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
|
|
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
|
|
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
|
|
- name: Cross-compile Extended platform-specific security code
|
|
run: |
|
|
set -euo pipefail
|
|
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
|
|
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
|
|
- name: Verify edition source isolation
|
|
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
|
|
- name: Run Extended tests
|
|
run: make extended-test
|
|
|
|
extended-platform-security:
|
|
needs: fast-gate
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [macos-latest, windows-latest]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- name: Run native helper isolation and path trust tests
|
|
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
|
|
|
|
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
|
unit-test:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Run tests
|
|
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
|
|
|
lint:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Resolve changed-from baseline
|
|
env:
|
|
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
|
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
|
- name: Run golangci-lint
|
|
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
|
- name: Run source-contract lint guards (lintcheck)
|
|
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
|
- name: Run lint module tests
|
|
run: go test -C lint ./... -count=1
|
|
|
|
script-test:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
with:
|
|
node-version: '22'
|
|
- name: Run script tests
|
|
run: make script-test
|
|
- name: Install GoReleaser
|
|
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
|
with:
|
|
version: '~> v2'
|
|
install-only: true
|
|
- name: Validate GoReleaser configuration
|
|
run: goreleaser check
|
|
- name: Check Extended installer syntax
|
|
shell: pwsh
|
|
run: |
|
|
sh -n scripts/install-extended.sh
|
|
$tokens = $null
|
|
$errors = $null
|
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
|
(Resolve-Path scripts/install-extended.ps1),
|
|
[ref]$tokens,
|
|
[ref]$errors
|
|
) | Out-Null
|
|
if ($errors.Count -ne 0) {
|
|
$errors | ForEach-Object { Write-Error $_ }
|
|
exit 1
|
|
}
|
|
|
|
deterministic-gate:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Resolve changed-from baseline
|
|
env:
|
|
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
|
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
|
- name: Write public content metadata
|
|
if: ${{ github.event_name == 'pull_request' }}
|
|
env:
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
PR_BRANCH: ${{ github.head_ref }}
|
|
run: |
|
|
mkdir -p .tmp/quality-gate
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
|
json.dump({
|
|
"title": os.environ.get("PR_TITLE", ""),
|
|
"body": os.environ.get("PR_BODY", ""),
|
|
"branch": os.environ.get("PR_BRANCH", ""),
|
|
}, f)
|
|
f.write("\n")
|
|
PY
|
|
- name: Run CLI deterministic gate
|
|
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
|
- name: Upload quality gate facts
|
|
if: ${{ always() && github.event_name == 'pull_request' }}
|
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
with:
|
|
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
|
|
path: .tmp/quality-gate/facts.json
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
coverage:
|
|
needs: fast-gate
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Run tests with coverage
|
|
run: |
|
|
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
|
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
|
# second time here — and, crucially, so an observe-only suite's failure
|
|
# can never block merges through coverage's spot in the results loop.
|
|
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
|
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
|
|
# Extended implementation files are selected by build tags and would
|
|
# otherwise be absent from the uploaded report. Their race-enabled
|
|
# suite runs in extended-integration; this pass contributes coverage.
|
|
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
|
|
- name: Upload coverage to Codecov
|
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
|
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
|
with:
|
|
files: coverage-standard.txt,coverage-extended.txt
|
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
- name: Check coverage threshold
|
|
run: |
|
|
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
|
|
threshold=40
|
|
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
|
if (( $(echo "$total < $threshold" | bc -l) )); then
|
|
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
|
|
exit 1
|
|
fi
|
|
- name: Coverage summary
|
|
if: ${{ !cancelled() }}
|
|
run: |
|
|
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
report_coverage() {
|
|
profile="$1"
|
|
label="$2"
|
|
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
if [ ! -f "$profile" ]; then
|
|
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
return
|
|
fi
|
|
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
|
|
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
}
|
|
report_coverage coverage-standard.txt Standard
|
|
report_coverage coverage-extended.txt Extended
|
|
|
|
deadcode:
|
|
needs: fast-gate
|
|
if: ${{ github.event_name == 'pull_request' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Dead code check (incremental)
|
|
run: |
|
|
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
|
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
|
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
|
grep -v '^go: ' | \
|
|
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
|
|
|
# Analyze base branch via worktree
|
|
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
|
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
|
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
|
grep -v '^go: ' | \
|
|
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
|
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
|
git worktree remove -f /tmp/dc-base 2>/dev/null || true
|
|
exit 0
|
|
}
|
|
git worktree remove -f /tmp/dc-base
|
|
|
|
# Only new dead code blocks the PR
|
|
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
|
|
if [ -s /tmp/dc-new.txt ]; then
|
|
echo "::group::New dead code"
|
|
cat /tmp/dc-new.txt
|
|
echo "::endgroup::"
|
|
echo "::error::New dead code detected — remove unreachable functions before merging"
|
|
exit 1
|
|
fi
|
|
echo "No new dead code introduced"
|
|
|
|
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
|
e2e-dry-run:
|
|
needs: [unit-test, lint, script-test, deterministic-gate]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
outputs:
|
|
mode: ${{ steps.e2e_domains.outputs.mode }}
|
|
reason: ${{ steps.e2e_domains.outputs.reason }}
|
|
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Resolve CLI E2E domains
|
|
id: e2e_domains
|
|
run: node scripts/e2e_domains.js
|
|
- name: Validate CLI E2E domain outputs
|
|
env:
|
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
|
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
|
run: |
|
|
case "$E2E_MODE" in
|
|
skip)
|
|
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
|
;;
|
|
full|subset)
|
|
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
|
;;
|
|
*)
|
|
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
|
exit 1
|
|
;;
|
|
esac
|
|
- name: Build lark-cli
|
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
|
run: make build
|
|
- name: Run dry-run E2E tests
|
|
env:
|
|
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
|
LARKSUITE_CLI_APP_ID: dry-run
|
|
LARKSUITE_CLI_APP_SECRET: dry-run
|
|
LARKSUITE_CLI_BRAND: feishu
|
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
|
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
|
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
|
run: |
|
|
if [ "$E2E_MODE" = "skip" ]; then
|
|
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
|
exit 0
|
|
fi
|
|
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
|
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
|
exit 1
|
|
fi
|
|
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
|
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
|
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
|
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
|
fi
|
|
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
|
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
|
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
|
fi
|
|
|
|
e2e-live:
|
|
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
|
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
checks: write
|
|
env:
|
|
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
|
LARKSUITE_CLI_BRAND: feishu
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Build lark-cli
|
|
id: build_cli
|
|
run: make build
|
|
- name: Prepare shared live E2E tenant token
|
|
id: live_e2e_tat
|
|
env:
|
|
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
|
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
|
run: node scripts/fetch_e2e_tat.js
|
|
- name: Run CLI E2E tests
|
|
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
|
# run is rejected below before it can start live E2E.
|
|
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
RUN_NUMBER: ${{ github.run_number }}
|
|
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
|
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
|
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
|
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
|
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
|
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
|
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
|
run: |
|
|
if [ "$EVENT_NAME" = "pull_request" ]; then
|
|
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
|
newer_runs="$(
|
|
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
|
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
|
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
|
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
|
)"
|
|
if [ -n "$newer_runs" ]; then
|
|
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
|
exit 1
|
|
fi
|
|
fi
|
|
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
|
echo "::error::Missing shared live E2E tenant token file"
|
|
exit 1
|
|
fi
|
|
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
|
rm -f "$E2E_TENANT_AUTH_FILE"
|
|
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
|
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
|
./lark-cli whoami --as bot | node -e '
|
|
let input = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
process.stdin.on("end", () => {
|
|
const result = JSON.parse(input);
|
|
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
|
});
|
|
'; then
|
|
echo "::error::Tenant credential preflight failed"
|
|
exit 1
|
|
fi
|
|
echo "Tenant credential preflight succeeded"
|
|
packages="$E2E_LIVE_PACKAGES"
|
|
if [ -z "$packages" ]; then
|
|
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
|
exit 1
|
|
fi
|
|
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
|
echo "Live CLI E2E packages: $packages"
|
|
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
|
- name: Publish CLI E2E test report
|
|
if: ${{ !cancelled() }}
|
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
|
with:
|
|
name: CLI E2E Tests
|
|
path: cli-e2e-report.xml
|
|
reporter: java-junit
|
|
use-actions-summary: true
|
|
list-suites: all
|
|
list-tests: all
|
|
|
|
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
|
security:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: go.mod
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Fetch meta data
|
|
run: python3 scripts/fetch_meta.py
|
|
- name: Gitleaks
|
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
|
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
|
- name: govulncheck
|
|
continue-on-error: true
|
|
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
|
- name: Check dependency licenses
|
|
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
|
|
|
license-header:
|
|
if: ${{ github.event_name == 'pull_request' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
- name: Check license headers
|
|
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
|
with:
|
|
config: .licenserc.yaml
|
|
|
|
# ── Results Gate (single required check for branch protection) ─────
|
|
results:
|
|
if: ${{ always() }}
|
|
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Evaluate results
|
|
run: |
|
|
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
|
|
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# Any failure or cancellation in any job blocks the merge.
|
|
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
|
# needed or on a fork, license-header on push) are OK.
|
|
#
|
|
# plugin-integration and sidecar-integration are intentionally NOT
|
|
# in this loop yet: they run on every PR and their status is shown
|
|
# in the table above, but a failure is observe-only (non-blocking)
|
|
# during the initial soak. Graduation to required is tracked in
|
|
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
|
# consecutive weeks with zero false positives).
|
|
FAILED=0
|
|
for result in \
|
|
"${{ needs.fast-gate.result }}" \
|
|
"${{ needs.unit-test.result }}" \
|
|
"${{ needs.lint.result }}" \
|
|
"${{ needs.script-test.result }}" \
|
|
"${{ needs.deterministic-gate.result }}" \
|
|
"${{ needs.coverage.result }}" \
|
|
"${{ needs.deadcode.result }}" \
|
|
"${{ needs.e2e-dry-run.result }}" \
|
|
"${{ needs.e2e-live.result }}" \
|
|
"${{ needs.security.result }}" \
|
|
"${{ needs.license-header.result }}" \
|
|
"${{ needs.extended-integration.result }}" \
|
|
"${{ needs.extended-platform-security.result }}"; do
|
|
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
|
FAILED=1
|
|
fi
|
|
done
|
|
|
|
if [ "$FAILED" = "1" ]; then
|
|
echo ""
|
|
echo "::error::One or more CI jobs failed — see table above"
|
|
exit 1
|
|
fi
|