diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86583b2..f6572f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,178 +1,161 @@ name: CI on: - push: - branches: [main, develop] - pull_request: - branches: [main] + push: + branches: [main, develop] + pull_request: + branches: [main] concurrency: - group: ci-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true permissions: - contents: read + contents: read env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: always jobs: - changes: - name: Detect Change Scope - runs-on: ubuntu-latest - outputs: - docs_only: ${{ steps.scope.outputs.docs_only }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 + changes: + name: Detect Change Scope + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.scope.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Detect docs-only changes - id: scope - shell: bash - run: | - set -euo pipefail + - name: Detect docs-only changes + id: scope + shell: bash + run: | + set -euo pipefail - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - else - BASE="${{ github.event.before }}" - fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi - if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi - CHANGED="$(git diff --name-only "$BASE" HEAD || true)" - if [ -z "$CHANGED" ]; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + CHANGED="$(git diff --name-only "$BASE" HEAD || true)" + if [ -z "$CHANGED" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi - docs_only=true - while IFS= read -r file; do - [ -z "$file" ] && continue + docs_only=true + while IFS= read -r file; do + [ -z "$file" ] && continue - if [[ "$file" == docs/* ]] \ - || [[ "$file" == *.md ]] \ - || [[ "$file" == *.mdx ]] \ - || [[ "$file" == "LICENSE" ]] \ - || [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \ - || [[ "$file" == .github/pull_request_template.md ]]; then - continue - fi + if [[ "$file" == docs/* ]] \ + || [[ "$file" == *.md ]] \ + || [[ "$file" == *.mdx ]] \ + || [[ "$file" == "LICENSE" ]] \ + || [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \ + || [[ "$file" == .github/pull_request_template.md ]]; then + continue + fi - docs_only=false - break - done <<< "$CHANGED" + docs_only=false + break + done <<< "$CHANGED" - echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" + echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" - lint: - name: Format & Lint - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Detect Rust source changes - id: rust_changes - shell: bash - run: | - set -euo pipefail + lint: + name: Format & Lint + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Run clippy + run: cargo clippy --locked --all-targets -- -D warnings - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - CHANGED="$(git diff --name-only "$BASE" HEAD -- '*.rs' || true)" - else - CHANGED="$(git diff --name-only "${{ github.event.before }}" HEAD -- '*.rs' || true)" - fi + test: + name: Test + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92 + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --locked --verbose - if [ -z "$CHANGED" ]; then - echo "has_rust_changes=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + build: + name: Build (Smoke) + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 - echo "has_rust_changes=true" >> "$GITHUB_OUTPUT" - - name: Run rustfmt - if: steps.rust_changes.outputs.has_rust_changes == 'true' - run: cargo fmt --all -- --check - - name: Run clippy - if: steps.rust_changes.outputs.has_rust_changes == 'true' - run: cargo clippy --all-targets -- -D warnings - - name: Skip rust lint (no Rust changes) - if: steps.rust_changes.outputs.has_rust_changes != 'true' - run: echo "No Rust source changes detected; skipping rustfmt and clippy." + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92 + - uses: Swatinem/rust-cache@v2 + - name: Build release binary + run: cargo build --release --locked --verbose - test: - name: Test - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Run tests - run: cargo test --verbose + docs-only: + name: Docs-Only Fast Path + needs: [changes] + if: needs.changes.outputs.docs_only == 'true' + runs-on: ubuntu-latest + steps: + - name: Skip heavy jobs for docs-only change + run: echo "Docs-only change detected. Rust lint/test/build skipped." - build: - name: Build (Smoke) - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 + ci-required: + name: CI Required Gate + if: always() + needs: [changes, lint, test, build, docs-only] + runs-on: ubuntu-latest + steps: + - name: Enforce required status + shell: bash + run: | + set -euo pipefail - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Build release binary - run: cargo build --release --locked --verbose + if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then + echo "Docs-only fast path passed." + exit 0 + fi - docs-only: - name: Docs-Only Fast Path - needs: [changes] - if: needs.changes.outputs.docs_only == 'true' - runs-on: ubuntu-latest - steps: - - name: Skip heavy jobs for docs-only change - run: echo "Docs-only change detected. Rust lint/test/build skipped." + lint_result="${{ needs.lint.result }}" + test_result="${{ needs.test.result }}" + build_result="${{ needs.build.result }}" - ci-required: - name: CI Required Gate - if: always() - needs: [changes, lint, test, build, docs-only] - runs-on: ubuntu-latest - steps: - - name: Enforce required status - shell: bash - run: | - set -euo pipefail + echo "lint=${lint_result}" + echo "test=${test_result}" + echo "build=${build_result}" - if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then - echo "Docs-only fast path passed." - exit 0 - fi + if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then + echo "Required CI jobs did not pass." + exit 1 + fi - lint_result="${{ needs.lint.result }}" - test_result="${{ needs.test.result }}" - build_result="${{ needs.build.result }}" - - echo "lint=${lint_result}" - echo "test=${test_result}" - echo "build=${build_result}" - - if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then - echo "Required CI jobs did not pass." - exit 1 - fi - - echo "All required CI jobs passed." + echo "All required CI jobs passed." diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d198575..cd7b0b9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,105 +1,105 @@ name: Docker on: - push: - branches: [main] - tags: ["v*"] - pull_request: - branches: [main] - paths: - - "Dockerfile" - - "docker-compose.yml" - - "dev/docker-compose.yml" - - "dev/sandbox/**" - - ".github/workflows/docker.yml" - workflow_dispatch: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + paths: + - "Dockerfile" + - "docker-compose.yml" + - "dev/docker-compose.yml" + - "dev/sandbox/**" + - ".github/workflows/docker.yml" + workflow_dispatch: concurrency: - group: docker-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: docker-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - pr-smoke: - name: PR Docker Smoke - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 25 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 + pr-smoke: + name: PR Docker Smoke + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=pr + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=pr - - name: Build smoke image - uses: docker/build-push-action@v5 - with: - context: . - push: false - load: true - tags: zeroclaw-pr-smoke:latest - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - platforms: linux/amd64 + - name: Build smoke image + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + tags: zeroclaw-pr-smoke:latest + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + platforms: linux/amd64 - - name: Verify image - run: docker run --rm zeroclaw-pr-smoke:latest --version + - name: Verify image + run: docker run --rm zeroclaw-pr-smoke:latest --version - publish: - name: Build and Push Docker Image - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 25 - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 + publish: + name: Build and Push Docker Image + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee82e36..2c602f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,9 @@ jobs: build-release: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} + timeout-minutes: 40 strategy: + fail-fast: false matrix: include: - os: ubuntu-latest @@ -73,6 +75,7 @@ jobs: name: Publish Release needs: build-release runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6d75ef0..60febb7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,37 +1,47 @@ name: Security Audit on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - - cron: "0 6 * * 1" # Weekly on Monday 6am UTC + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" # Weekly on Monday 6am UTC + +concurrency: + group: security-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: always jobs: - audit: - name: Security Audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + audit: + name: Security Audit + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 - - name: Install cargo-audit - run: cargo install --locked cargo-audit --version 0.22.1 + - name: Install cargo-audit + run: cargo install --locked cargo-audit --version 0.22.1 - - name: Run cargo-audit - run: cargo audit + - name: Run cargo-audit + run: cargo audit - deny: - name: License & Supply Chain - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + deny: + name: License & Supply Chain + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 - - uses: EmbarkStudios/cargo-deny-action@v2 - with: - command: check advisories licenses sources + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories licenses sources diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index fda65d4..47d692d 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -23,6 +23,7 @@ permissions: jobs: no-tabs: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -55,6 +56,7 @@ jobs: actionlint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c08857c..ade282c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,7 @@ When PR traffic is high (especially with AI-assisted contributions), these rules - **Security-first review**: changes in `src/security/`, runtime, and CI need stricter validation. Full maintainer workflow: [`docs/pr-workflow.md`](docs/pr-workflow.md). +CI workflow ownership and triage map: [`docs/ci-map.md`](docs/ci-map.md). ## Agent Collaboration Guidance diff --git a/README.md b/README.md index 4445bae..76f3ce2 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,7 @@ MIT — see [LICENSE](LICENSE) ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Implement a trait, submit a PR: +- CI workflow guide: [docs/ci-map.md](docs/ci-map.md) - New `Provider` → `src/providers/` - New `Channel` → `src/channels/` - New `Observer` → `src/observability/` diff --git a/docs/ci-map.md b/docs/ci-map.md new file mode 100644 index 0000000..375ffa6 --- /dev/null +++ b/docs/ci-map.md @@ -0,0 +1,60 @@ +# CI Workflow Map + +This document explains what each GitHub workflow does, when it runs, and whether it should block merges. + +## Merge-Blocking vs Optional + +Merge-blocking checks should stay small and deterministic. Optional checks are useful for automation and maintenance, but should not block normal development. + +### Merge-Blocking + +- `.github/workflows/ci.yml` (`CI`) + - Purpose: Rust validation (`fmt`, `clippy`, `test`, release build smoke) + - Merge gate: `CI Required Gate` +- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`) + - Purpose: lint GitHub workflow files (`actionlint`, tab checks) + - Recommended for workflow-changing PRs + +### Non-Blocking but Important + +- `.github/workflows/docker.yml` (`Docker`) + - Purpose: PR docker smoke check and publish images on `main`/tag pushes +- `.github/workflows/security.yml` (`Security Audit`) + - Purpose: dependency advisories (`cargo audit`) and policy/license checks (`cargo deny`) +- `.github/workflows/release.yml` (`Release`) + - Purpose: build tagged release artifacts and publish GitHub releases + +### Optional Repository Automation + +- `.github/workflows/labeler.yml` (`PR Labeler`) + - Purpose: path labels + size labels +- `.github/workflows/auto-response.yml` (`Auto Response`) + - Purpose: first-time contributor onboarding messages +- `.github/workflows/stale.yml` (`Stale`) + - Purpose: stale issue/PR lifecycle automation + +## Trigger Map + +- `CI`: push to `main`/`develop`, PRs to `main` +- `Docker`: push to `main`, tag push (`v*`), PRs touching docker/workflow files, manual dispatch +- `Release`: tag push (`v*`) +- `Security Audit`: push to `main`, PRs to `main`, weekly schedule +- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change +- `PR Labeler`: `pull_request_target` lifecycle events +- `Auto Response`: issue opened, `pull_request_target` opened +- `Stale`: daily schedule, manual dispatch + +## Fast Triage Guide + +1. `CI Required Gate` failing: start with `.github/workflows/ci.yml`. +2. Docker failures on PRs: inspect `.github/workflows/docker.yml` `pr-smoke` job. +3. Release failures on tags: inspect `.github/workflows/release.yml`. +4. Security failures: inspect `.github/workflows/security.yml` and `deny.toml`. +5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`. + +## Maintenance Rules + +- Keep merge-blocking checks deterministic and reproducible (`--locked` where applicable). +- Prefer explicit workflow permissions (least privilege). +- Use path filters for expensive workflows when practical. +- Avoid mixing onboarding/community automation with merge-gating logic. diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index d34826c..a766868 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -9,6 +9,8 @@ This document defines how ZeroClaw handles high PR volume while maintaining: - High sustainability - High security +Related reference: [`docs/ci-map.md`](ci-map.md) for per-workflow ownership, triggers, and triage flow. + ## 1) Governance Goals 1. Keep merge throughput predictable under heavy PR load.