name: Contract Guard
on:
pull_request:
branches: [main, master]
push:
branches: [main, master]
concurrency:
group: contract-guard-${{ github.ref }}
cancel-in-progress: true
env:
MIN_SCORE: "0.6"
SPEC_DIR: "specs"
CODE_DIR: "."
jobs:
rust-checks:
name: Rust Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Test
run: cargo test --quiet
contract-guard:
name: Contract Verification
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache agent-spec binary
id: cache-agent-spec
uses: actions/cache@v4
with:
path: ~/.cargo/bin/agent-spec
key: agent-spec-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Install agent-spec
if: steps.cache-agent-spec.outputs.cache-hit != 'true'
run: cargo install --path . --bin agent-spec
- name: Run Contract Guard
id: guard
run: |
echo "::group::Contract Guard Output"
agent-spec guard \
--spec-dir "$SPEC_DIR" \
--code "$CODE_DIR" \
--change-scope worktree \
--min-score "$MIN_SCORE" \
2>&1 | tee /tmp/guard-output.txt
GUARD_EXIT=${PIPESTATUS[0]}
echo "::endgroup::"
echo "exit_code=$GUARD_EXIT" >> "$GITHUB_OUTPUT"
exit $GUARD_EXIT
continue-on-error: true
- name: Generate Contract Summaries
id: explain
run: |
SUMMARY=""
SPEC_COUNT=0
PASS_COUNT=0
FAIL_COUNT=0
for spec in "$SPEC_DIR"/*.spec "$SPEC_DIR"/*.spec.md; do
[ -f "$spec" ] || continue
SPEC_COUNT=$((SPEC_COUNT + 1))
SPEC_NAME=$(basename "$spec" .spec.md)
SPEC_NAME=$(basename "$SPEC_NAME" .spec)
# Run explain and capture output
EXPLAIN_OUTPUT=$(agent-spec explain "$spec" \
--code "$CODE_DIR" \
--format markdown 2>/dev/null || echo "⚠️ explain failed for $spec")
# Check if this spec passes
if agent-spec lifecycle "$spec" \
--code "$CODE_DIR" \
--change-scope worktree \
--format json 2>/dev/null | grep -q '"passed": true'; then
PASS_COUNT=$((PASS_COUNT + 1))
STATUS="✅"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
STATUS="❌"
fi
SUMMARY="${SUMMARY}
<details>
<summary>${STATUS} ${SPEC_NAME}</summary>
${EXPLAIN_OUTPUT}
</details>
"
done
# Write the full summary to a file for later steps
{
echo "## 📋 Contract Guard Report"
echo ""
if [ "$FAIL_COUNT" -eq 0 ] && [ "$SPEC_COUNT" -gt 0 ]; then
echo "**✅ All ${SPEC_COUNT} contract(s) passed.**"
elif [ "$SPEC_COUNT" -eq 0 ]; then
echo "**ℹ️ No .spec/.spec.md files found in \`${SPEC_DIR}/\`.**"
else
echo "**❌ ${FAIL_COUNT} of ${SPEC_COUNT} contract(s) failed.**"
fi
echo ""
echo "| Total | Passed | Failed |"
echo "| --- | --- | --- |"
echo "| ${SPEC_COUNT} | ${PASS_COUNT} | ${FAIL_COUNT} |"
echo ""
echo "$SUMMARY"
} > /tmp/contract-summary.md
# Also write to GitHub Step Summary (visible in Actions tab)
cat /tmp/contract-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Post Contract Summary to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const summary = fs.readFileSync('/tmp/contract-summary.md', 'utf8');
// Find existing agent-spec comment to update (avoid duplicates)
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '<!-- agent-spec-contract-guard -->';
const body = `${marker}\n${summary}`;
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}
- name: Check Guard Result
if: steps.guard.outputs.exit_code != '0'
run: |
echo "::error::Contract Guard failed. See the Contract Summary above for details."
exit 1