Skip to content

ci: list every composite action in the paths filters that use it #62

ci: list every composite action in the paths filters that use it

ci: list every composite action in the paths filters that use it #62

Workflow file for this run

# CI for runner/ (mirroir-run Rust binary). Four per-push lanes:
# - runner-fast : fmt + clippy + test + diff-text smoke on linux + macos.
# - runner-smoke : process + http + web + cross_surface end-to-end (no Ollama).
# - runner-full-loop : the thirteen-phase acceptance test for the whole loop.
# - runner-e2e : full pipeline — Playwright (chromium) + real Ollama judge + mega-sample.
# Plus runner-e2e-allbrowsers on a Sunday cron, and the deny / publish audits.
name: runner
on:
push:
paths:
- 'runner/**'
- '.github/workflows/runner.yml'
- '.github/actions/setup-playwright/**'
# Every branch, not just main: this repo squash-merges locally and opens no
# pull requests, so a feature branch has no other way to reach these lanes
# before it lands. The paths filter above keeps unrelated pushes out, and the
# concurrency group below cancels superseded runs on the same branch.
pull_request:
paths:
- 'runner/**'
- '.github/workflows/runner.yml'
- '.github/actions/setup-playwright/**'
# Sunday 03:00 UTC — gates the all-browsers lane.
schedule:
- cron: '0 3 * * 0'
workflow_dispatch:
# Cancel superseded runs on the same branch / PR to save runner-minutes.
concurrency:
group: runner-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Pinned to match runner/rust-toolchain.toml (channel = "1.96.0") so the
# rustup-installed toolchain name lines up with what the toml selects —
# clippy/rustfmt components land on the exact toolchain cargo uses, and CI
# stops floating on `stable` (a float once broke clippy via collapsible_if).
# Bump this and runner/rust-toolchain.toml together; keep Cargo.toml's
# rust-version (MSRV floor) in step.
RUST_TOOLCHAIN: 1.96.0
# Suppress Cargo's interactive progress; CI logs are nicer without it.
CARGO_TERM_COLOR: always
jobs:
runner-fast:
name: fmt + clippy + test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
permissions:
contents: read
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain (${{ env.RUST_TOOLCHAIN }})
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: rustfmt, clippy
- name: Cache cargo registry + git + runner target
uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-fast-${{ matrix.os }}
- name: cargo fmt --check
working-directory: runner
run: cargo fmt --all -- --check
- name: cargo clippy (deny pedantic+nursery)
working-directory: runner
run: cargo clippy --all-targets --all-features -- -D warnings
- name: cargo test (all targets)
working-directory: runner
run: cargo test --all-targets --all-features --no-fail-fast
- name: cargo build --release (smoke)
working-directory: runner
run: cargo build --release --bin mirroir-run
- name: --validate sample scenarios (sanity)
working-directory: runner
run: |
for f in samples/mega-sample/scenarios/*.yaml; do
echo "::group::validate $f"
./target/release/mirroir-run --validate "$f"
echo "::endgroup::"
done
- name: --diff-text MATCH path
working-directory: runner
run: |
echo "Hello world from mirroir" > /tmp/diff-a.txt
echo "Hello world from mirroir" > /tmp/diff-b.txt
./target/release/mirroir-run --diff-text /tmp/diff-a.txt /tmp/diff-b.txt
# DRIFT has its own exit code (65) so a lane can tell "the semantics
# moved" from "something broke" (1). Pinning the code here keeps that
# distinction from regressing into a plain failure.
- name: --diff-text DRIFT path (expected exit 65)
working-directory: runner
run: |
echo "Completely unrelated text from a parallel universe" > /tmp/diff-c.txt
set +e
./target/release/mirroir-run --diff-text /tmp/diff-a.txt /tmp/diff-c.txt
status=$?
set -e
if [ "$status" != "65" ]; then
echo "Expected exit 65 (DRIFT) from the drift path, got $status"
exit 1
fi
echo "DRIFT path correctly exited 65"
runner-smoke:
name: process + http + web + cross_surface smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
needs: runner-fast
permissions:
contents: read
timeout-minutes: 15
env:
MIRROIR_PLAYWRIGHT_HOME: /tmp/mirroir-pw
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache cargo registry + git + runner target
uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-smoke-${{ matrix.os }}
- name: Setup Playwright + chromium
uses: ./.github/actions/setup-playwright
- name: Build release binary
working-directory: runner
run: cargo build --release --bin mirroir-run
- name: Verify python3 available (used by smoke http server)
run: python3 --version
- name: --run-scenario with process + http (spawn python http.server)
working-directory: runner
run: |
cat > /tmp/smoke-process-http.yaml <<'EOF'
version: 1
name: ci smoke — process + http
description: spawn python http.server, probe it, kill it
steps:
- spawn: { id: server, command: "python3 -m http.server 18950" }
- wait_port: { port: 18950, timeout_s: 15 }
- http:
method: GET
url: "http://127.0.0.1:18950/"
expect_status: 200
expect_body_contains: ["Directory listing"]
- kill: { id: server, grace_s: 2 }
EOF
./target/release/mirroir-run --run-scenario /tmp/smoke-process-http.yaml
- name: --run-scenario with cross_surface (pairwise equivalence)
working-directory: runner
run: |
mkdir -p /tmp/cs
printf 'Atmosphere supports WebSocket, SSE, and long-polling transports.\n' > /tmp/cs/web.txt
printf 'Atmosphere transports include WebSocket SSE and long-polling support.\n' > /tmp/cs/ios.txt
cat > /tmp/cs-pass.yaml <<'EOF'
version: 1
name: ci smoke — cross_surface pass
steps:
- cross_surface:
response_files:
- "/tmp/cs/web.txt"
- "/tmp/cs/ios.txt"
min_similarity: 0.5
EOF
./target/release/mirroir-run --run-scenario /tmp/cs-pass.yaml
- name: --run-scenario with cross_surface mismatch (expected exit 1)
working-directory: runner
run: |
printf 'Completely unrelated text about quantum physics and gravity.\n' > /tmp/cs/bad.txt
cat > /tmp/cs-fail.yaml <<'EOF'
version: 1
name: ci smoke — cross_surface fail
steps:
- cross_surface:
response_files:
- "/tmp/cs/web.txt"
- "/tmp/cs/bad.txt"
min_similarity: 0.5
EOF
set +e
./target/release/mirroir-run --run-scenario /tmp/cs-fail.yaml
status=$?
set -e
if [ "$status" != "1" ]; then
echo "Expected exit 1 from cross_surface mismatch, got $status"
exit 1
fi
echo "cross_surface mismatch path correctly exited 1"
- name: --sample with a real web scenario (web-fixture through Playwright)
working-directory: runner
run: |
# samples/web-fixture boots a python http.server over static pages and
# signs in through Playwright. There is no way to ask the runner to
# skip the web block, so a lane without Node fails here instead of
# reporting a green run over assertions that never executed.
./target/release/mirroir-run --sample samples/web-fixture
- name: --emit playwright writes the workspace a run executes
working-directory: runner
run: |
./target/release/mirroir-run --emit playwright samples/web-fixture --scenarios all
for f in target/playwright/web-fixture/login/login.spec.ts \
target/playwright/web-fixture/login/playwright.config.ts \
target/playwright/web-fixture/order-summary/order-summary.spec.ts; do
if [ ! -f "$f" ]; then
echo "--emit did not write $f"
exit 1
fi
done
grep -q "AUTO-GENERATED by mirroir-run" target/playwright/web-fixture/login/login.spec.ts
grep -q "retain-on-failure" target/playwright/web-fixture/login/playwright.config.ts
echo "--emit playwright wrote both scenario workspaces"
- name: negative web scenarios (expected exit 1)
working-directory: runner
run: |
# console-error.html: every locator resolves and both assertions hold,
# and the page throws — the compiled spec's pageerror collector is the
# only thing that catches it. wrong-selector: a locator naming nothing
# must fail by name AND leave its trace on disk.
set +e
./target/release/mirroir-run --run-scenario samples/web-fixture/scenarios/console-error.yaml
console_status=$?
./target/release/mirroir-run --run-scenario samples/web-fixture/scenarios/wrong-selector.yaml
selector_status=$?
set -e
if [ "$console_status" != "1" ]; then
echo "Expected exit 1 from the throwing page, got $console_status"
exit 1
fi
if [ "$selector_status" != "1" ]; then
echo "Expected exit 1 from the wrong selector, got $selector_status"
exit 1
fi
trace=$(find target/playwright/wrong-selector/test-results -name trace.zip | head -1)
if [ -z "$trace" ]; then
echo "The failing run left no trace.zip on disk"
find target/playwright/wrong-selector -type f
exit 1
fi
echo "Both negative paths exited 1; trace kept at $trace"
# The acceptance test for the whole runner loop, in one run and in order:
# boot, one browser invocation, the capture channel, the oracle, the
# runner-side hooks, PASS, an idempotent rerun, DRIFT, accept, green again,
# a real break, the artifacts it leaves, and the lockfile gate. The suite
# lives in runner/tests/e2e_full_loop.rs and needs a browser, which is why it
# runs here rather than in runner-fast.
runner-full-loop:
name: e2e — the whole loop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
needs: runner-fast
permissions:
contents: read
timeout-minutes: 25
env:
MIRROIR_PLAYWRIGHT_HOME: /tmp/mirroir-pw
# This is the lane that owes the loop. If its browser setup ever breaks,
# the test must go red rather than announce a skip and pass.
MIRROIR_E2E_REQUIRED: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache cargo registry + git + runner target
uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-full-loop-${{ matrix.os }}
- name: Setup Playwright + chromium
uses: ./.github/actions/setup-playwright
- name: Verify python3 available (the fixture's static server)
run: python3 --version
- name: The thirteen-phase loop
working-directory: runner
run: |
set -euo pipefail
# On a host that provisioned no browser the suite reports "NOT RUN"
# and stays green — that is what keeps runner-fast honest without an
# #[ignore]. This lane provisioned one, so a run that did not observe
# all thirteen phases is a failure here, not a skip.
cargo test --test e2e_full_loop -- --nocapture 2>&1 | tee /tmp/full-loop.log
if ! grep -q 'FULL LOOP: 13/13 phases observed' /tmp/full-loop.log; then
echo "::error::the full-loop suite did not observe all thirteen phases"
exit 1
fi
runner-e2e:
name: e2e — Playwright + Ollama + mega-sample (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
needs: runner-smoke
permissions:
contents: read
timeout-minutes: 30
env:
MIRROIR_PLAYWRIGHT_HOME: /tmp/mirroir-pw
OLLAMA_JUDGE_MODEL: qwen2.5:0.5b
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache cargo registry + git + runner target
uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-e2e-${{ matrix.os }}
- name: Setup Playwright + chromium
uses: ./.github/actions/setup-playwright
- name: Cache Ollama models
id: ollama-cache
uses: actions/cache@v4
with:
path: |
~/.ollama/models
# Bump the suffix when the model digest changes.
key: ollama-${{ runner.os }}-${{ env.OLLAMA_JUDGE_MODEL }}-v1
- name: Install Ollama (Linux)
if: runner.os == 'Linux'
run: |
curl -fsSL https://ollama.com/install.sh | sh
- name: Install Ollama (macOS)
if: runner.os == 'macOS'
run: |
# The Homebrew `ollama` FORMULA bottle omits the inference runtime, so
# the judge's chat-completion returns HTTP 500 ("llama-server binary not
# found"). The official `ollama-app` cask (~574MB) bundles the runners
# (libllama / libggml-cpu-*) and does inference in-process. Install it,
# clear the Gatekeeper quarantine so the bundled binaries run headless,
# and put its CLI on PATH so the daemon step's `ollama serve` uses it.
brew install --cask ollama-app
xattr -dr com.apple.quarantine /Applications/Ollama.app 2>/dev/null || true
OLLAMA_BIN=/Applications/Ollama.app/Contents/Resources/ollama
[ -x "$OLLAMA_BIN" ] || { echo "::error::ollama CLI not found at $OLLAMA_BIN"; exit 1; }
echo "$(dirname "$OLLAMA_BIN")" >> "$GITHUB_PATH"
- name: Start Ollama daemon
run: |
ollama serve > /tmp/ollama.log 2>&1 &
echo $! > /tmp/ollama.pid
# Wait for daemon ready (port 11434).
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:11434/api/version > /dev/null; then
echo "ollama ready after ${i}s"
break
fi
sleep 1
done
curl -sf http://127.0.0.1:11434/api/version | tee /tmp/ollama-version.json
- name: Pull judge model (${{ env.OLLAMA_JUDGE_MODEL }})
run: |
ollama pull "$OLLAMA_JUDGE_MODEL"
ollama list
- name: Build release binary
working-directory: runner
run: cargo build --release --bin mirroir-run
- name: Run mega-sample (Playwright + Ollama judge + drift + cross-surface)
working-directory: runner
run: |
./target/release/mirroir-run --sample samples/mega-sample
- name: Tail Ollama log on failure
if: failure()
run: tail -200 /tmp/ollama.log || true
- name: Stop Ollama daemon
if: always()
run: |
if [ -f /tmp/ollama.pid ]; then
kill "$(cat /tmp/ollama.pid)" 2>/dev/null || true
fi
# All three browsers + dual-OS. Heavy lane — Sunday-night cron + on-demand.
# Reuses the same Ollama setup as runner-e2e; downloads firefox + webkit
# (~600 MB total) which is why this isn't on every push.
runner-e2e-allbrowsers:
name: e2e — all browsers (${{ matrix.os }})
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
permissions:
contents: read
timeout-minutes: 45
env:
MIRROIR_PLAYWRIGHT_HOME: /tmp/mirroir-pw
OLLAMA_JUDGE_MODEL: qwen2.5:0.5b
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache cargo + target
uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-e2e-allbrowsers-${{ matrix.os }}
- name: Setup Playwright + all browsers
uses: ./.github/actions/setup-playwright
with:
browsers: chromium firefox webkit
cache-key: allbrowsers-v1
with-deps: 'true'
- name: Cache Ollama models
uses: actions/cache@v4
with:
path: ~/.ollama/models
key: ollama-${{ runner.os }}-${{ env.OLLAMA_JUDGE_MODEL }}-v1
- name: Install Ollama (Linux)
if: runner.os == 'Linux'
run: curl -fsSL https://ollama.com/install.sh | sh
- name: Install Ollama (macOS)
if: runner.os == 'macOS'
run: |
# The Homebrew `ollama` FORMULA bottle omits the inference runtime, so
# the judge's chat-completion returns HTTP 500 ("llama-server binary not
# found"). The official `ollama-app` cask (~574MB) bundles the runners
# (libllama / libggml-cpu-*) and does inference in-process. Install it,
# clear the Gatekeeper quarantine so the bundled binaries run headless,
# and put its CLI on PATH so the daemon step's `ollama serve` uses it.
brew install --cask ollama-app
xattr -dr com.apple.quarantine /Applications/Ollama.app 2>/dev/null || true
OLLAMA_BIN=/Applications/Ollama.app/Contents/Resources/ollama
[ -x "$OLLAMA_BIN" ] || { echo "::error::ollama CLI not found at $OLLAMA_BIN"; exit 1; }
echo "$(dirname "$OLLAMA_BIN")" >> "$GITHUB_PATH"
- name: Start Ollama daemon + pull model
run: |
ollama serve > /tmp/ollama.log 2>&1 &
echo $! > /tmp/ollama.pid
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:11434/api/version > /dev/null && break
sleep 1
done
ollama pull "$OLLAMA_JUDGE_MODEL"
- name: Build release binary
working-directory: runner
run: cargo build --release --bin mirroir-run
- name: Run mega-sample with three browsers
working-directory: runner
run: |
# The per-push lane keeps web scenarios chromium-only; here we patch
# the cross-browser scenario up to all three projects so firefox +
# webkit actually execute in CI (this is the lane that installs them).
sed -i.bak 's/browsers: \[chrome\]/browsers: [chrome, firefox, webkit]/' \
samples/mega-sample/scenarios/web-cross-browser.yaml
rm -f samples/mega-sample/scenarios/web-cross-browser.yaml.bak
./target/release/mirroir-run --sample samples/mega-sample
- name: Stop Ollama daemon
if: always()
run: |
if [ -f /tmp/ollama.pid ]; then
kill "$(cat /tmp/ollama.pid)" 2>/dev/null || true
fi
# Security / license / source audit over runner/ — runs the committed
# deny.toml (advisories, license allowlist, bans, crates.io-only sources).
runner-deny:
name: cargo deny check
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: cargo-deny check
uses: EmbarkStudios/cargo-deny-action@v2
with:
manifest-path: runner/Cargo.toml
arguments: --all-features
# Rehearse `cargo publish` so crates.io packaging never silently drifts from
# the source tree (catches bad metadata / excluded files / build breakage
# before a release). Mirrors runner-release.yml's publish job — no token, no
# upload.
publish-rehearsal:
name: cargo publish --dry-run
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: runner -> target
shared-key: runner-publish-rehearsal
- name: cargo publish --dry-run
working-directory: runner
run: cargo publish --dry-run --locked