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 + cla.yml:cla-sign (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. Holds APKO_BOT_APP_CLIENT_ID + APKO_BOT_APP_PRIVATE_KEY: a copy of the synthorg-repo-bot App credentials (Contents: Read and write + Pull requests: Read and write + Metadata: Read, scoped to this repo only), used by the apko-lock workflow to mint an installation token for the lockfile-update PR. GITHUB_TOKEN cannot create the PR because the repo-level setting can_approve_pull_request_reviews: false blocks it. The same App credentials live under RELEASE_BOT_APP_* in the release env (env-scoped, not shared across envs). Both copies point at the same App; the dedicated apko-lock env keeps weekly-cron auth and release-pipeline auth in separate boxes even though they share an identity. |
cloudflare-preview |
none (see below) | pages-preview.yml pull_request events |
lighthouse |
none (see below) | lighthouse.yml pull_request events. Holds LHCI_GITHUB_APP_TOKEN. |
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 lighthouse 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.
lighthouse is the same case: lighthouse.yml's dashboard + site
jobs run only on pull_request, hold LHCI_GITHUB_APP_TOKEN, and a
main policy would block every web/site PR (the Lighthouse Pass
aggregate is a REQUIRED check, so a permanently-pending sub-job would
wedge merges). It therefore carries no branch policy either.
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.lighthouse.yml:lighthouse-dashboard/lighthouse-site: gated ongithub.event.pull_request.head.repo.full_name == github.repository(orworkflow_dispatch) so a fork PR cannot reachLHCI_GITHUB_APP_TOKEN; thelighthouse-passaggregator treats the resulting skipped sub-jobs as a pass.
If GitHub ever extends deployment branch policies to cover PR refs, revisit
this entry 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 lighthouse 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-repo-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. The requiredCI Passcheck is therefore satisfied by a direct commit-status post: the job's final step resolves the release PR's current head SHA and issuesgh api -X POST repos/.../statuses/<head_sha>withcontext=CI Pass,state=successunderGITHUB_TOKEN(which carriesstatuses: write). Statuses are unconditionally part of a PR'sstatusCheckRollupand satisfyrequired_status_checksevaluation, so the merge gate resolves without ci.yml ever firing. (The earlier mechanism, agh workflow run ci.ymlworkflow_dispatch nudge, produced a check_run on the SHA but the dispatched run'scheck_suite.pull_requestswas empty, so the check did not appear in the rollup; the merge UI stayed stuck onCI Pass: Expected, waiting. See PR #1615 for the full root cause.)ci.yml'sis_release_pleaseskip remains in place as defence-in-depth so a future change in release-please's identity does not accidentally run a full CI suite on a release PR. Thebranch-protection-auditjob insideci.ymlkeeps agithub.ref == 'refs/heads/main'gate so any ad-hoc dispatch from a non-main ref skips cleanly instead of hitting thereleaseenvironment's branch allowlist.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.cla.yml:cla-sign: signed commit on thecla-signaturesbranch via the Git Data API (POST /git/blobs+POST /git/trees+POST /git/commits+PATCH /git/refs/heads/ cla-signatures). The signed commit lets thecla-signaturesbranch participate in the default ruleset's required-signatures rule without a per-branch carve-out. Read-only signature verification (cla-check) lives in the same workflow but usessecrets.GITHUB_TOKENdirectly -- no App-token mint required.
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