diff --git a/.github/workflows/two-runner-poc.yml b/.github/workflows/two-runner-poc.yml new file mode 100644 index 0000000..3d2f4f7 --- /dev/null +++ b/.github/workflows/two-runner-poc.yml @@ -0,0 +1,171 @@ +name: Two-runner regtest PoC + +# Demonstrates running the regtest stack on a second GitHub-hosted runner rather +# than a cloud VM, reached over Tailscale. Needs no cloud account. +# +# Requires secrets.TS_AUTHKEY (reusable, ephemeral, tagged tag:ci) and a tailnet +# ACL allowing tag:ci to reach tag:ci. + +on: + workflow_dispatch: + inputs: + simulator: + description: Simulator device name + required: true + default: iPhone 17 + +permissions: + contents: read + actions: read + +concurrency: + group: two-runner-poc + cancel-in-progress: false + +env: + STACK_HOST: regtest-${{ github.run_id }} + PING_PORT: '8081' + +jobs: + # Neither job may declare `needs:` on the other. The stack job only finishes + # once the tester is done, so a dependency either way deadlocks. + stack: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - uses: tailscale/github-action@v3 + with: + authkey: ${{ secrets.TS_AUTHKEY }} + hostname: ${{ env.STACK_HOST }} + + - name: Start regtest stack + working-directory: docker + run: | + set -euo pipefail + mkdir -p lnd && chmod 777 lnd + docker compose pull + docker compose up -d + docker compose ps + + wait_for() { + local what=$1 deadline=$(( SECONDS + 300 )) + until eval "$2"; do + if (( SECONDS >= deadline )); then + echo "::error::timed out waiting for $what" + docker compose logs --no-color --tail=50 + exit 1 + fi + echo "waiting for $what ..." + sleep 5 + done + echo "✓ $what" + } + + wait_for "electrs on 60001" 'nc -z 127.0.0.1 60001' + # sudo: lnd/data is 0700 owned by the container uid, so an unprivileged + # test -f returns false whether or not the file is there. + wait_for "lnd macaroon" 'sudo test -f lnd/data/chain/bitcoin/regtest/admin.macaroon' + sudo chmod -R 777 lnd + + - name: Serve probe targets + run: | + set -euo pipefail + mkdir -p /tmp/probe + echo ok > /tmp/probe/shell.txt + echo ok > /tmp/probe/simulator.txt + nohup python3 -m http.server "$PING_PORT" --bind 0.0.0.0 --directory /tmp/probe \ + > /tmp/probe/access.log 2>&1 & + until nc -z 127.0.0.1 "$PING_PORT"; do sleep 1; done + + - name: Hold the stack up until the tester finishes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + deadline=$(( SECONDS + 2100 )) + while (( SECONDS < deadline )); do + status=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ + --jq '.jobs[] | select(.name=="tester") | .status' 2>/dev/null || echo "") + echo "tester: ${status:-not started}" + [ "$status" = "completed" ] && break + sleep 20 + done + + - name: Did the Simulator reach us? + run: | + set -euo pipefail + cat /tmp/probe/access.log || true + # simulator.txt only — the tester's shell fetches shell.txt, so it + # cannot satisfy this. + if grep -q "GET /simulator.txt" /tmp/probe/access.log; then + echo "✓ request from inside the Simulator recorded on this runner" + else + echo "::error::no request from the Simulator reached the stack runner" + exit 1 + fi + + tester: + runs-on: macos-latest + timeout-minutes: 45 + steps: + - uses: tailscale/github-action@v3 + with: + authkey: ${{ secrets.TS_AUTHKEY }} + hostname: tester-${{ github.run_id }} + + - name: Wait for the stack runner + run: | + set -euo pipefail + # Peer address from `tailscale status`, not MagicDNS: the action points + # macOS DNS at a network service named "Ethernet", which these runners + # do not have, so names never resolve here. + deadline=$(( SECONDS + 900 )) + while :; do + ip=$(tailscale status --json 2>/dev/null \ + | jq -r --arg h "$STACK_HOST" \ + 'first(.Peer[]? | select(.HostName == $h) | .TailscaleIPs[0]) // empty' \ + || true) + [ -n "$ip" ] && break + if (( SECONDS >= deadline )); then + echo "::error::peer $STACK_HOST never joined the tailnet" + tailscale status || true + exit 1 + fi + echo "waiting for peer $STACK_HOST ..." + sleep 10 + done + echo "STACK_IP=$ip" >> "$GITHUB_ENV" + echo "✓ $STACK_HOST is $ip" + + until nc -z -w 5 "$ip" 60001 2>/dev/null; do + if (( SECONDS >= deadline )); then + echo "::error::$ip:60001 never became reachable" + tailscale ping -c 3 "$ip" || true + exit 1 + fi + echo "waiting for electrs ..." + sleep 10 + done + echo "✓ electrs reachable" + + - name: Reach the stack from the runner shell + run: | + set -euo pipefail + for port in 60001 9735 8080; do + if nc -z -w 5 "$STACK_IP" "$port"; then echo "✓ $port"; else echo "::error::$port unreachable"; exit 1; fi + done + curl -fsS --max-time 20 "http://${STACK_IP}:${PING_PORT}/shell.txt" + + - name: Reach the stack from inside the Simulator + run: | + set -euo pipefail + xcrun simctl boot "${{ github.event.inputs.simulator }}" || true + if ! xcrun simctl bootstatus "${{ github.event.inputs.simulator }}" -b; then + echo "::error::Simulator did not boot — says nothing about routing" + exit 1 + fi + xcrun simctl openurl booted "http://${STACK_IP}:${PING_PORT}/simulator.txt" \ + || echo "::warning::openurl returned non-zero; the stack job's access log decides" + sleep 20