Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .github/workflows/two-runner-poc.yml
Original file line number Diff line number Diff line change
@@ -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