3d8207af0f
## Summary `bore.pub`'s public server has been increasingly unreliable: tunnels register fine on the runner side (our `Create Tunnel` step always succeeds), but the bore.pub side later stops accepting inbound traffic, leaving the preview environment unreachable for the rest of the 5h keep-alive window with no signal back to the runner. Recent symptom: `curl http://bore.pub:50422` → `Couldn't connect to server`, while the corresponding action keeps sleeping. This PR replaces the `codetalkio/expose-tunnel` action with a direct invocation of `cloudflared` running an account-less [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/). The tunnel is served from Cloudflare's edge so reliability is materially better, and the URL is HTTPS by default (`https://*.trycloudflare.com`), which also eliminates the mixed-content issues we'd hit when `SERVER_URL` was `http://bore.pub:port`. ## What changes - `Create Tunnel` step now: - Downloads a pinned `cloudflared` binary (`2026.3.0`) - Starts `cloudflared tunnel --url http://localhost:3000` in the background, logging to `$RUNNER_TEMP/cloudflared.log` - Polls the log for `https://<name>.trycloudflare.com` (up to 2 minutes), failing fast if the process exits - Writes the URL to the `tunnel-url` step output — same name as before, so no downstream changes needed - `Cleanup` step kills the `cloudflared` process for hygiene ## What stays the same - `SERVER_URL` plumbing through `.env` → `docker compose up` - `tunnel-url` artifact - `$GITHUB_STEP_SUMMARY` formatting - PR-comment dispatch (`twentyhq/ci-privileged`) - 5h keep-alive sleep ## Trade-offs - Quick tunnels are explicitly labelled by Cloudflare for "testing/development" use without an SLA. For our preview-env use case (ephemeral, per-PR) that fits, but if we ever need stable URLs on a custom domain we'd move to *named* tunnels — same `cloudflared` binary, plus a free Cloudflare account + delegated domain + a service token stored as a repo secret. Strictly additive when we want it. - `cloudflared` is pinned to `2026.3.0` to avoid surprise breakage from upstream releases. Bumping is a one-line change. ## Testing **Locally (macOS) — verified end-to-end:** - `cloudflared tunnel --url http://localhost:18080` against a `python3 -m http.server` - Regex `https://[a-zA-Z0-9-]+\.trycloudflare\.com` correctly extracts the URL from the log - `curl $URL/` returns the upstream server's response (HTTP 200, ~0.5s) - Process supervision: if `cloudflared` dies mid-wait, the step fails fast instead of hitting the 2-min timeout **Validation:** - `actionlint` passes (the remaining shellcheck warnings are in pre-existing steps, not my changes) - `shellcheck` on the new Create Tunnel script: clean **What's not testable from a PR (and why):** - The full keep-alive workflow runs on `repository_dispatch`, which always uses the workflow file from `main`. So the cloudflared logic only runs against PR contents *after* merge. - I'll trigger a one-off Ubuntu-runner test of just the install + URL extraction logic via a throwaway branch (`workflow_dispatch`-only) and link the run here before this merges. ## Test plan - [ ] Throwaway run validates: cloudflared installs on `ubuntu-latest`, prints the URL, regex matches, tunnel is reachable from outside the runner. - [ ] After merge, the next PR's preview environment uses `*.trycloudflare.com` instead of `bore.pub:port`, and the URL stays reachable for the full 5h window. - [ ] PR-comment bot still posts the preview URL correctly (link should now be `https://*.trycloudflare.com`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
187 lines
7.1 KiB
YAML
187 lines
7.1 KiB
YAML
name: 'Preview Environment Keep Alive'
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
on:
|
|
repository_dispatch:
|
|
types: [preview-environment]
|
|
|
|
jobs:
|
|
preview-environment:
|
|
timeout-minutes: 310
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout PR
|
|
uses: actions/checkout@v4
|
|
with:
|
|
ref: ${{ github.event.client_payload.pr_head_sha }}
|
|
|
|
- name: Login to Docker Hub
|
|
uses: docker/login-action@v3
|
|
with:
|
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
|
|
- name: Run compose setup
|
|
run: |
|
|
echo "Patching docker-compose.yml..."
|
|
# change image to localbuild using yq
|
|
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
|
|
|
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
|
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
|
|
|
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
|
|
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
|
|
|
|
echo "Setting up .env file..."
|
|
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
|
|
|
|
echo "Generating secrets..."
|
|
echo "" >> packages/twenty-docker/.env
|
|
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
|
|
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
|
|
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
|
|
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
|
|
echo "Docker compose build..."
|
|
cd packages/twenty-docker/
|
|
docker compose build
|
|
working-directory: ./
|
|
|
|
- name: Create Tunnel
|
|
id: expose-tunnel
|
|
env:
|
|
CLOUDFLARED_VERSION: '2026.3.0'
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Install cloudflared (pinned for reproducibility)
|
|
sudo curl -fsSL -o /usr/local/bin/cloudflared \
|
|
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
|
|
sudo chmod +x /usr/local/bin/cloudflared
|
|
cloudflared --version
|
|
|
|
# Start an account-less "quick tunnel" pointing at the server container.
|
|
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
|
|
log_file="$RUNNER_TEMP/cloudflared.log"
|
|
: > "$log_file"
|
|
|
|
cloudflared tunnel \
|
|
--url http://localhost:3000 \
|
|
--no-autoupdate \
|
|
--logfile "$log_file" \
|
|
--loglevel info \
|
|
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
|
|
|
|
pid=$!
|
|
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
|
|
echo "cloudflared PID: $pid"
|
|
|
|
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
|
|
url=''
|
|
for _ in $(seq 1 60); do
|
|
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
|
|
[ -n "$url" ] && break
|
|
if ! kill -0 "$pid" 2>/dev/null; then
|
|
echo "cloudflared exited before producing a URL"
|
|
cat "$log_file" || true
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
if [ -z "$url" ]; then
|
|
echo "Timed out waiting for tunnel URL"
|
|
cat "$log_file" || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "Tunnel URL: $url"
|
|
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Start services with correct SERVER_URL
|
|
env:
|
|
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
|
run: |
|
|
cd packages/twenty-docker/
|
|
|
|
echo "Setting SERVER_URL to $TUNNEL_URL"
|
|
sed -i '/SERVER_URL=/d' .env
|
|
echo "" >> .env
|
|
echo "SERVER_URL=$TUNNEL_URL" >> .env
|
|
|
|
# Start the services
|
|
echo "Docker compose up..."
|
|
docker compose up -d || {
|
|
echo "Docker compose failed to start"
|
|
docker compose logs
|
|
exit 1
|
|
}
|
|
|
|
echo "Waiting for services to be ready..."
|
|
count=0
|
|
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
|
|
sleep 5
|
|
count=$((count+1))
|
|
if [ $count -gt 60 ]; then
|
|
echo "Timeout waiting for services to be ready"
|
|
docker compose logs
|
|
exit 1
|
|
fi
|
|
echo "Still waiting for services... ($count/60)"
|
|
done
|
|
|
|
echo "All services are up and running!"
|
|
working-directory: ./
|
|
|
|
- name: Seed Dev Workspace
|
|
run: |
|
|
cd packages/twenty-docker/
|
|
echo "Seeding full dev workspace..."
|
|
if ! docker compose exec -T server yarn command:prod -- workspace:seed:dev; then
|
|
echo "❌ Seeding full dev workspace failed. Dumping server logs..."
|
|
docker compose logs server
|
|
exit 1
|
|
fi
|
|
working-directory: ./
|
|
|
|
- name: Output tunnel URL
|
|
env:
|
|
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
|
run: |
|
|
echo "✅ Preview Environment Ready!"
|
|
echo "🔗 Preview URL: $TUNNEL_URL"
|
|
echo "⏱️ This environment will be available for 5 hours"
|
|
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
|
|
echo "$TUNNEL_URL" > tunnel-url.txt
|
|
|
|
- name: Upload tunnel URL artifact
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: tunnel-url
|
|
path: tunnel-url.txt
|
|
retention-days: 1
|
|
|
|
- name: Keep tunnel alive for 5 hours
|
|
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
run: |
|
|
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
|
|
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
|
|
fi
|
|
cd packages/twenty-docker/
|
|
docker compose down -v
|
|
working-directory: ./
|