feat(apps): add role management shortcuts #5812
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 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 | |
| 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><summary>Details</summary>" >> $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 "</details>" >> $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 | |
| 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: 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] | |
| 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 | |
| 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: Build lark-cli | |
| if: ${{ steps.e2e_domains.outputs.mode != 'skip' }} | |
| run: make build | |
| - name: Configure bot credentials | |
| if: ${{ steps.e2e_domains.outputs.mode != 'skip' }} | |
| 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 | |
| E2E_MODE: ${{ steps.e2e_domains.outputs.mode }} | |
| E2E_REASON: ${{ steps.e2e_domains.outputs.reason }} | |
| E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }} | |
| run: | | |
| if [ "$E2E_MODE" = "skip" ]; then | |
| echo "No live CLI E2E needed: $E2E_REASON" | |
| exit 0 | |
| fi | |
| 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() && steps.e2e_domains.outputs.mode != 'skip' }} | |
| 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 |