ADR-036: Workflow Trust Boundaries for CI/CD
Status
Accepted (2026-06-04)
Context
- Two jobs in
validate.ymlhold a credential with write reach:docker-pushwithpackages: writeon GHCR, anddeploy-testwith the fork's Azure federated identity for the attached stack (ADR-041). This ADR fixes the boundary both inherit. - The validate-only
docker-buildjob runs withpermissions: contents: readonly (no GHCR write, no Azure login) and is out of scope for this boundary. It never takes thepull_request_targetlane and never sets a checkout ref: CodeQL reports a PR-head ref in a read-only job of aworkflow_dispatchworkflow as cache poisoning in every fork (#164), and the job has no need for one. - GitHub event contexts are not equally trusted.
pull_request_target, external-fork PRs, and Dependabot PRs can place attacker-controlled code in a context with secret access. Running a credential-bearing job there would hand that credential to the PR author.
Decision
Credential-bearing jobs run only in the trusted contexts below. The "runs?" column applies to docker-push and to deploy-test, which additionally skips when the fork is not onboarded or declares no suite.
| Event | Code source | Secret access | Credential-bearing job runs? |
|---|---|---|---|
push to main or fork_integration | Repo HEAD (post-merge) | Yes | Yes |
push to fork_upstream | Repo HEAD | Yes | No; a filter-mode tree has no Azure JAR to package, and the cascade PR validates the image |
pull_request from an internal branch (head repo equals base repo) | PR HEAD | Yes | Yes |
pull_request from an external fork | PR HEAD | No (GitHub default) | No; explicitly skipped rather than left to fail |
pull_request_target | PR HEAD via explicit ref | Yes | No; a PR could exfiltrate the credential by running arbitrary code with secret access |
dependabot[bot] PR | PR HEAD | Dependabot secrets scope only | No; dependabot-validation.yml is the dependency-update path |
workflow_dispatch | Repo HEAD at chosen ref | Yes | No today. The clause reserves a force_full_pipeline operator override, but validate.yml does not yet declare that input, so the override cannot match |
| Tag push (release-please) | Tagged commit already on main | Yes | Not via validate.yml. release.yml re-tags the existing image with the semver through GITHUB_TOKEN and does not rebuild or deploy |
Cascade push to fork_integration | Cascade-resolved tree | Yes | Yes |
The gate on docker-push:
if: |
(
needs.java-build.outputs.build_result == 'success' &&
github.actor != 'dependabot[bot]' &&
github.event_name != 'pull_request_target' &&
github.event_name != 'workflow_dispatch' &&
github.ref_name != 'fork_upstream' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
) || (
github.event_name == 'workflow_dispatch' &&
inputs.force_full_pipeline == true
)
The clause needs no separate check-initialization or check-repo-state guard because java-build reports build_result == 'success' only after both held. The github.event_name != 'workflow_dispatch' guard is what keeps a routine manual run, such as post-init validation, from pushing. The second half is reserved for a planned operator override and stays inert until validate.yml declares the force_full_pipeline input. Every credential-bearing job carries the clause itself instead of relying on skip-propagation from docker-push, and none of the event guards may be dropped. deploy-gate restates each guard as a named refusal (event, head repository, actor, branch) so the deploy lane's absence on a run is explained rather than inferred, and dev-ci.yml asserts the guards are present.
Consequences
Positive
- Registry and cluster credentials are never exposed to attacker-controlled PR execution contexts.
- Trust assumptions are explicit and identical across service forks.
- Cascade pushes keep image signal for upstream-integration risk.
Negative
- External-fork PRs get no image push and no deploy signal. A maintainer who wants to prove such a change pushes it to a branch in the repository, which puts the reviewed code behind the boundary. CONTRIBUTING.md currently limits contributors to Microsoft employees, so no external-fork review process is documented yet.
- The
if:clause is easy to weaken when adding a new sensitive job; review must check it against this ADR.
Neutral
docker-buildruns on every event except thepull_request_targetlane and a filter-modefork_upstream. Sync PRs get their image validated on the cascade PR, and in mirror mode on thefork_upstreampush.- Dependabot keeps its own validation path outside credential-bearing workflows.
Alternatives Considered
- Allow
pull_request_targetfor credential-bearing jobs: rejected, direct credential-exfiltration risk. - Allow external-fork PRs: rejected, untrusted-code boundary.
- Trust checks by reviewer convention only: rejected, policy is enforced in the workflow
if:guard, not left to vigilance.