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 open / sync | fork | Build and test with no secrets (GitHub withholds them and issues a read-only token). No versioning, no publishes. |
pull_request closed | fork | Vetoed at the event gate — see Fork closes do nothing. |
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. |
push | Flowzone itself | Vetoed at the event gate — see Not re-triggering itself. |
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).
Not re-triggering itself
Adding the push trigger means Flowzone reacts to pushes it makes itself. versioned_source
pushes the versioned commit with the GitHub App token, and unlike github.token, an App token
does re-trigger workflows — so the versioned commit re-enters the pipeline.
That run cannot loop: it merged no PR, so fork_merge and push_finalize are both false, the
test jobs skip, and no git ref is written again. It is not harmless, though. The jobs whose if:
carries no push-lane gate beyond trusted == 'true' — balena_publish, website_publish, and a
caller's custom_always — would run a second time, so a release that already published would
push another balena release and redeploy the Cloudflare Pages site. versioned_source would also
re-run versionist and leave orphan commit and tag objects behind.
The event_types job's if: vetoes it. Every other job has event_types in its needs
(directly or transitively), so skipping there skips the whole workflow — one place to veto a run.
Two independent signals, because either can be absent:
github.event.sender.login != 'flowzone-app[bot]'— any push made by the App, including auto-merge. Does not cover the legacyFLOWZONE_TOKEN(PAT) path, where the pusher is a human account whose login Flowzone cannot know.!contains(github.event.head_commit.message, 'Flowzone-version-commit:')— the trailerversioned_sourcewrites into the versioned commit, so the veto holds whichever credential pushed it.
This is deliberately not a [skip ci] commit directive. GitHub honours skip directives for
every workflow in the repository, not just Flowzone, so vetoing that way also suppressed
downstream pipelines that key deployments off pushes to the default branch (balena-os). A private
trailer that only Flowzone reads keeps the veto scoped to Flowzone and leaves other consumers of
the push running.
Fork closes do nothing
The same if: also vetoes a fork pull_request closed event, whether the PR merged or not.
GitHub withholds secrets from every fork pull_request action, including the close, so
trusted is false on that run and there is no outcome it can reach:
- Merged. The work belongs to the fork-merge
pushlane, which starts in parallel and has secrets. The close can only recompute what that run computes properly. - Not merged. There is nothing to clean up. A fork never had secrets, so it drafted no
release or artifact, and
github_clean/custom_cleanrequiretrustedin any case.
Before the veto, a fork close still spent a run on versioned_source (which executed versionist
and then wrote nothing, every App-token step being gated on trusted), octoscan, file_list,
and the is_* detectors. actionlint and pre_commit_hooks already gated themselves out with
github.event.action != 'closed'.
pull_request_target is excluded from this veto on purpose: its close must still reach the
Reject pull_request_target events step and fail loudly, rather than disappear as a skipped run.
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.