From 2f4ebf8160934847c68d63e48ffe2de750e2f9c0 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 23 May 2026 13:37:37 +0200 Subject: [PATCH] Move preview environment workflow to ci-privileged (#20872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .github/workflows/preview-env-dispatch.yaml | 21 +-- .github/workflows/preview-env-keepalive.yaml | 186 ------------------- 2 files changed, 3 insertions(+), 204 deletions(-) delete mode 100644 .github/workflows/preview-env-keepalive.yaml diff --git a/.github/workflows/preview-env-dispatch.yaml b/.github/workflows/preview-env-dispatch.yaml index d7554b31ae..344030dc75 100644 --- a/.github/workflows/preview-env-dispatch.yaml +++ b/.github/workflows/preview-env-dispatch.yaml @@ -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" diff --git a/.github/workflows/preview-env-keepalive.yaml b/.github/workflows/preview-env-keepalive.yaml deleted file mode 100644 index 1e73f14ece..0000000000 --- a/.github/workflows/preview-env-keepalive.yaml +++ /dev/null @@ -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: ./