mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.
Public SDK (extension/platform):
- Plugin interface (Name / Version / Capabilities / Install).
- Registrar verbs: Observe, Wrap, On, Restrict.
- Hook types: Observer (side-effect, panic-safe, fires Before/After
RunE), Wrapper (middleware, may short-circuit via AbortError),
LifecycleHandler (Startup / Shutdown), Selector with nil-safe
And/Or/Not composition.
- Risk / Identity are defined string types with closed taxonomies;
ParseRisk / ParseIdentity convert raw strings with the
absent-vs-invalid distinction the engine relies on.
- Builder ergonomic constructor (NewPlugin().Observer().Wrap()
...MustBuild()) that enforces name/hookName grammar, hookName
uniqueness, and the Restrict ↔ FailClosed pairing regardless of
call order.
- Invocation is a read-only interface; the framework's concrete
invocation type lives in internal/hook so plugins cannot
fabricate denial / strict-mode / identity state. Args() returns
a defensive copy on every call so hook mutation cannot leak
into the original RunE.
- CommandDeniedError + AbortError carry structured fields for the
closed `command_denied` / `hook` envelope contract.
- ResetForTesting gated behind //go:build testing.
- README + godoc examples (Observer / Wrapper / Restrict) + two
runnable example forks (audit-observer, readonly-policy).
Host (internal/platform, internal/hook, internal/cmdpolicy):
- InstallAll: staged plugin registration with atomic commit, panic
isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
semver check, single-Restrict invariant, duplicate-plugin-name
detection.
- hook.Install wraps every runnable cmd.RunE with:
Before observers (panic-safe) → denial guard → composed Wrap
chain → original RunE → After observers (always fire, even on
err). Denied commands physically bypass the Wrap chain so a
plugin Wrapper cannot suppress or rewrite a denial; observers
still see the attempt for audit.
- Recover shim around plugin Wrappers converts panics (including
the factory call) into a structured `hook` envelope with
reason_code=panic; namespacing shim attributes AbortError to
the namespaced hook name.
- cmdpolicy (renamed from internal/pruning) is the user-layer
command policy engine: walks the cobra tree, evaluates each
runnable command against a Rule's four-axis filter (Allow /
Deny / MaxRisk / Identities), produces parent-group aggregate
denials, and installs denyStubs. Rule.AllowUnannotated opts out
of the unannotated-deny gate for gradual adoption; risk_invalid
typos always deny with an edit-distance "did you mean"
suggestion.
- Strict-mode stub in cmd/prune.go composes the shared
detail.* / wrapped CommandDeniedError shape via cmdpolicy
helpers (BuildDenialError / CommandDeniedFromDenial /
DenialDetailMap), so command_denied envelopes from strict-mode
and user-layer policy carry the same closed-enum fields
(detail.layer / reason_code / policy_source). The historical
short Message + independent Hint are preserved unchanged.
- cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
with KnownFields strict mode, including allow_unannotated.
- `config policy show` / `config policy validate` and the plugin
inventory diagnostic surface the resolved Rule (allow,
deny, max_risk, identities, allow_unannotated) and the hook
contributions per plugin.
Envelope contract (docs/extension/reason-codes.md):
- error.type is a closed set: command_denied, hook, plugin_install,
plugin_conflict, plugin_lifecycle.
- reason_code is a closed enum per error.type, dispatched on by
external agents and CI integrations.
- detail.layer = "policy" | "strict_mode" attributes the rejection.
Build / CI:
- Makefile unit-test / vet / coverage and ci.yml fast-gate +
unit-test + coverage now pass -tags testing so register_testing.go
is visible; ./extension/... is in the package list so the SDK's
own tests actually run.
- fmt-check and examples-build Makefile targets.
- bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
matching in Rule.Allow / Rule.Deny.
Author-facing material:
- docs/extension/ (quickstart, plugin-author-guide, reason-codes)
is provided in the working tree but kept out of git tracking
per repo convention (.gitignore covers docs/).
Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703
335 lines
14 KiB
YAML
335 lines
14 KiB
YAML
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 -tags testing ./...
|
|
- 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 -tags testing -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
|
|
- 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 --build-tags=testing --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 -tags testing -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><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 -tags=testing -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 -tags=testing -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]
|
|
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 }}
|
|
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
|
|
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
|