Move preview environment workflow to ci-privileged (#20872)
## Summary Slims `preview-env-dispatch.yaml` to a single dispatch and deletes `preview-env-keepalive.yaml`. The actual preview-env work moves to **twentyhq/ci-privileged#22** (must merge as a pair). ## Why Context: PR #20867 was a credential-exfil attempt against our workflows. GitHub's default fork-PR-no-secrets policy + our existing gates (`author_association` checks, `pull_request_target` checking out base, `enableScripts: false`) neutralized the actual attack — but the audit surfaced one workflow that *would* have given a malicious external PR access to a real secret if a maintainer had applied the `preview-app` label: `preview-env-keepalive.yaml`. That workflow checked out the PR head SHA, did `docker login` with `DOCKERHUB_PASSWORD`, then ran the PR's `docker-compose.yml`. A malicious compose could have mounted `~/.docker/config.json` and exfiltrated the Dockerhub credential. After this PR, that workflow lives in `twentyhq/ci-privileged` instead, paired with a rename of the credential to `DOCKERHUB_RO_TOKEN` (Dockerhub PAT with `Public Repo Read-only` scope). A read-only PAT has no exfiltration value — it's equivalent to anonymous Dockerhub access plus rate-limit headroom — so the credential lives safely on the runner without further hygiene tricks. ## What this PR does - **Modifies** `.github/workflows/preview-env-dispatch.yaml`: - Single dispatch to `twentyhq/ci-privileged` (was: self-dispatch to twenty for the env + a separate dispatch to ci-privileged for the PR comment). - `permissions: {}` (was: `contents: write`). - Drops `preview-env-keepalive.yaml` from the path-trigger list. - **Deletes** `.github/workflows/preview-env-keepalive.yaml`. The 207-line workflow now lives in `twentyhq/ci-privileged/.github/workflows/preview-env.yaml`. Net `twenty` repo change: **-204 lines / +3 lines**. ## Companion PR twentyhq/ci-privileged#22 — adds the new `preview-env.yaml`, deletes the now-redundant `post-preview-comment.yaml`. ## Secrets fallout in this repo After this PR, `DOCKERHUB_PASSWORD` in `twentyhq/twenty` secrets is only used by `ci-test-docker-compose.yaml`, where: - It evaluates to empty for fork PRs (GitHub default — secrets aren't passed to fork-PR workflows). - It's only needed for internal / merge_queue runs, for Dockerhub rate-limit headroom on base-image pulls. Recommend (separate change): also convert the twenty-side `DOCKERHUB_PASSWORD` to a `Public Repo Read-only` Dockerhub PAT, and rename it to `DOCKERHUB_RO_TOKEN` for consistency with ci-privileged. The workflow change for `ci-test-docker-compose.yaml` would just be a rename — login flow is identical for password vs. PAT. ## Test plan - [ ] Merge twentyhq/ci-privileged#22 first (so the dispatched event has a handler) - [ ] Open an internal PR touching `packages/twenty-docker/**`, confirm `Preview Environment Dispatch` runs and ci-privileged's `Preview Environment` workflow runs the docker compose + posts the URL - [ ] On an external contributor PR, apply the `preview-app` label, confirm the same flow - [ ] Confirm closing the PR doesn't break (no cleanup workflow was changed)
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -11,7 +10,6 @@ on:
|
||||
- packages/twenty-server/**
|
||||
- packages/twenty-front/**
|
||||
- .github/workflows/preview-env-dispatch.yaml
|
||||
- .github/workflows/preview-env-keepalive.yaml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -35,28 +33,15 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
- name: Dispatch preview-env to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api repos/"$REPOSITORY"/dispatches \
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=preview-environment \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[repo_full_name]=$REPOSITORY"
|
||||
|
||||
- name: Dispatch to ci-privileged for PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=preview-env-url \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.client_payload.pr_head_sha }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
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 light dev workspace (Apple only)..."
|
||||
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
|
||||
echo "❌ Seeding light 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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: ./
|
||||
Reference in New Issue
Block a user