name: CI on: push: branches: [main] pull_request: branches: [main] types: [opened, synchronize, reopened, edited] workflow_dispatch: 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 # ── 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 errs/ lint guards (lintcheck) run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" .. 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 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: | packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') go test -race -coverprofile=coverage.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.txt token: ${{ secrets.CODECOV_TOKEN }} - name: Check coverage threshold run: | total=$(go tool cover -func=coverage.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: | if [ ! -f coverage.txt ]; then echo "No coverage data available" >> $GITHUB_STEP_SUMMARY exit 0 fi total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "
Details" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "
" >> $GITHUB_STEP_SUMMARY 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 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: Build lark-cli 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 run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' e2e-live: needs: [unit-test, lint, script-test, deterministic-gate] if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest permissions: contents: read checks: write env: TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }} TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }} TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }} 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: Build lark-cli run: make build - name: Configure bot credentials run: | if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" exit 1 fi printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin - name: Run CLI E2E tests env: LARK_CLI_BIN: ${{ github.workspace }}/lark-cli run: | packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') if [ -z "$packages" ]; then echo "No CLI E2E packages to test after exclusions." exit 1 fi packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -) go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --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] 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 # Any failure or cancellation in any job blocks the merge. # Legitimately skipped jobs (deadcode on push, e2e-live on fork, # license-header on push) are OK. 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 }}"; 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