chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,465 @@
|
||||
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
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
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: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Issue Labels
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Do not write labels, only print planned changes"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-issue-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# v6+ uses Node 24 runtime for JavaScript actions.
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Sync managed issue labels
|
||||
id: sync_issue_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
args=(
|
||||
"--max-issues" "300"
|
||||
)
|
||||
|
||||
# Schedule runs should write labels by default.
|
||||
# Manual runs default to dry-run unless explicitly disabled.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
|
||||
args+=("--dry-run" "--json")
|
||||
fi
|
||||
|
||||
node scripts/issue-labels/index.js "${args[@]}"
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::Issue label sync failed; labels may be stale."
|
||||
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,149 @@
|
||||
name: PR Preview Package Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Preview Package"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check comment payload artifact
|
||||
id: payload
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run?.id;
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
per_page: 100,
|
||||
});
|
||||
const found = Boolean(
|
||||
data.artifacts?.some((artifact) => artifact.name === "pkg-pr-new-comment-payload")
|
||||
);
|
||||
core.setOutput("found", found ? "true" : "false");
|
||||
if (!found) {
|
||||
core.notice("No comment payload artifact found for this run; skipping comment.");
|
||||
}
|
||||
|
||||
- name: Download comment payload
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Comment install command
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const payload = JSON.parse(fs.readFileSync("pkg-pr-new-comment-payload.json", "utf8"));
|
||||
const url = payload?.url;
|
||||
const payloadPr = payload?.pr;
|
||||
const sourceRepo = payload?.sourceRepo;
|
||||
const sourceBranch = payload?.sourceBranch;
|
||||
if (!Number.isInteger(payloadPr)) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
if (payloadPr <= 0) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
const issueNumber = payloadPr;
|
||||
const runPrNumber = context.payload.workflow_run?.pull_requests?.[0]?.number;
|
||||
if (Number.isInteger(runPrNumber) && runPrNumber !== issueNumber) {
|
||||
throw new Error(
|
||||
`PR number mismatch between workflow_run (${runPrNumber}) and artifact payload (${issueNumber})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof url !== "string" || url.trim() !== url || /[\u0000-\u001F\u007F]/.test(url)) {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "pkg.pr.new") {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
|
||||
const safeRepoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const safeBranchPattern = /^[A-Za-z0-9._\/-]+$/;
|
||||
const hasSkillSource =
|
||||
typeof sourceRepo === "string" &&
|
||||
typeof sourceBranch === "string" &&
|
||||
safeRepoPattern.test(sourceRepo) &&
|
||||
safeBranchPattern.test(sourceBranch);
|
||||
const skillSection = hasSkillSource
|
||||
? [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"```bash",
|
||||
`npx skills add ${sourceRepo}#${sourceBranch} -y -g`,
|
||||
"```",
|
||||
]
|
||||
: [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"_Unavailable for this PR because source repo/branch metadata is missing._",
|
||||
];
|
||||
|
||||
const body = [
|
||||
"<!-- pkg-pr-new-install-guide -->",
|
||||
"## 🚀 PR Preview Install Guide",
|
||||
"",
|
||||
"### 🧰 CLI update",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
...skillSection,
|
||||
].join("\n");
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.includes("<!-- pkg-pr-new-install-guide -->")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: PR Preview Package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.pull_request.draft == false
|
||||
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-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Build preview package
|
||||
run: ./scripts/build-pkg-pr-new.sh
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
|
||||
|
||||
- name: Build comment payload
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) throw new Error("No package URL found in output.json");
|
||||
if (!url.startsWith("https://pkg.pr.new/")) {
|
||||
throw new Error(`Unexpected package URL: ${url}`);
|
||||
}
|
||||
|
||||
const pr = Number(process.env.PR_NUMBER);
|
||||
if (!Number.isInteger(pr) || pr <= 0) {
|
||||
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pr,
|
||||
url,
|
||||
sourceRepo: process.env.SOURCE_REPO || "",
|
||||
sourceBranch: process.env.SOURCE_BRANCH || "",
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
"pkg-pr-new-comment-payload.json",
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload comment payload
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
path: pkg-pr-new-comment-payload.json
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Test PR Label Logic
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-pr-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run PR label regression tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/test.js
|
||||
@@ -0,0 +1,43 @@
|
||||
name: PR Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NOTE: This event runs with base-branch code and write permissions.
|
||||
# Do NOT add `ref: github.event.pull_request.head.sha` to the checkout step,
|
||||
# as that would execute untrusted PR code with elevated access.
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-pr-labels:
|
||||
if: ${{ github.event.pull_request.state == 'open' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Sync managed PR labels
|
||||
id: sync_pr_labels
|
||||
# Labeling is best-effort and must not block PR merges.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/index.js
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_pr_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::PR label sync failed; labels may be stale."
|
||||
echo "⚠️ PR label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
@@ -0,0 +1,611 @@
|
||||
name: Semantic Review
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pr-quality-summary:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request for summary
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify summary facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract summary facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Publish PR quality summary
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/ci-quality-summary-publish.js");
|
||||
await publish({ github, context, core });
|
||||
|
||||
semantic-review:
|
||||
needs: pr-quality-summary
|
||||
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("head_owner", pr.head.repo.owner.login);
|
||||
core.setOutput("head_repo", pr.head.repo.name);
|
||||
core.setOutput("head_repo_id", String(pr.head.repo.id));
|
||||
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Publish pre-checkout semantic review failure
|
||||
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
|
||||
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
|
||||
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR base changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.repo.id !== context.payload.repository.id) {
|
||||
throw new Error("PR base repo mismatch before infrastructure failure check");
|
||||
}
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
|
||||
head_sha: headSha,
|
||||
status: "completed",
|
||||
conclusion: runtimeBlockMode ? "failure" : "neutral",
|
||||
output: {
|
||||
title: "Semantic review infrastructure failure",
|
||||
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
|
||||
},
|
||||
});
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify semantic facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
|
||||
throw new Error("missing verified facts artifact binding");
|
||||
}
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract semantic facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Download PR semantic waiver config
|
||||
id: waiver_config
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
|
||||
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
|
||||
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
|
||||
if (!headOwner || !headRepo) {
|
||||
throw new Error("missing verified semantic review head repository");
|
||||
}
|
||||
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
|
||||
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
|
||||
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
|
||||
if (!headIsBaseRepo) {
|
||||
core.notice("fork PR semantic waiver config is ignored");
|
||||
core.setOutput("path", "");
|
||||
return;
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: waiverPath,
|
||||
ref: headSha,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
|
||||
throw new Error(`${waiverPath} is not a base64 file at PR head`);
|
||||
}
|
||||
if (data.size > 256 * 1024) {
|
||||
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
|
||||
}
|
||||
content = Buffer.from(data.content, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outPath, content);
|
||||
core.setOutput("path", outPath);
|
||||
|
||||
- name: Run semantic review
|
||||
id: semantic
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
|
||||
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
|
||||
ARK_MODEL: ${{ vars.ARK_MODEL }}
|
||||
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
run: |
|
||||
args=(
|
||||
--repo .
|
||||
--facts facts.json
|
||||
--decision-out decision.json
|
||||
--markdown-out semantic-review.md
|
||||
)
|
||||
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
|
||||
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
|
||||
fi
|
||||
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
|
||||
args+=(--block)
|
||||
fi
|
||||
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
|
||||
|
||||
- name: Publish semantic review
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/semantic-review-publish.js");
|
||||
await publish({ github, context, core });
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Skill Format Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run Skill Format Check
|
||||
run: node scripts/skill-format-check/index.js
|
||||
Reference in New Issue
Block a user