chore(ci): document and harden workflow pipeline (#241)

* docs(ci): add CI workflow map and cross-links

* chore(ci): harden workflow determinism and safety

* chore(ci): address workflow review feedback

* style(ci): normalize workflow and ci-map formatting
This commit is contained in:
Will Sarg 2026-02-15 20:42:47 -05:00 committed by GitHub
parent 3014926687
commit 82ffb36f90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 322 additions and 260 deletions

View file

@ -1,178 +1,161 @@
name: CI name: CI
on: on:
push: push:
branches: [main, develop] branches: [main, develop]
pull_request: pull_request:
branches: [main] branches: [main]
concurrency: concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }} group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions:
contents: read contents: read
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
changes: changes:
name: Detect Change Scope name: Detect Change Scope
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
docs_only: ${{ steps.scope.outputs.docs_only }} docs_only: ${{ steps.scope.outputs.docs_only }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Detect docs-only changes - name: Detect docs-only changes
id: scope id: scope
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}" BASE="${{ github.event.pull_request.base.sha }}"
else else
BASE="${{ github.event.before }}" BASE="${{ github.event.before }}"
fi fi
if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
CHANGED="$(git diff --name-only "$BASE" HEAD || true)" CHANGED="$(git diff --name-only "$BASE" HEAD || true)"
if [ -z "$CHANGED" ]; then if [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
docs_only=true docs_only=true
while IFS= read -r file; do while IFS= read -r file; do
[ -z "$file" ] && continue [ -z "$file" ] && continue
if [[ "$file" == docs/* ]] \ if [[ "$file" == docs/* ]] \
|| [[ "$file" == *.md ]] \ || [[ "$file" == *.md ]] \
|| [[ "$file" == *.mdx ]] \ || [[ "$file" == *.mdx ]] \
|| [[ "$file" == "LICENSE" ]] \ || [[ "$file" == "LICENSE" ]] \
|| [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \ || [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \
|| [[ "$file" == .github/pull_request_template.md ]]; then || [[ "$file" == .github/pull_request_template.md ]]; then
continue continue
fi fi
docs_only=false docs_only=false
break break
done <<< "$CHANGED" done <<< "$CHANGED"
echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"
lint: lint:
name: Format & Lint name: Format & Lint
needs: [changes] needs: [changes]
if: needs.changes.outputs.docs_only != 'true' if: needs.changes.outputs.docs_only != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 with:
- name: Detect Rust source changes toolchain: 1.92
id: rust_changes components: rustfmt, clippy
shell: bash - uses: Swatinem/rust-cache@v2
run: | - name: Run rustfmt
set -euo pipefail run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --locked --all-targets -- -D warnings
if [ "${{ github.event_name }}" = "pull_request" ]; then test:
BASE="${{ github.event.pull_request.base.sha }}" name: Test
CHANGED="$(git diff --name-only "$BASE" HEAD -- '*.rs' || true)" needs: [changes]
else if: needs.changes.outputs.docs_only != 'true'
CHANGED="$(git diff --name-only "${{ github.event.before }}" HEAD -- '*.rs' || true)" runs-on: ubuntu-latest
fi 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 build:
echo "has_rust_changes=false" >> "$GITHUB_OUTPUT" name: Build (Smoke)
exit 0 needs: [changes]
fi if: needs.changes.outputs.docs_only != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
echo "has_rust_changes=true" >> "$GITHUB_OUTPUT" steps:
- name: Run rustfmt - uses: actions/checkout@v4
if: steps.rust_changes.outputs.has_rust_changes == 'true' - uses: dtolnay/rust-toolchain@stable
run: cargo fmt --all -- --check with:
- name: Run clippy toolchain: 1.92
if: steps.rust_changes.outputs.has_rust_changes == 'true' - uses: Swatinem/rust-cache@v2
run: cargo clippy --all-targets -- -D warnings - name: Build release binary
- name: Skip rust lint (no Rust changes) run: cargo build --release --locked --verbose
if: steps.rust_changes.outputs.has_rust_changes != 'true'
run: echo "No Rust source changes detected; skipping rustfmt and clippy."
test: docs-only:
name: Test name: Docs-Only Fast Path
needs: [changes] needs: [changes]
if: needs.changes.outputs.docs_only != 'true' if: needs.changes.outputs.docs_only == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 steps:
steps: - name: Skip heavy jobs for docs-only change
- uses: actions/checkout@v4 run: echo "Docs-only change detected. Rust lint/test/build skipped."
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
build: ci-required:
name: Build (Smoke) name: CI Required Gate
needs: [changes] if: always()
if: needs.changes.outputs.docs_only != 'true' needs: [changes, lint, test, build, docs-only]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 steps:
- name: Enforce required status
shell: bash
run: |
set -euo pipefail
steps: if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then
- uses: actions/checkout@v4 echo "Docs-only fast path passed."
- uses: dtolnay/rust-toolchain@stable exit 0
- uses: Swatinem/rust-cache@v2 fi
- name: Build release binary
run: cargo build --release --locked --verbose
docs-only: lint_result="${{ needs.lint.result }}"
name: Docs-Only Fast Path test_result="${{ needs.test.result }}"
needs: [changes] build_result="${{ needs.build.result }}"
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."
ci-required: echo "lint=${lint_result}"
name: CI Required Gate echo "test=${test_result}"
if: always() echo "build=${build_result}"
needs: [changes, lint, test, build, docs-only]
runs-on: ubuntu-latest
steps:
- name: Enforce required status
shell: bash
run: |
set -euo pipefail
if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
echo "Docs-only fast path passed." echo "Required CI jobs did not pass."
exit 0 exit 1
fi fi
lint_result="${{ needs.lint.result }}" echo "All required CI jobs passed."
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."

View file

@ -1,105 +1,105 @@
name: Docker name: Docker
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"] tags: ["v*"]
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "Dockerfile" - "Dockerfile"
- "docker-compose.yml" - "docker-compose.yml"
- "dev/docker-compose.yml" - "dev/docker-compose.yml"
- "dev/sandbox/**" - "dev/sandbox/**"
- ".github/workflows/docker.yml" - ".github/workflows/docker.yml"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }} group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
pr-smoke: pr-smoke:
name: PR Docker Smoke name: PR Docker Smoke
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) - name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=pr type=ref,event=pr
- name: Build smoke image - name: Build smoke image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: false push: false
load: true load: true
tags: zeroclaw-pr-smoke:latest tags: zeroclaw-pr-smoke:latest
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
platforms: linux/amd64 platforms: linux/amd64
- name: Verify image - name: Verify image
run: docker run --rm zeroclaw-pr-smoke:latest --version run: docker run --rm zeroclaw-pr-smoke:latest --version
publish: publish:
name: Build and Push Docker Image name: Build and Push Docker Image
if: github.event_name != 'pull_request' if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry - name: Log in to Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) - name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}

View file

@ -14,7 +14,9 @@ jobs:
build-release: build-release:
name: Build ${{ matrix.target }} name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 40
strategy: strategy:
fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
@ -73,6 +75,7 @@ jobs:
name: Publish Release name: Publish Release
needs: build-release needs: build-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -1,37 +1,47 @@
name: Security Audit name: Security Audit
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
schedule: schedule:
- cron: "0 6 * * 1" # Weekly on Monday 6am UTC - 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: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
audit: audit:
name: Security Audit name: Security Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: timeout-minutes: 20
- uses: actions/checkout@v4 steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install --locked cargo-audit --version 0.22.1 run: cargo install --locked cargo-audit --version 0.22.1
- name: Run cargo-audit - name: Run cargo-audit
run: cargo audit run: cargo audit
deny: deny:
name: License & Supply Chain name: License & Supply Chain
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: timeout-minutes: 20
- uses: actions/checkout@v4 steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2 - uses: EmbarkStudios/cargo-deny-action@v2
with: with:
command: check advisories licenses sources command: check advisories licenses sources

View file

@ -23,6 +23,7 @@ permissions:
jobs: jobs:
no-tabs: no-tabs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -55,6 +56,7 @@ jobs:
actionlint: actionlint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View file

@ -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. - **Security-first review**: changes in `src/security/`, runtime, and CI need stricter validation.
Full maintainer workflow: [`docs/pr-workflow.md`](docs/pr-workflow.md). 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 ## Agent Collaboration Guidance

View file

@ -441,6 +441,7 @@ MIT — see [LICENSE](LICENSE)
## Contributing ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Implement a trait, submit a PR: 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 `Provider``src/providers/`
- New `Channel``src/channels/` - New `Channel``src/channels/`
- New `Observer``src/observability/` - New `Observer``src/observability/`

60
docs/ci-map.md Normal file
View file

@ -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.

View file

@ -9,6 +9,8 @@ This document defines how ZeroClaw handles high PR volume while maintaining:
- High sustainability - High sustainability
- High security - High security
Related reference: [`docs/ci-map.md`](ci-map.md) for per-workflow ownership, triggers, and triage flow.
## 1) Governance Goals ## 1) Governance Goals
1. Keep merge throughput predictable under heavy PR load. 1. Keep merge throughput predictable under heavy PR load.