Skip to content

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 on same_repo == 'true' so fork PRs cannot access Cloudflare secrets.
  • ci.yml:schema-validate: runs only through trusted SynthOrg CI paths; the environment protects the ATLAS_TOKEN secret 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_policies for github-pages, apko-lock, release: ["main"]
  • branch_policies for release-tags: ["v*"]
  • branch_policies for image-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 (format Iv23...). This is what actions/create-github-app-token@v3.1+ expects via its client-id input; the older app-id input accepted the numeric App ID and was deprecated in v3.1.
  • RELEASE_BOT_APP_PRIVATE_KEY -- the full .pem contents 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:

  1. main enforces required_signatures, so any API commit that lands there MUST verify as {verified: true, reason: "valid"}. Only GITHUB_TOKEN and App installation tokens produce GitHub-signed API commits; PAT-authored API commits are unsigned and get rejected at the branch-protection gate.
  2. 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-action token input, so the RP tag push on release-PR merge triggers Docker + CLI builds. The BSL Change Date Contents API commit keeps GITHUB_TOKEN (lands on the RP PR branch, not main; no recursion concern). One side-effect of the App-token PR creation: GitHub's anti-recursion rule blocks pull_request workflows for events created by the workflow's own installation token, so ci.yml does not auto-fire on the release PR. To unblock the required CI Pass check, the job's final step issues gh workflow run ci.yml --ref release-please--branches--main--components--synthorg with GITHUB_TOKEN (which IS allowed to invoke workflow_dispatch -- the documented exception to the anti-recursion rule). The resulting ci.yml run dispatches against the release branch's HEAD, so its CI Pass check_run posts on the release PR's head SHA and satisfies the protect-main ruleset. The branch-protection-audit job inside ci.yml keeps a github.ref == 'refs/heads/main' gate so non-main dispatches skip cleanly instead of hitting the release environment's branch allowlist and emitting a "deployment was rejected" annotation on every release PR.
  • dev-release.yml -- tag creation for dev pre-releases via gh api.
  • auto-rollover.yml -- empty Release-As: commit via the Git Data API (POST /git/commits + PATCH /git/refs/heads/main).
  • graduate.yml -- user-triggered signed empty commit with a Release-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/synthorg only. Single-repo install bounds the blast radius to the intended target.
  • Repository permissions:
  • Contents: Read and write
  • Pull requests: Read and write
  • Metadata: Read
  • Subscribe to no events -- this App has no webhook endpoint and does not need to receive events.

Provisioning checklist (follow once at setup):

  1. Settings -> Developer settings -> GitHub Apps -> New GitHub App.
  2. Configure permissions + install scope as above.
  3. Generate a private key; download the .pem.
  4. Install the App on Aureliolo/synthorg.
  5. Copy the App's Client ID (Iv23... format) from the same page.
  6. Repo Settings -> Environments -> release -> Environment secrets. Add RELEASE_BOT_APP_CLIENT_ID (the Iv23... Client ID from the App's settings page) and RELEASE_BOT_APP_PRIVATE_KEY (full PEM contents).
  7. Confirm the action allowlist includes actions/create-github-app-token@* (SHA-pinned in-workflow) and actions/ai-inference@* (used by the release-notes Highlights step in release.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