Flowzone trust boundary
This document describes how Flowzone keeps secrets away from untrusted (fork) code, and how a fork contribution still builds, tests, and publishes. It is the maintainer-facing companion to the "External Contributions" section of the README.
The problem it replaces
Flowzone used to give fork PRs access to secrets via pull_request_target plus a review-gate:
a maintainer approved a reaction before merge. Untrusted fork code ran in the base repository's
trusted context — its GITHUB_TOKEN, secrets, cache scope, and runner access — with only human
vigilance between a fork and the org's registry, cloud, and deploy credentials. That is the
"pwn request" anti-pattern: a boundary held by a person, not by structure.
The redesign removes pull_request_target entirely and makes the trust boundary structural:
a fork never runs with secrets. Secrets only ever appear on trusted lanes, selected by
event, not by review.
One trust boundary per job, selected by event
| Event | Actor | Behaviour |
|---|---|---|
pull_request | internal branch | Full pipeline with secrets; draft on open, finalize on merge. Unchanged. |
pull_request | fork | Build and test with no secrets (GitHub withholds them and issues a read-only token). No versioning, no publishes. |
push | default branch, fork merge | Rebuild + publish + finalize from the merged (trusted) commit. Where fork contributions publish. |
push | default branch, internal merge | Quiet skip — already finalized on the internal PR's pull_request lane. |
push | tags / direct | Trusted release path. Unchanged. |
pull_request_target | any | Rejected at the event gate. |
The lane is decided in the event_types job and exported as outputs the rest of the workflow
gates on:
trusted—trueon everything except a forkpull_request. Secret-consuming jobs and steps gate on this so they skip (not fail) on the untrusted fork lane.fork_merge—truewhen apushto the default branch merged a PR that came from a fork. Set by theClassify push mergestep, which looks up the PR associated with the pushed commit (GET /repos/{}/commits/{sha}/pulls).push_finalize—truefor tag pushes and fork merges; gates the finalize path onpush. Internal merges and direct pushes arefalse(no double-publish).
Why a fork merge rebuilds
A fork's pull_request run has no secrets, so it publishes nothing during review. On merge, the
push to the default branch runs the whole pipeline in one run: the test jobs rebuild from
the merged commit, the publishers push, and the finalize jobs promote. This is structurally
different from the internal flow, which splits work across two runs (open → draft-publish,
merge → finalize).
The rebuild is deliberate. Reusing a fork PR's uploaded artifact would let a fork publish bytes
that do not match its reviewed source, built under its own workflow — a supply-chain break. So
the fork-merge push downloads same-run artifacts produced by the rebuild; the cross-run
artifact lookup (dawidd6/action-download-artifact, keyed by commit SHA) is used only on the
internal-merge and tag lanes, whose artifact was built in a trusted earlier run.
On this lane there is no reviewer for a "draft", so pure-draft steps are skipped: the GitHub
release is created directly by github_finalize (not promoted from a draft), and the npm /
PyPI draft publishes do not run (the finalize jobs publish the final release). Docker keeps
publish + finalize, because docker_finalize retags the build-<sha> images that
docker_publish pushes — Docker has registry tags, not a draft-vs-final artifact. Custom jobs
likewise run test → publish → finalize on this lane, since a caller's custom_finalize may
consume its custom_publish output (the action bodies are caller-defined).
Migration
- Fork testing requires a caller change. The previous caller snippet routed fork PRs to
pull_request_targetwith anif:condition, so a fork'spull_requestinvocation was filtered out. Flowzone no longer runs onpull_request_target, so callers must adopt the new usage snippet — removing thepull_request_targettrigger and its routingif:— to send fork PRs to the no-secretspull_requestlane. - Fork publishing additionally requires the
pushtrigger on the default branch. - Internal PRs are unchanged. A caller that keeps the old
pull_request+pull_request_targetwiring keeps working for internal PRs: thepull_request_targetinvocation is filtered out by the callerif:(and if it does run, Flowzone rejects it and itsall_jobsis the non-required per-event context, so it never gates). Removing the dead trigger is otherwise a mechanical follow-up. - Caller permissions. No permission change is required. Flowzone declares no
GITHUB_TOKENpermissions the caller must grant, so it never fails the reusable-workflow permissions subset check at startup. The push-laneClassify push mergelookup needspull-requests: read, but it mints an ephemeral Flowzone app token for it (scopedpull-requests: read) whenapp_idis configured — so fork publishing works without the caller granting anything. If the app is not configured it falls back toFLOWZONE_TOKEN, thengithub.token; if that last fallback lacks the scope, fork-merge detection is skipped (a warning is logged) and everything else is unaffected. restrict_custom_actionsis deprecated. It only existed to keep fork custom code out of the trustedpull_request_targetcontext; forks now run without secrets, so it has no effect.custom_testruns on forks;custom_publish/custom_finalize/custom_alwaysare trusted-only. Flowzone logs a::warning::when the input is set; remove it from callers (it will be dropped in a future release).
Accepted limitations
- Fork PRs cannot run steps that need secrets (private submodules,
COMPOSE_VARS-backed compose tests) during review; the trusted merge lane rebuilds them with credentials. - Fork publish-path bugs surface at merge, not during review, because forks cannot draft-publish.
- A repo without the
pushtrigger gets fork test-only, and will need it once internal branches move onto the push lane. push-merge runs share the default-branch concurrency group withcancel-in-progress: false; GitHub keeps one pending run per group, so stacked merges can drop a pending publish. Re-run the workflow for the affected merge commit.
Runners
runs_on / docker_runs_on are caller inputs and Flowzone does not override them. A fork job
may use self-hosted runners only if they are ephemeral and isolated: provisioned
just-in-time, torn down per job, with no persistent tool-cache or disk and no reachable internal
network or IAM. The real risk is persistent runner state bridging an untrusted fork job into a
later trusted one. Flowzone cannot enforce this (it is infrastructure config), so it is org
runner-group policy — see #534 — together
with "Require approval for all outside collaborators".
Auto-merge
Flowzone's auto-merge is internal-only; it needs an app token that the fork lane does not have.
For fork auto-merge, install bulldozer (a GitHub App
that merges on label/config as itself) and add a per-repo .bulldozer.yml.