diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2bbffc2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,228 @@ +name: Build - Release + +on: + # main builds (no publish) so the release cache is seeded on main for PRs to restore. + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + paths: + - "src/**" + - "tests/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/release.yml" + workflow_dispatch: + inputs: + bump: + description: "Version bump type (leave 'auto' to detect from commit messages)" + required: true + type: choice + default: auto + options: + - auto + - patch + - minor + - major + +permissions: + contents: write + +# On pull requests the build jobs run (to validate the release build) but compute/publish +# don't; cancel superseded PR runs. Tag/dispatch runs key on run_id, so they never cancel. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + # Dispatch-only: work out the next version. Nothing is written or pushed here — the + # version is stamped into each build's working tree, and only committed + tagged by + # `publish` after the builds succeed, so a failed build never bumps the version or + # leaves a dangling tag. + compute: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.version.outputs.new }} + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 # history + tags for `git describe` + + - name: Detect bump type from commits + id: detect + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + RANGE="HEAD" + [ -n "$LAST_TAG" ] && RANGE="${LAST_TAG}..HEAD" + COMMITS=$(git log --pretty=format:"%s" "$RANGE") + BUMP="patch" + if echo "$COMMITS" | grep -qiE "^feat(\(.*\))?!:|BREAKING CHANGE"; then + BUMP="major" + elif echo "$COMMITS" | grep -qiE "^feat(\(.*\))?:"; then + BUMP="minor" + elif echo "$COMMITS" | grep -qiE "^(fix|perf)(\(.*\))?:"; then + BUMP="patch" + fi + echo "detected=$BUMP" >> "$GITHUB_OUTPUT" + + - name: Resolve bump type + id: bump + run: | + BUMP="${{ inputs.bump }}" + [ "$BUMP" = "auto" ] && BUMP="${{ steps.detect.outputs.detected }}" + echo "type=$BUMP" >> "$GITHUB_OUTPUT" + + - name: Compute new version + id: version + run: | + CURRENT=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)"/\1/') + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + case "${{ steps.bump.outputs.type }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + NEW="${MAJOR}.${MINOR}.${PATCH}" + echo "new=$NEW" >> "$GITHUB_OUTPUT" + echo "tag=v${NEW}" >> "$GITHUB_OUTPUT" + + build-linux: + name: Build Linux (${{ matrix.arch }}) + needs: [compute] + if: | + !cancelled() && + (startsWith(github.ref, 'refs/tags/') || needs.compute.result == 'success' + || github.event_name == 'pull_request' || github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + arch: x86_64 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: release-${{ matrix.target }} + + # Stamp the computed version into the working tree (no commit) so the binary embeds + # it via CARGO_PKG_VERSION. Skipped on tag/PR builds, which use Cargo.toml as-is. + - name: Set release version + if: needs.compute.outputs.version != '' + run: perl -i -pe 'if (!$d && s/^version = ".*"/version = "${{ needs.compute.outputs.version }}"/) { $d = 1 }' Cargo.toml + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Package + run: | + mkdir -p dist + cp "target/${{ matrix.target }}/release/agentcap" "dist/agentcap-${{ matrix.arch }}-linux" + chmod +x dist/* + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-${{ matrix.arch }} + path: dist/ + + build-macos: + name: Build macOS (arm64) + needs: [compute] + if: | + !cancelled() && + (startsWith(github.ref, 'refs/tags/') || needs.compute.result == 'success' + || github.event_name == 'pull_request' || github.ref == 'refs/heads/main') + runs-on: macos-14 + timeout-minutes: 45 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: aarch64-apple-darwin + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: release-aarch64-apple-darwin + + - name: Set release version + if: needs.compute.outputs.version != '' + run: perl -i -pe 'if (!$d && s/^version = ".*"/version = "${{ needs.compute.outputs.version }}"/) { $d = 1 }' Cargo.toml + + - name: Build + run: cargo build --release --target aarch64-apple-darwin + + - name: Package + run: | + mkdir -p dist + cp target/aarch64-apple-darwin/release/agentcap dist/agentcap-arm64-apple-darwin + chmod +x dist/* + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-arm64 + path: dist/ + + # Runs only after both builds succeed. On dispatch it commits the version bump, tags, + # and pushes — so the version/tag only ever advance for a release that actually built. + # On a tag push there's nothing to commit; it just publishes at that tag. + publish: + name: Publish Release + needs: [compute, build-linux, build-macos] + if: | + !cancelled() && + needs.build-linux.result == 'success' && + needs.build-macos.result == 'success' && + (startsWith(github.ref, 'refs/tags/') || needs.compute.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + # Dispatch only: bump now that the builds passed, matching the version stamped into + # the artifacts, then tag and push. + - name: Commit and tag release + if: needs.compute.outputs.version != '' + run: | + perl -i -pe 'if (!$d && s/^version = ".*"/version = "${{ needs.compute.outputs.version }}"/) { $d = 1 }' Cargo.toml + perl -i -pe 'if ($h) { s/^version = .*/version = "${{ needs.compute.outputs.version }}"/; $h = 0 } $h = 1 if /^name = "agentcap"$/' Cargo.lock + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Cargo.toml Cargo.lock + git commit -m "chore: release ${{ needs.compute.outputs.tag }}" + git tag "${{ needs.compute.outputs.tag }}" + git push origin HEAD:main --tags + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: artifacts + pattern: "{linux-*,macos-*}" + merge-multiple: true + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.compute.outputs.tag || github.ref_name }}" + gh release create "$TAG" artifacts/* \ + --repo "${{ github.repository }}" \ + --title "$TAG" \ + --generate-notes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6549d2d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,72 @@ +name: Test - Unit & Integration + +# Hermetic tests only: unit tests + the loopback proxy integration test. No +# network, podman, or model server. The full agent×model end-to-end ("live") +# tests are a separate, resource-heavy category — see `linux-live-tests.yml`. + +on: + pull_request: + branches: [main] + paths: + - "src/**" + - "tests/**" + - "Cargo.toml" + - "Cargo.lock" + - "rustfmt.toml" + - ".github/workflows/test.yml" + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + lint-unit: + name: Lint & Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: test + + - name: Format check + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Unit tests + run: cargo test --lib + + integration: + name: Integration Tests + needs: lint-unit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: test + + # Spins a mock upstream over loopback and drives CaptureProxy through it. + - name: Integration tests + run: cargo test --test proxy diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ea92d11 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pin the toolchain so local `cargo fmt/clippy/test` matches CI (which pins the +# same version in .github/workflows). Bump here and in the workflows together. +[toolchain] +channel = "1.95.0" +components = ["rustfmt", "clippy"] diff --git a/src/inspect/sources.rs b/src/inspect/sources.rs index 65f9ae6..6782005 100644 --- a/src/inspect/sources.rs +++ b/src/inspect/sources.rs @@ -199,7 +199,7 @@ pub fn enumerate_workspace_requests(run_dir: &Path) -> Vec { let row = build_req_row(&run_id, rid, req, status, &mut chains); rows.push(row); } - rows.sort_by(|a, b| (a.run_id.clone(), a.captured_at).cmp(&(b.run_id.clone(), b.captured_at))); + rows.sort_by_key(|a| (a.run_id.clone(), a.captured_at)); rows }