Agent API tests fail in three ways: the API is broken, the verification email is late, or yesterday’s state leaked into today’s run. GitHub Actions shows all three as the same red check. The workflow becomes expensive to maintain when you cannot tell which failure mode you are debugging.
The engineering problem is not about building a huge end-to-end framework. It is about giving each test run its own inputs, inbox identity, and evidence trail. This article walks through the plumbing: how to create disposable test environments for agent workflows that call external APIs, without spinning up expensive cloud resources or maintaining complex mocking infrastructure.
The Coordination Problem
A typical agent signup smoke test crosses several boundaries:
- Create a user account
- Wait for a verification email
- Open the link in the email
- Call an authenticated API endpoint
Each boundary introduces state. A reused email address may already have an account. A shared mailbox may contain an old message. A retry may create a second user. Then the final assertion says expected 200, got 409, which is technically accurate but not useful for debugging.
The failure mode is ambiguous. You cannot tell if the API rejected the request, if the email service dropped the message, or if the test accidentally reused a previous identity.
Isolation Primitives in GitHub Actions
GitHub Actions provides three primitives that map directly to agent testing needs:
Run identity: github.run_id and github.run_attempt give you a unique identifier for each workflow execution. Derive test identities from this value.
Ephemeral secrets: Store API keys and inbox credentials in repository secrets. Reference them in env blocks without printing them to logs.
Artifact upload: The actions/upload-artifact action with if: always() captures evidence even when the test fails.
These primitives are cheap. You do not pay for compute when the workflow is idle. You do not maintain a separate test environment. Each run gets a fresh context.
Workflow Contract
Start by making run inputs explicit. The test should know its run ID and derive a unique address from it:
name: API smoke
on:
workflow_dispatch:
schedule:
- cron: "17 */6 * * *"
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run isolated smoke test
env:
RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
API_BASE_URL: ${{ secrets.SMOKE_API_BASE_URL }}
run: ./scripts/smoke-api.sh
- name: Upload evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-evidence-${{ github.run_id }}
path: artifacts/smoke/
The if: always() step matters. When the test fails, you need the evidence artifact to reproduce the failure locally. Without it, you are guessing.
Disposable Inbox Pattern
Agents that interact with email need an inbox that:
- Accepts messages for a unique address
- Provides an API to poll for new messages
- Expires after the test completes
Services like Mailinator, Maildrop, or self-hosted Mailhog provide this. The key is to generate the address from the run ID:
#!/bin/bash
set -euo pipefail
RUN_ID="${RUN_ID:-local-$(date +%s)}"
TEST_EMAIL="smoke-${RUN_ID}@mailinator.com"
EVIDENCE_DIR="artifacts/smoke"
mkdir -p "$EVIDENCE_DIR"
echo "$RUN_ID" > "$EVIDENCE_DIR/run-id.txt"
# Create user with unique email
curl -X POST "$API_BASE_URL/signup" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$TEST_EMAIL\",\"password\":\"test123\"}" \
| tee "$EVIDENCE_DIR/signup-response.json"
# Poll for verification email
for i in {1..30}; do
INBOX=$(curl -s "https://mailinator.com/api/v2/domains/mailinator.com/inboxes/smoke-${RUN_ID}" \
-H "Authorization: Bearer $MAILINATOR_TOKEN")
echo "$INBOX" > "$EVIDENCE_DIR/inbox-poll-${i}.json"
if echo "$INBOX" | jq -e '.msgs[0].id' > /dev/null; then
break
fi
sleep 2
done
This pattern gives you three things:
- Unique identity per run: No collision with previous tests
- Traceable messages: Each poll attempt is saved to the evidence directory
- Bounded lifetime: The inbox expires automatically after a few hours
State Management for Multi-Step Flows
Agent workflows often require multiple API calls with shared state. A login token from step one must be available in step three. The naive approach is to store tokens in environment variables, but this leaks credentials into logs when the script fails.
Instead, write state to a local file and reference it by path:
# Step 1: Extract verification link
VERIFY_LINK=$(jq -r '.msgs[0].parts[0].body' "$EVIDENCE_DIR/inbox-poll-30.json" \
| grep -oP 'https://[^ ]+/verify/[^ ]+')
echo "$VERIFY_LINK" > "$EVIDENCE_DIR/verify-link.txt"
# Step 2: Follow link and capture token
curl -s "$VERIFY_LINK" \
| jq -r '.token' \
> "$EVIDENCE_DIR/auth-token.txt"
# Step 3: Call authenticated endpoint
AUTH_TOKEN=$(cat "$EVIDENCE_DIR/auth-token.txt")
curl -H "Authorization: Bearer $AUTH_TOKEN" \
"$API_BASE_URL/protected" \
| tee "$EVIDENCE_DIR/protected-response.json"
When the test fails, the evidence artifact contains every intermediate value. You can reproduce the failure locally by reading the same files.
Observability Without Logs
GitHub Actions logs are linear and verbose. When a test fails, you scroll through hundreds of lines looking for the relevant error. The evidence artifact pattern inverts this: each step writes structured output to a file, and the artifact becomes a timeline.
A useful convention is to name files by step and attempt:
artifacts/smoke/
run-id.txt
signup-response.json
inbox-poll-1.json
inbox-poll-2.json
...
inbox-poll-30.json
verify-link.txt
auth-token.txt
protected-response.json
When the test fails, download the artifact and inspect the last file written. If inbox-poll-30.json is empty, the email never arrived. If protected-response.json contains a 401, the token was invalid.
Failure Mode Taxonomy
| Failure Type | Evidence Signal | Debugging Path |
|---|---|---|
| API rejected signup | signup-response.json contains error | Check API logs for validation failure |
| Email never arrived | All inbox-poll-*.json files are empty | Check email service status, verify sender domain |
| Email arrived late | inbox-poll-30.json is empty but inbox-poll-31.json has message | Increase poll timeout or reduce poll interval |
| Verification link expired | protected-response.json contains 401 | Check token TTL, reduce time between steps |
| State collision | signup-response.json contains “email already exists” | Verify RUN_ID is unique, check for leaked state |
This taxonomy turns a red check into a specific hypothesis. You know which boundary failed and what to inspect next.
Local Reproduction
The evidence artifact makes failures reproducible. Download the artifact, extract it, and replay the failing step:
# Download artifact from GitHub Actions UI
unzip smoke-evidence-12345.zip -d artifacts/smoke/
# Replay the protected endpoint call
AUTH_TOKEN=$(cat artifacts/smoke/auth-token.txt)
curl -v -H "Authorization: Bearer $AUTH_TOKEN" \
"$API_BASE_URL/protected"
If the local replay succeeds, the failure was transient (network timeout, rate limit). If it fails with the same error, the failure is deterministic and you can debug it without re-running the entire workflow.
Security Boundaries
Disposable test environments still need to respect security boundaries:
Never log credentials: Use set +x in shell scripts before handling tokens. Write tokens to files, not stdout.
Rotate secrets regularly: Even if the inbox is disposable, the API key that accesses it should rotate every 90 days.
Limit scope: The test API key should only have permission to create test users and call test endpoints. Do not reuse production credentials.
Clean up state: If the test creates resources (users, files, database rows), delete them in a cleanup step. Use if: always() to ensure cleanup runs even when the test fails.
When to Use This Pattern
This pattern works when:
- Your agent calls external APIs that require email verification or multi-step flows
- You need to test the integration without mocking the external service
- Test failures are expensive to debug because you cannot reproduce them locally
- You want to run tests on a schedule without maintaining a persistent test environment
Avoid this pattern when:
- The external API has strict rate limits (you will hit them quickly)
- The API does not support disposable identities (some services block temporary email domains)
- Your test needs to verify behavior over days or weeks (GitHub Actions artifacts expire after 90 days)
- You need to test concurrent agent behavior (this pattern is serial)
Technical Verdict
GitHub Actions workflows with disposable inboxes and evidence artifacts give you cheap isolation for agent API tests. The primitives are simple: derive unique identities from run IDs, write structured output to files, and upload artifacts unconditionally. The payoff is that failures become debuggable without re-running the entire pipeline.
Use this pattern when you need to test agent integrations with external APIs and the cost of maintaining a persistent test environment exceeds the cost of occasional transient failures. Avoid it when the external service does not support disposable identities or when you need to test long-running agent behavior.
The workflow is not a replacement for unit tests or contract tests. It is a smoke test that verifies the plumbing between your agent and the external world. When it fails, you know which boundary broke and have the evidence to fix it.
Source Links
- GitHub Actions API Tests Need Cheap Isolation (primary source)