craftbag 0.1.0

Discover and load Agent Skills (SKILL.md) for CLI and MCP hosts
Documentation
name: CI

# One compile per tree. pull_request (and merge_group if a queue is
# added) is the matrix. Do not rebuild on push to main or on a tag of
# that same commit. workflow_dispatch is the escape hatch. A later
# release job may compile once for signing; it must not re-run this
# matrix.
on:
  pull_request:
  merge_group: {}
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
  contents: read
  pull-requests: read

env:
  CARGO_INCREMENTAL: "0"
  RUSTFLAGS: "-D warnings"

jobs:
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      rust: ${{ steps.filter.outputs.rust || 'true' }}
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
        id: filter
        if: github.event_name == 'pull_request'
        with:
          filters: |
            rust:
              - 'src/**'
              - 'crates/**'
              - 'tests/**'
              - 'Cargo.toml'
              - 'Cargo.lock'
              - 'rustfmt.toml'
              - 'rust-toolchain.toml'
              - '.gitignore'
              - 'deny.toml'
              - '.github/workflows/**'
              - '.github/zizmor.yml'
              - 'factory/scripts/deny-check.sh'
              - 'Makefile'
              - 'AGENTS.md'
              - 'README.md'
              - 'factory/BLINE_CONSUMER.md'
              - 'fuzz/**'

  lint:
    name: Lint
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      CARGO_TARGET_DIR: target/ci-lint
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
        with:
          toolchain: "1.85"
          components: rustfmt, clippy
      - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
        with:
          tool: cargo-deny@0.20.2
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
        with:
          save-if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' }}
      - run: cargo fmt --check
      - run: cargo clippy --locked --workspace --all-targets -- -D warnings
      - run: bash factory/scripts/deny-check.sh

  test:
    name: Test
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      CARGO_TARGET_DIR: target/ci-test
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
        with:
          toolchain: "1.85"
      - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
        with:
          tool: cargo-nextest@0.9.143
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
        with:
          save-if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' }}
      - run: cargo nextest run --locked --workspace
      - run: cargo test --locked --workspace --doc

  fuzz-smoke:
    name: Fuzz smoke
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      RUSTUP_TOOLCHAIN: nightly
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
        with:
          toolchain: nightly
          targets: x86_64-unknown-linux-gnu
      - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
        with:
          tool: cargo-fuzz@0.13.2
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
        with:
          workspaces: |
            .
            fuzz
          shared-key: fuzz
          cache-on-failure: true
          save-if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' }}
      # ASAN needs glibc. cargo-fuzz may default to musl.
      - run: cargo fuzz run parse_skill --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""
      - run: cargo fuzz run xml_catalog --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""
      - run: cargo fuzz run list_format --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""
      - run: cargo fuzz run catalog_format --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""
      - run: cargo fuzz run vendor_token --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""
      - run: cargo fuzz run sanitize_error_token --target x86_64-unknown-linux-gnu -- -max_total_time=5
        env:
          RUSTFLAGS: ""

  test-windows:
    name: Test (Windows)
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: windows-latest
    timeout-minutes: 25
    env:
      CARGO_TARGET_DIR: target/ci-test
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
        with:
          toolchain: "1.85"
      - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
        with:
          tool: cargo-nextest@0.9.143
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
        with:
          save-if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' }}
      - run: cargo nextest run --locked --workspace
      - run: cargo test --locked --workspace --doc

  test-macos:
    name: Test (macOS)
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: macos-latest
    timeout-minutes: 25
    env:
      CARGO_TARGET_DIR: target/ci-test
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
        with:
          toolchain: "1.85"
      - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
        with:
          tool: cargo-nextest@0.9.143
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
        with:
          save-if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' }}
      - run: cargo nextest run --locked --workspace
      - run: cargo test --locked --workspace --doc

  workflows:
    name: Workflows
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
      - name: zizmor
        run: |
          python3 -m pip install --user 'zizmor==1.29.0'
          "$HOME/.local/bin/zizmor" --min-severity=high .github/workflows
      - run: bash factory/scripts/write-ledger.sh --self-test

  gitleaks:
    name: Gitleaks
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Install gitleaks
        run: |
          VER=8.30.1
          curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" \
            | tar xz -C /usr/local/bin gitleaks
      - run: gitleaks detect --source . --exit-code 1

  ci:
    name: CI
    if: always()
    needs: [changes, lint, test, fuzz-smoke, test-windows, test-macos, workflows, gitleaks]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2
        with:
          egress-policy: audit
      - name: Check job results
        run: |
          for r in "${{ needs.changes.result }}" "${{ needs.lint.result }}" \
                   "${{ needs.test.result }}" \
                   "${{ needs.fuzz-smoke.result }}" \
                   "${{ needs.test-windows.result }}" "${{ needs.test-macos.result }}" \
                   "${{ needs.workflows.result }}" "${{ needs.gitleaks.result }}"; do
            if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
              echo "::error::needed job $r"
              exit 1
            fi
          done