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 |
build-docs.yml push to main |
release |
main |
release-cut.yml + release-dev.yml + release-rollover.yml + release-graduate.yml + repo-cla.yml:cla-sign (main-scoped), release-finalize.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* |
verify-cli.yml:cli-release + build-images.yml:update-release (v* tag pushes). Structural ref gate only; no privileged secrets. |
image-push |
main, v* |
build-images.yml *-publish jobs (4 apko base pushes + 5 app image pushes) on main and v* refs |
apko-lock |
main |
maint-apko-lock.yml and maint-capability-bundle.yml, both 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 to mint an installation token for the maintenance PR each cron opens. GITHUB_TOKEN cannot create those PRs 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 maintenance-cron auth and release-pipeline auth in separate boxes even though they share an identity. The two scheduled workflows share this env rather than taking one each: they need the same identity, the same Contents/Pull requests scopes and the same main-only gate, and they open PRs in the same ci/* branch namespace, so a second env would duplicate the credentials into a second rotation site while narrowing nothing. The split that does exist (release) is a genuine trust boundary: that env gates the release pipeline's writes to a protected main, which is a higher trust level than a maintenance cron opening a PR. |
cloudflare-preview |
none (see below) | build-docs-preview.yml pull_request events |
lighthouse |
none (see below) | perf-web-vitals.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. perf-web-vitals.yml triggers on
pull_request, merge_group, and push to main;
lighthouse-dashboard runs on all three and holds
LHCI_GITHUB_APP_TOKEN, while lighthouse-site runs on pull_request
only (it audits a per-PR Cloudflare preview URL that has no merge-group
or push equivalent). A main branch policy cannot match the
pull_request ref (refs/pull/<N>/merge) or the merge_group ref
(refs/heads/gh-readonly-queue/...), and 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:
build-docs-preview.yml:deploy-preview/cleanup-preview: gated onsame_repo == 'true'so fork PRs cannot access Cloudflare secrets.perf-web-vitals.yml:lighthouse-site: gated ongithub.event.pull_request.head.repo.full_name == github.repository(orworkflow_dispatch) so a fork PR cannot reachLHCI_GITHUB_APP_TOKEN.lighthouse-dashboardcarries the same fork gate plusmerge_groupandpush(both always upstream-owned refs, never a fork), so it also runs in the merge queue and on push tomain. Thelighthouse-passaggregator treats the skipped sub-jobs (e.g.lighthouse-siteonmerge_group/push) 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-cut.yml: the.github/actions/release-pleasecomposite's token input, forwarded torelease-please-action, 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, soverify-backend.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 verify-backend.yml ever firing. (The earlier mechanism, agh workflow run verify-backend.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.)verify-backend.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.release-dev.yml: tag creation for dev pre-releases viagh api.release-rollover.yml: emptyRelease-As:commit via the Git Data API (POST /git/commits+PATCH /git/refs/heads/main).release-graduate.yml: user-triggered signed empty commit with aRelease-As:trailer for target versions that skip the normal patch cadence.repo-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@*anddataaxiom/ghcr-cleanup-action@*(both SHA-pinned in-workflow). The latter backs the weekly GHCR prune job; an Actions-restricted fork that omits it fails the cleanup at action-resolution time.
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
(verify-cli.yml:cli-release, build-images.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.
MISTRAL_API_KEY (repository secret)¶
Unrelated to the App above: this is a plain repository secret, not an
environment one, and it mints nothing. The release-notes tagline +
Highlights step in release-cut.yml and the
release-highlights-dryrun.yml dry-run read it; a repository-level
secret is visible to the release environment job.
The step is failure-tolerant, so a missing or invalid key only skips the
Highlights block, never the release. Silence is not the outcome, though:
a failure opens the self-closing Release Highlights generation is
failing tracker, and the block already published survives, because the
sticky comment is only rewritten on a successful generation.
Deliberately no plan or allowance figures here: Mistral's tiers and their included usage change, and a number copied into this page is one nobody re-checks. Read the current terms from Mistral's pricing page and the account's own Admin Console, which is the only place this key's actual balance and rate limits appear.
What matters operationally is that which models a key may call is
published nowhere, and a plan can withdraw one without notice. The
model is declared once as HIGHLIGHTS_MODEL in release-cut.yml; when
it starts returning 403 tier_not_allowed, dispatch
release-highlights-dryrun.yml, which lists what the key can call and
rehearses the real prompt and digest against any candidate (its
reasoning_effort input defaults to high and drives max_tokens to match
production, so a bare dispatch is a real rehearsal; clear reasoning_effort
first when probing an unfamiliar candidate, since a model that does not
accept that field rejects any non-blank value with its own HTTP 422,
separate from the tier question being tested).
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 maint-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 maint-apko-lock.yml --ref chore/some-branch
maint-capability-bundle.yml rides the same environment and so behaves
identically; substitute its filename to exercise the gate through the
other consumer.