Gated Environments
How approval-gated GitHub Environments hold privileged secrets, and how to add a consumer.
Gated Environments
Privileged CI secrets -- 1Password service-account (SA) tokens, release
signing material -- live behind approval-gated GitHub Environments so that a
poisoned same-repo workflow cannot read them. This is the native remediation
for the pipeline-privilege-escalation (PPE) class: a required-reviewer gate
binds to the job that declares environment:, and a job that does not
declare it resolves the secret to the empty string. On a reviewer-protected
environment there is no way to read a gated secret without a human approval.
(One documented exception exists: the reviewerless release-gated
environment on the private glycemicgpt-discord-bot repo is
environment-scoped but not approval-gated -- see its section below.)
This page describes op-github-gated, the reference environment, and how to
add a consumer without weakening the gate.
op-github-gated
Label: pre-gated bootstrap / no consumers. (GitHub Environments have no
description field, so the label lives here.) The environment holds
BACKEND_ACTIONS_SERVICE_ACCOUNT -- the monorepo's read-only 1Password SA
token, which resolves op://github/... references. It has zero production
consumers today (the secrets-plumbing-check smoke is the only workflow
that exercises it); it is provisioned ahead of the secret migration.
Any future consumer of this token must independently prove it is safe (a "Class-A" job: gated environment, no PR-head code execution while the token is in scope). An unattended consumer of a whole-vault SA token would recreate the PPE with vault-wide reach.
Protection configuration (the load-bearing conditions)
| Setting | Value | Why |
|---|---|---|
| Required reviewers | the maintainer lead (outside the write-actor set) | The gate. A job declaring the environment pauses here before any step runs. |
prevent_self_review | true | The dispatcher of a run cannot approve their own deployment. |
can_admins_bypass | false | An admin cannot skip the reviewer gate. This is not the same as branch/ruleset merge bypass, which is unrelated. |
| Deployment branch policy | custom: main, develop (protected trunks) | Purely additive defense-in-depth: a workflow_dispatch from an attacker-pushed feature branch (carrying a tampered local composite) cannot even reach the approval prompt. It never substitutes for the reviewer. Use a custom pattern, never the "protected branches" option, which admits the read for same-repo PRs against a protected base. |
| Deployment-protection apps | none | An auto-approver app would silently defeat the human pause. |
The SA token exists only as this environment's secret: never as a plain
repo secret and never as an organization secret (both are ungated to
non-environment jobs). The scheduled secrets-hygiene.yml audit
(check-secret-invariants.py) drift-checks this: every environment must keep
required_reviewers >= 1, and a gated secret must not reappear as a plain
copy.
release-gated
Label: release credentials / live consumers. Exists on the monorepo,
website, android-unofficial, and glycemicgpt-discord-bot. It holds the
RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY GitHub App key (formerly an
org-wide secret) on every repo, plus -- on the monorepo only -- the four
android release-signing keystore secrets (RELEASE_KEYSTORE_BASE64,
RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD).
The 1Password items remain escrow only; CI reads the environment secrets
directly.
Consumers are every RELEASE-minting job: changelog-pr.yml (changelog) and
release.yml (release-please, fallback-release, release-android-apk,
update-release-body), and the equivalent jobs on the sibling repos. All are
push: main / workflow_dispatch jobs (Class A, pause-tolerant). On the
reviewer-protected repos (monorepo, website, android-unofficial) each
gated job pauses for reviewer approval before the key is in scope, so a
single release run prompts more than once as successive jobs start
(release-please first, then the APK/release-body jobs); an unapproved job
strands that run, and downstream jobs that need release_created skip
rather than hang if release-please is rejected. On the reviewerless
glycemicgpt-discord-bot exception (below) jobs do not pause -- its
secrets are environment-scoped, not approval-gated.
Configuration differs from op-github-gated in two deliberate ways:
prevent_self_review = false. The release trigger is already lead-only: pushes tomainare restricted to promotion merges the lead performs,workflow_dispatchrequires write access and the lead is the only write-capable collaborator, so the dispatcher and the only sensible approver are the same person. Approval authority itself never widens: whoever triggers a run, only the required reviewer (the lead) can approve it --prevent_self_review = falsemerely stops the lead's own dispatches from deadlocking on a second human. With a single-maintainer topology,prevent_self_review = truewould stall every release without excluding any realistic attacker (an attacker who can triggerpush: mainor dispatch already has lead credentials). All other conditions --can_admins_bypass = false, custom additive branch policy, no auto-approver apps -- are unchanged. Revisit this setting if the monorepo ever gains a second write-capable collaborator.glycemicgpt-discord-botcarries no reviewer rule at all. The repo is private, and on the org's current plan the required-reviewer rule is rejected for private repos (empirically: the API returns HTTP 422 "billing plan" for the reviewer rule, while custom deployment branch policies on the same environment are accepted and live -- they are not the same plan gate). Compensating controls, both verified by the drift audit rather than assumed: amain-only custom branch policy (ENV-REVIEWERLESS-POLICYfires if removed or widened) and zero non-admin write actors (ENV-REVIEWERLESS-TRIPWIREfires when one appears). Add the reviewer rule if that repo ever goes public.
release-signing-smoke.yml (workflow_dispatch) proves the monorepo
plumbing without cutting a release: the gated job mints a RELEASE app token,
builds :app / :wear-device / :watchface assembleRelease with the
environment-held keystore, and asserts the phone and wear APKs' signing
certificate SHA-256 still matches the shipped release cert (the frozen
signing identity). :watchface must build but is excluded from the cert
assertion -- its release build type deliberately signs with the debug
config until production watchface distribution is set up
(apps/mobile/watchface/build.gradle.kts), so it has never carried the
release identity. The no-environment job asserts all six secrets resolve
len=0 outside the gate.
The op-load-secrets composite
.github/actions/op-load-secrets/action.yml is the reference composite for
loading secrets from 1Password inside a gated job. It mirrors
android-unofficial's op-load-signing-secrets:
- Fail-closed preflight -- errors if
OP_SERVICE_ACCOUNT_TOKENis unset (which is what happens outside the gated environment). - SHA-pinned 1Password actions --
install-cli-actionandload-secrets-action, pinned by commit with a version comment. - Text fields via
load-secrets-actionwithexport-env: true, the 1Password field name identical to the exported env-var name (no renaming). - File attachments via
op read --out-fileunderumask 077with a pre-delete, anERRtrap, andchmod 600--load-secrets-actioncannot resolve file attachments. - Caller sets
OP_SERVICE_ACCOUNT_TOKENat job env -- a composite cannot read thesecretscontext, so the token is mapped by the calling job and inherited, never re-declared here. - Caller-side
if: always()cleanup -- a composite cannot register a post-job step, so the caller removes any materialized file.
op:// references are hardcoded on purpose
The SA token's 1Password scope is vault-level -- it can read any item in
the vault -- so the hardcoded op:// reference is not access control on
the token. It is the only item-level control in the caller chain: it stops a
poisoned caller from repointing the whole-vault token at another item.
Prefer a per-purpose composite with hardcoded references over a generic
item-input composite, which would hand a poisoned caller exactly that
repoint. True item-level isolation of the token itself requires per-purpose
scoped service accounts or a github-vault split -- tracked as a follow-up,
and the reason a future consumer of this whole-vault token must independently
prove it is safe.
Adding a consumer
- Copy
op-load-secretsto a per-purpose composite; swap the hardcodedop://references for your item's fields/attachments and rename the exported env vars to match the field names. - Give the consuming job
environment: op-github-gated(job level -- a reusable workflow'son.workflow_calland a composite both cannot declare it, so the job is the gate point) and mapOP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.BACKEND_ACTIONS_SERVICE_ACCOUNT }}at job env. - Never reference the SA token in a
pull_request/pull_request_targetworkflow (check-secret-invariants.pyflags this asSA-REF-PR). - When you move a secret behind the environment, delete every plain repo and
organization copy in the same change, and add the environment to
EXPECTED_GATED_ENVIRONMENTSincheck-secret-invariants.py.
Proving the plumbing
secrets-plumbing-check.yml (workflow_dispatch) proves the pattern without
touching a real secret:
- The gated job declares the environment, pauses on the reviewer, and --
once approved -- resolves the non-secret
canaryfield via the composite. - The no-environment job resolves
BACKEND_ACTIONS_SERVICE_ACCOUNTand asserts it is empty (len=0); a non-empty value means a plain copy still exists outside the gate.
Because prevent_self_review = true, the approver must be someone other than
the dispatcher. And because the branch policy admits only main/develop,
dispatch the smoke from one of those refs.
Give the canary field a distinctive value (e.g. backend-actions-plumbing-ok),
not a short common string: load-secrets-action masks the resolved value
run-wide, and masking a 2-character token would garble unrelated words in the
logs.