GitHub Deployment Environments¶
SynthOrg uses GitHub deployment environments to gate workflow jobs that carry elevated permissions or access sensitive secrets. Each environment has a branch allowlist (deployment branch policy) so the job only runs when the workflow was triggered from an expected ref.
Policies cannot be declared in workflow YAML -- they live on the GitHub
environment itself. Apply them via scripts/configure_environments.sh.
Current environments¶
| Environment | Branch policy | Triggered by |
|---|---|---|
github-pages |
main |
pages.yml push to main |
release |
main |
release.yml + dev-release.yml + auto-rollover.yml + graduate.yml + test-signing.yml (main-scoped), finalize-release.yml:publish (workflow_run resolves github.ref to main; carries statuses: write so the publish job can post a finalize-release commit status against workflow_run.head_sha). Holds RELEASE_BOT_APP_CLIENT_ID + RELEASE_BOT_APP_PRIVATE_KEY. |
release-tags |
v* |
cli.yml:cli-release + docker.yml:update-release (v* tag pushes). Structural ref gate only; no privileged secrets. |
image-push |
main, v* |
docker.yml *-publish jobs (4 apko base pushes + 5 app image pushes) on main and v* refs |
apko-lock |
main |
apko-lock.yml schedule + workflow_dispatch |
cloudflare-preview |
none (see below) | pages-preview.yml pull_request events |
atlas |
none (see below) | ci.yml:schema-validate push + pull_request |
The release path is intentionally split into two environments. GitHub's
deployment branch policies only match ref names -- they do NOT verify
that a tag's commit is reachable from an allowed branch. Admitting v*
on the secret-bearing environment would let any v-prefixed tag
(including one forged on an unmerged feature branch) unlock the App
credentials. Keeping release main-only and routing tag-only jobs
through release-tags preserves the structural ref gate without
exposing the App.
Why cloudflare-preview and atlas have no branch policy¶
GitHub's deployment branch policies match against github.ref using fnmatch,
but only for refs under refs/heads/* (branches) and refs/tags/* (tags).
For pull_request event workflows, github.ref is refs/pull/<N>/merge,
which cannot be matched by any branch-type policy.
cloudflare-preview only runs on pull_request events, so a branch policy
would either:
- block every PR preview (if set to
main), or - admit everything (if set to
*), providing no real protection.
atlas runs on both push and pull_request, so a main-only policy would
block the migration validation gate on every PR.
In both cases the workflow-level gate is the actual control:
pages-preview.yml:deploy-preview/cleanup-preview: gated onsame_repo == 'true'so fork PRs cannot access Cloudflare secrets.ci.yml:schema-validate: runs only through trusted SynthOrg CI paths; the environment protects theATLAS_TOKENsecret but does not restrict which refs can reach it.
If GitHub ever extends deployment branch policies to cover PR refs, revisit
these two entries in scripts/configure_environments.sh.
Applying policies¶
Run once after merging SUP-1, and whenever a new environment is added:
# Preview the API calls (safe, default)
bash scripts/configure_environments.sh
# Apply
bash scripts/configure_environments.sh --apply
The script is a reconciler: on --apply the final state of each environment's
branch policies exactly matches the ENV_CONFIG table inside the script.
Missing policies are POST-ed (with HTTP 422 already-exists treated as a
no-op), and any extra policy not in the desired set is DELETE-d. Requires a
gh CLI authenticated with the repo scope (classic PAT/OAuth) or
administration:write (fine-grained PATs/GitHub Apps) -- see the
deployments API docs.
Verifying policies¶
gh api repos/Aureliolo/synthorg/environments/apko-lock \
--jq '.deployment_branch_policy, .name'
gh api repos/Aureliolo/synthorg/environments/apko-lock/deployment-branch-policies \
--jq '.branch_policies[].name'
Expected output for the reconciled environments (github-pages, apko-lock,
release, release-tags, image-push):
deployment_branch_policy:{"protected_branches": false, "custom_branch_policies": true}branch_policiesforgithub-pages,apko-lock,release:["main"]branch_policiesforrelease-tags:["v*"]branch_policiesforimage-push:["main", "v*"]
cloudflare-preview and atlas are intentionally excluded from the
custom_branch_policies expectation -- see the rationale above.
Required secrets¶
Secrets gated by deployment environments are only available to jobs whose
github.ref matches that environment's branch policy. Any job referencing
secrets.<NAME> in its env: or step inputs must run under the
environment that scopes the secret.
RELEASE_BOT_APP_*¶
The release pipeline is authenticated by a dedicated GitHub App,
synthorg-release-bot. Its credentials live in the release
deployment environment as two secrets:
RELEASE_BOT_APP_CLIENT_ID-- the App's Client ID as shown on the App's settings page (formatIv23...). This is whatactions/create-github-app-token@v3.1+expects via itsclient-idinput; the olderapp-idinput accepted the numeric App ID and was deprecated in v3.1.RELEASE_BOT_APP_PRIVATE_KEY-- the full.pemcontents verbatim, including the opening and closing marker lines. Both markers must be present and spelled exactly as emitted by the GitHub App page:- Opening line:
-----BEGIN RSA PRIVATE KEY----- - Closing line:
-----END RSA PRIVATE KEY-----Paste the file contents exactly as downloaded -- GitHub's secret store accepts multi-line values but silently strips trailing whitespace, so do not hand-edit the.pem.
Why an App token. Two constraints rule out the alternatives:
mainenforcesrequired_signatures, so any API commit that lands there MUST verify as{verified: true, reason: "valid"}. OnlyGITHUB_TOKENand App installation tokens produce GitHub-signed API commits; PAT-authored API commits are unsigned and get rejected at the branch-protection gate.- A tag or main-commit push must fire downstream workflows
(Docker, CLI, Dev Release). GitHub's anti-recursion rule
suppresses those events when the triggering push was authored
by
GITHUB_TOKEN. App installation tokens are exempt.
App tokens are the only credential that satisfies both at once.
Purpose. Every release workflow mints a fresh short-lived App
installation token (valid ≤1 hour) via the
release-runner-setup composite action, which wraps
actions/create-github-app-token@v3.1.1. Consumers:
release.yml--release-please-actiontoken input, so the RP tag push on release-PR merge triggers Docker + CLI builds. The BSL Change Date Contents API commit keepsGITHUB_TOKEN(lands on the RP PR branch, notmain; no recursion concern). One side-effect of the App-token PR creation: GitHub's anti-recursion rule blockspull_requestworkflows for events created by the workflow's own installation token, soci.ymldoes not auto-fire on the release PR. To unblock the requiredCI Passcheck, the job's final step issuesgh workflow run ci.yml --ref release-please--branches--main--components--synthorgwithGITHUB_TOKEN(which IS allowed to invokeworkflow_dispatch-- the documented exception to the anti-recursion rule). The resultingci.ymlrun dispatches against the release branch's HEAD, so itsCI Passcheck_run posts on the release PR's head SHA and satisfies theprotect-mainruleset. Thebranch-protection-auditjob insideci.ymlkeeps agithub.ref == 'refs/heads/main'gate so non-main dispatches skip cleanly instead of hitting thereleaseenvironment's branch allowlist and emitting a "deployment was rejected" annotation on every release PR.dev-release.yml-- tag creation for dev pre-releases viagh api.auto-rollover.yml-- emptyRelease-As:commit via the Git Data API (POST /git/commits+PATCH /git/refs/heads/main).graduate.yml-- user-triggered signed empty commit with aRelease-As:trailer for target versions that skip the normal patch cadence.test-signing.yml-- nightly verification that each of the above paths produces a commit with{verified: true, reason: "valid"}.
App configuration. Ship the App with the minimum privilege set:
- Owner:
Aureliolo(personal account). - Install scope:
Aureliolo/synthorgonly. Single-repo install bounds the blast radius to the intended target. - Repository permissions:
Contents: Read and writePull requests: Read and writeMetadata: Read- Subscribe to no events -- this App has no webhook endpoint and does not need to receive events.
Provisioning checklist (follow once at setup):
- Settings -> Developer settings -> GitHub Apps -> New GitHub App.
- Configure permissions + install scope as above.
- Generate a private key; download the
.pem. - Install the App on
Aureliolo/synthorg. - Copy the App's Client ID (
Iv23...format) from the same page. - Repo Settings -> Environments ->
release-> Environment secrets. AddRELEASE_BOT_APP_CLIENT_ID(the Iv23... Client ID from the App's settings page) andRELEASE_BOT_APP_PRIVATE_KEY(full PEM contents). - Confirm the action allowlist includes
actions/create-github-app-token@*(SHA-pinned in-workflow) andactions/ai-inference@*(used by the release-notes Highlights step inrelease.yml).
No rotation schedule. Installation tokens are ephemeral --
minted per workflow run and valid for at most one hour, then
discarded. The only long-lived secret is the App private key,
rotated only if the key file is compromised. Private-key rotation
is a two-step: generate a new key in the App settings, replace
RELEASE_BOT_APP_PRIVATE_KEY in the release environment,
delete the old key.
Access control. The release environment's branch policy
(main only) is the structural gate. A workflow triggered from
any other ref cannot read RELEASE_BOT_APP_* even if it
declares them in its YAML. Tag-only release jobs
(cli.yml:cli-release, docker.yml:update-release) run under
the separate release-tags environment, which carries no
privileged secrets, so a forged v* tag on unmerged code
cannot reach the App credentials.
Testing the apko-lock gate¶
Trigger the workflow from a non-main ref via workflow_dispatch API (pushing
a commit to a feature branch is not enough -- the workflow is not configured
for push events):
# Trigger from main -- should succeed
gh workflow run apko-lock.yml --ref main
# Trigger from a feature branch -- should be blocked at the environment gate.
# GitHub will show the job in "Waiting" state; the run log cites the branch
# policy violation.
gh workflow run apko-lock.yml --ref chore/some-branch