diff --git a/.codecov.yml b/.codecov.yml
index 2f0a040f7..9c640e6eb 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -6,3 +6,6 @@ coverage:
patch:
default:
target: 60%
+
+github_checks:
+ annotations: true
diff --git a/.github/workflows/arch-audit.yml b/.github/workflows/arch-audit.yml
new file mode 100644
index 000000000..3db50f605
--- /dev/null
+++ b/.github/workflows/arch-audit.yml
@@ -0,0 +1,116 @@
+name: Architecture Audit
+
+on:
+ schedule:
+ - cron: '0 9 * * 1' # Monday 09:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ audit:
+ 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: Dead code detection
+ run: |
+ echo "## Dead Code" >> report.md
+ go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
+ count=$(wc -l < deadcode.txt | tr -d ' ')
+ echo "Found **$count** unreachable functions" >> report.md
+ echo '```' >> report.md
+ cat deadcode.txt >> report.md
+ echo '```' >> report.md
+
+ - name: Package complexity
+ run: |
+ echo "## Package Complexity" >> report.md
+ echo "" >> report.md
+ echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
+ echo "" >> report.md
+ echo "| Package | Files | Lines | Deps |" >> report.md
+ echo "|---------|-------|-------|------|" >> report.md
+ found=0
+ for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
+ dir=$(go list -f '{{.Dir}}' "$pkg")
+ files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
+ lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
+ deps=$(go list -f '{{len .Imports}}' "$pkg")
+ if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
+ echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
+ found=1
+ fi
+ done
+ if [ "$found" = "0" ]; then
+ echo "| _(none)_ | | | |" >> report.md
+ fi
+
+ - name: Dependency freshness
+ run: |
+ echo "## Outdated Dependencies" >> report.md
+ echo '```' >> report.md
+ go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
+ echo '```' >> report.md
+
+ - name: Circular dependency check
+ run: |
+ echo "## Circular Dependencies" >> report.md
+ go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
+ go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
+ if [ -s cycles.txt ]; then
+ echo '```' >> report.md
+ cat cycles.txt >> report.md
+ echo '```' >> report.md
+ else
+ echo "No circular dependencies detected." >> report.md
+ fi
+
+ - name: E2E coverage gaps
+ run: |
+ echo "## E2E Coverage Gaps" >> report.md
+ echo "" >> report.md
+ echo "Shortcut domains without E2E tests:" >> report.md
+ echo "" >> report.md
+ found=0
+ for domain in $(ls -d shortcuts/*/); do
+ name=$(basename "$domain")
+ if [ "$name" = "common" ]; then continue; fi
+ if [ ! -d "tests/cli_e2e/$name" ]; then
+ echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
+ found=1
+ fi
+ done
+ if [ "$found" = "0" ]; then
+ echo "All shortcut domains have E2E test directories." >> report.md
+ fi
+
+ - name: Coverage
+ run: |
+ echo "## Coverage" >> report.md
+ packages=$(go list ./... | grep -v 'tests/cli_e2e')
+ go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
+ total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
+ echo "Current total coverage: **${total:-n/a}**" >> report.md
+
+ - name: Publish report
+ run: |
+ echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ cat report.md >> $GITHUB_STEP_SUMMARY
+
+ - name: Upload report artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: arch-audit-${{ github.run_number }}
+ path: report.md
+ retention-days: 90
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..df35f110e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,333 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ actions: read
+ checks: write
+ pull-requests: write
+
+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/...
+
+ lint:
+ needs: fast-gate
+ 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: Run golangci-lint
+ run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
+
+ 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
+ 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 ./... 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 ./... 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]
+ 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]
+ if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
+ runs-on: ubuntu-latest
+ env:
+ TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
+ TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
+ 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
+ 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, 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 | 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.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
diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml
deleted file mode 100644
index c08620863..000000000
--- a/.github/workflows/cli-e2e.yml
+++ /dev/null
@@ -1,83 +0,0 @@
-name: CLI E2E Tests
-
-on:
- push:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - Makefile
- - scripts/fetch_meta.py
- - tests/cli_e2e/**
- - .github/workflows/cli-e2e.yml
- pull_request:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - Makefile
- - scripts/fetch_meta.py
- - tests/cli_e2e/**
- - .github/workflows/cli-e2e.yml
- workflow_dispatch:
-
-permissions:
- contents: read
- actions: read
- checks: write
-
-jobs:
- cli-e2e:
- # Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
- if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
- runs-on: ubuntu-latest
- env:
- TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
- TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
- 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
- # gotestsum requires --packages when --rerun-fails is combined with go test args after --.
- 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
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
deleted file mode 100644
index 921bf1e81..000000000
--- a/.github/workflows/coverage.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-name: Coverage
-
-on:
- push:
- branches: [main]
- paths:
- - "**.go"
- - "!tests/cli_e2e/**"
- - go.mod
- - go.sum
- - .github/workflows/coverage.yml
- pull_request:
- branches: [main]
- paths:
- - "**.go"
- - "!tests/cli_e2e/**"
- - go.mod
- - go.sum
- - .github/workflows/coverage.yml
-
-permissions:
- contents: read
-
-jobs:
- coverage:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
-
- - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
- with:
- go-version-file: go.mod
-
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- 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: Generate coverage report
- run: |
- 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
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
deleted file mode 100644
index d44b5d6ab..000000000
--- a/.github/workflows/gitleaks.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: Gitleaks
-
-on:
- pull_request:
- branches: [main]
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- gitleaks:
- # Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
- if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- with:
- fetch-depth: 0
-
- - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
- env:
- # GITHUB_TOKEN is provided automatically by GitHub Actions.
- # GITLEAKS_KEY must be configured as a repository secret.
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
diff --git a/.github/workflows/license-header.yml b/.github/workflows/license-header.yml
deleted file mode 100644
index 8107ee8fc..000000000
--- a/.github/workflows/license-header.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: License Header
-
-on:
- pull_request:
- branches: [main]
- paths:
- - "**/*.go"
- - "**/*.js"
- - "**/*.py"
- - .licenserc.yaml
- - .github/workflows/license-header.yml
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- header-check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
-
- - name: Check license headers
- uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
- with:
- config: .licenserc.yaml
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index cec20a8b3..000000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,60 +0,0 @@
-name: Lint
-
-on:
- push:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - .golangci.yml
- - .github/workflows/lint.yml
- pull_request:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - .golangci.yml
- - .github/workflows/lint.yml
-
-permissions:
- contents: read
-
-jobs:
- golangci-lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- with:
- fetch-depth: 0
-
- - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
- with:
- go-version-file: go.mod
-
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- with:
- python-version: '3.x'
-
- - name: Fetch meta_data.json
- run: python3 scripts/fetch_meta.py
-
- - name: Ensure go.mod and go.sum are tidy
- 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
-
- - name: Run golangci-lint
- run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
-
- - name: Run govulncheck
- continue-on-error: true # informational until Go version is upgraded
- 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
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index 58351696d..000000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Tests
-
-on:
- push:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - .github/workflows/tests.yml
- pull_request:
- branches: [main]
- paths:
- - "**.go"
- - go.mod
- - go.sum
- - .github/workflows/tests.yml
-
-permissions:
- contents: read
-
-jobs:
- unit-test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
-
- - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
- with:
- go-version-file: go.mod
-
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- 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/...
-
- - name: Build
- run: go build -v ./...
diff --git a/.golangci.yml b/.golangci.yml
index faa9ee79d..6889caea4 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -70,6 +70,14 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
+ shortcuts-no-raw-http:
+ files:
+ - "**/shortcuts/**"
+ deny:
+ - pkg: "net/http"
+ desc: >-
+ use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
+ The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
@@ -100,6 +108,16 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
+ # ── output: shortcuts must use ctx.Out() ──
+ - pattern: fmt\.Print(f|ln)?\b
+ msg: >-
+ use ctx.Out() or ctx.OutFormat() for structured JSON output.
+ fmt.Print* bypasses the output envelope and breaks --jq/--format.
+ # ── logging: shortcuts must return errors, not log.Fatal ──
+ - pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
+ msg: >-
+ use structured error return, not log.Fatal/Panic.
+ Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-
diff --git a/AGENTS.md b/AGENTS.md
index e6ed5f872..b23920650 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,9 +18,11 @@ make test # Full: vet + unit + integration
## Pre-PR Checks (match CI gates)
1. `make unit-test`
-2. `go mod tidy` — must not change `go.mod`/`go.sum`
-3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
-4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
+2. `go vet ./...`
+3. `gofmt -l .` — must produce no output
+4. `go mod tidy` — must not change `go.mod`/`go.sum`
+5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
+6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
+
+### E2E Testing
+
+**Dry-run E2E (required for every shortcut change)**
+- Validates request structure without calling real APIs
+- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
+- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
+- No secrets needed — runs on fork PRs
+- Explore correct params with `lark-cli --help` and `lark-cli schema` first
+
+**Live E2E (required for new flows or behavior changes)**
+- Validates real API round-trips
+- Place in `tests/cli_e2e//`
+- Must be self-contained: create -> use -> cleanup
+- Needs bot credentials (CI secrets, skipped on fork PRs)
+- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
+
+| Change | Dry-run E2E | Live E2E |
+|--------|:-----------:|:--------:|
+| New shortcut | Required | Required |
+| Modify shortcut flags/params | Required | If behavior changes |
+| Shortcut bug fix | Required | If regression risk |
+| Internal refactor (no shortcut impact) | Not needed | Not needed |
diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go
index 4bfb622ea..73948ca96 100644
--- a/cmd/config/init_messages.go
+++ b/cmd/config/init_messages.go
@@ -10,12 +10,12 @@ import (
)
type initMsg struct {
- SelectAction string
- CreateNewApp string
- ConfigExistingApp string
- Platform string
- SelectPlatform string
- Feishu string
+ SelectAction string
+ CreateNewApp string
+ ConfigExistingApp string
+ Platform string
+ SelectPlatform string
+ Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
@@ -29,11 +29,11 @@ type initMsg struct {
}
var initMsgZh = &initMsg{
- SelectAction: "选择操作",
- CreateNewApp: "一键配置应用 (推荐) ",
- ConfigExistingApp: "手动输入应用凭证",
- Platform: "平台",
- SelectPlatform: "选择平台",
+ SelectAction: "选择操作",
+ CreateNewApp: "一键配置应用 (推荐) ",
+ ConfigExistingApp: "手动输入应用凭证",
+ Platform: "平台",
+ SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
}
var initMsgEn = &initMsg{
- SelectAction: "Select action",
- CreateNewApp: "Set up your app with one click (Recommended)",
- ConfigExistingApp: "Enter app credentials yourself",
- Platform: "Platform",
- SelectPlatform: "Select platform",
+ SelectAction: "Select action",
+ CreateNewApp: "Set up your app with one click (Recommended)",
+ ConfigExistingApp: "Enter app credentials yourself",
+ Platform: "Platform",
+ SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go
index ecafb0ec2..0bdaecf2a 100644
--- a/cmd/config/init_messages_test.go
+++ b/cmd/config/init_messages_test.go
@@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
- "SelectAction": msg.SelectAction,
- "CreateNewApp": msg.CreateNewApp,
- "ConfigExistingApp": msg.ConfigExistingApp,
- "Platform": msg.Platform,
- "SelectPlatform": msg.SelectPlatform,
- "Feishu": msg.Feishu,
+ "SelectAction": msg.SelectAction,
+ "CreateNewApp": msg.CreateNewApp,
+ "ConfigExistingApp": msg.ConfigExistingApp,
+ "Platform": msg.Platform,
+ "SelectPlatform": msg.SelectPlatform,
+ "Feishu": msg.Feishu,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
diff --git a/cmd/service/service.go b/cmd/service/service.go
index 5639075bd..63b6fc6b7 100644
--- a/cmd/service/service.go
+++ b/cmd/service/service.go
@@ -101,16 +101,16 @@ type ServiceMethodOptions struct {
SchemaPath string
// Flags
- Params string
- Data string
- As core.Identity
- Output string
- PageAll bool
- PageLimit int
- PageDelay int
- Format string
- JqExpr string
- DryRun bool
+ Params string
+ Data string
+ As core.Identity
+ Output string
+ PageAll bool
+ PageLimit int
+ PageDelay int
+ Format string
+ JqExpr string
+ DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
}
diff --git a/internal/util/strings_test.go b/internal/util/strings_test.go
new file mode 100644
index 000000000..766a5d395
--- /dev/null
+++ b/internal/util/strings_test.go
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import "testing"
+
+func TestTruncateStr(t *testing.T) {
+ tests := []struct {
+ name string
+ s string
+ n int
+ want string
+ }{
+ {"short string", "hello", 10, "hello"},
+ {"exact length", "hello", 5, "hello"},
+ {"truncate", "hello world", 5, "hello"},
+ {"empty", "", 5, ""},
+ {"zero limit", "hello", 0, ""},
+ {"CJK characters", "你好世界测试", 4, "你好世界"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := TruncateStr(tt.s, tt.n); got != tt.want {
+ t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTruncateStrWithEllipsis(t *testing.T) {
+ tests := []struct {
+ name string
+ s string
+ n int
+ want string
+ }{
+ {"short string", "hello", 10, "hello"},
+ {"exact length", "hello", 5, "hello"},
+ {"truncate with ellipsis", "hello world", 8, "hello..."},
+ {"limit less than 3", "hello", 2, "he"},
+ {"limit equals 3", "hello world", 3, "..."},
+ {"empty", "", 5, ""},
+ {"CJK with ellipsis", "你好世界测试", 5, "你好..."},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
+ t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
+ }
+ })
+ }
+}