## Why
PR preview environments were broken. The dispatch half (this repo) was
fine, but the receiving `preview-env.yaml` in **ci-privileged** failed
on every run — and ci-privileged is a **private** repo, so its 5h
keepalive job bills paid Actions minutes anyway.
The intended design (started but never wired up) runs the preview env on
the **public** `twentyhq/ci-public` repo, where Actions minutes are
free, and keeps no privileged token on the runner that executes
PR-controlled code. This finishes that migration.
## What this PR does
Repoints `preview-env-dispatch.yaml` to dispatch `preview-env.yaml` on
**ci-public** instead of ci-privileged. The dispatcher app token is
simply retargeted (still `actions:write` only, via `workflow_dispatch`).
## Companion PRs (must land together)
- **twentyhq/ci-public** — converts `preview-env.yaml` to
`workflow_dispatch`; dispatches the tunnel URL back to ci-privileged for
the PR comment before any PR code runs.
- **twentyhq/ci-privileged** — adds `preview-env-comment.yaml` (posts
the PR comment with the App key) and deletes the now-dead
`preview-env.yaml`.
## Manual prerequisites (cannot be done in a PR)
- Install/authorize the `TWENTY_WORKFLOW_DISPATCHER` GitHub App on
**ci-public** with `actions:write`.
- On **ci-public**: add `vars.DOCKERHUB_RO_USERNAME`,
`secrets.DOCKERHUB_RO_TOKEN`, and `secrets.CI_PRIVILEGED_DISPATCH_TOKEN`
(fine-grained PAT, `actions:write` on ci-privileged only).
## Suggested merge order
1. ci-privileged (receiver must exist on `main` before ci-public
dispatches to it)
2. ci-public (workflow must exist on `main` before this repo dispatches
to it)
3. this PR
## Test plan
- [ ] Open an internal PR touching `packages/twenty-docker/**`; confirm
`Preview Environment Dispatch` runs and triggers `Preview Environment`
on ci-public.
- [ ] Confirm the trycloudflare URL sticky comment lands on the PR
(posted by ci-privileged's `preview-env-comment`).
- [ ] Confirm a `preview-app` label on an external contributor PR
triggers the same flow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VqZBvafHqdCGtHZUT216C5)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22245?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
# Introduction
Getting rid of the fine grained PAT used to dispatch to internal
repositories.
Repo dispatch requires the contents write permissions which is too wide
for such use
Refactored all senders and target to pass through a workflow dispatch
instead
Creating a centralize app that forges a token with actions: write only
provided permissions to mitigate any token exfiltrations
## 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)
## What
One-line token swap on the same-repo dispatch step in
[`preview-env-dispatch.yaml`](.github/workflows/preview-env-dispatch.yaml#L40):
`secrets.GITHUB_TOKEN` → `secrets.CI_PRIVILEGED_DISPATCH_TOKEN`.
## Why
Regression from [#20476](https://github.com/twentyhq/twenty/pull/20476)
("security: harden CI against supply-chain attacks"), merged 2026-05-12.
That PR replaced
```yaml
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
...
```
with a raw `gh api` call but kept `GITHUB_TOKEN`:
```yaml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api repos/"$REPOSITORY"/dispatches -f event_type=preview-environment ...
```
The auto-provisioned `GITHUB_TOKEN` can't fire `repository_dispatch` via
`gh api` even when the workflow declares `permissions: contents: write`.
The action used a different code path that worked; the CLI requires a
token with `repo` scope. So every dispatch from this workflow has
returned `403 Resource not accessible by integration` since that PR
merged — except for runs the `author_association` / `preview-app` label
gate skips entirely (which then show "success" because no jobs ran).
Recent failed example:
https://github.com/twentyhq/twenty/actions/runs/26162974597/job/76959379235?pr=20769
## The fix
`secrets.CI_PRIVILEGED_DISPATCH_TOKEN` already exists in repo secrets
and is **already used** by the immediately-following cross-repo dispatch
step in the same file. Using it for the same-repo dispatch too matches
the surrounding code and is consistent with the original hardening
intent (use a scoped PAT, not the auto-provisioned token).
## Test plan
- [ ] Merge this PR
- [ ] Next PR open / sync / reopen on a member's branch → check that
`Preview Environment Dispatch` succeeds (no 403)
- [ ] Confirm `Preview Environment Keep Alive` workflow gets triggered
(the downstream effect of the dispatch)
- [ ] Confirm the tunnel URL sticky comment lands on the PR
Discovered while testing an unrelated PR
([#20762](https://github.com/twentyhq/twenty/pull/20762)). Independent
fix.
## Summary
- Replaces all `depot-ubuntu-24.04` runners with `ubuntu-latest`
- Replaces all `depot-ubuntu-24.04-8` runners with
`ubuntu-latest-8-cores`
- Updates storybook build cache keys in ci-front.yaml to reflect the
runner name change
Reverts the temporary Depot migration introduced in #18163 / #18179
across all 23 workflow files.
The `repository_dispatch` API endpoint requires `contents: write`
permission on the GITHUB_TOKEN, not `actions: write`. Our security
hardening PR inadvertently changed this to `contents: read`, breaking
the self-dispatch to the keepalive workflow.
Made-with: Cursor
## Summary
- Fix expression injection vulnerabilities in composite actions
(`restore-cache`, `nx-affected`) and workflow files (`claude.yml`)
- Reduce overly broad permissions in `ci-utils.yaml` (Danger.js) and
`ci-breaking-changes.yaml`
- Restructure `preview-env-dispatch.yaml`: auto-trigger for members,
opt-in for contributor PRs via `preview-app` label (safe because
keepalive has no write tokens)
- Isolate all write-access operations (PR comments, cross-repo posting)
to a new dedicated
[`twentyhq/ci-privileged`](https://github.com/twentyhq/ci-privileged)
repo via `repository_dispatch`, so that workflows in twenty that execute
contributor code never have write tokens
- Create `post-ci-comments.yaml` (`workflow_run` bridge) to dispatch
breaking changes results to ci-privileged, solving the [fork PR comment
issue](https://github.com/twentyhq/twenty/pull/13713#issuecomment-3168999083)
- Delete 5 unused secrets and broken `i18n-qa-report` workflow
- Remove `TWENTY_DISPATCH_TOKEN` from twenty (moved to ci-privileged as
`CORE_TEAM_ISSUES_COMMENT_TOKEN`)
- Use `toJSON()` for all `client-payload` values to prevent JSON
injection
## Security model after this PR
| Workflow | Executes fork code? | Write tokens available? |
|----------|---------------------|------------------------|
| preview-env-keepalive | Yes | None (contents: read only) |
| preview-env-dispatch | No (base branch) | CI_PRIVILEGED_DISPATCH_TOKEN
only |
| ci-breaking-changes | Yes | None (contents: read only) |
| post-ci-comments (workflow_run) | No (default branch) |
CI_PRIVILEGED_DISPATCH_TOKEN only |
| claude.yml | No (base branch) | CI_PRIVILEGED_DISPATCH_TOKEN,
CLAUDE_CODE_OAUTH_TOKEN |
| ci-utils (Danger.js) | No (base branch) | GITHUB_TOKEN (scoped) |
All actual write tokens (`TWENTY_PR_COMMENT_TOKEN`,
`CORE_TEAM_ISSUES_COMMENT_TOKEN`) live in `twentyhq/ci-privileged` with
strict CODEOWNERS review and branch protection.
## Test plan
- [ ] Verify preview environment comments still appear on member PRs
- [ ] Verify adding `preview-app` label triggers preview for contributor
PRs
- [ ] Verify breaking changes reports still post on PRs (including fork
PRs)
- [ ] Verify Claude cross-repo responses still post on core-team-issues
- [ ] Confirm ci-privileged branch protection is enforced
This is hard to test without merging PRs unfortunately
Goal of this PR is to replace the action I had introduced since there
was already a similar one in the codebase