One YAML file. One workflow. Every target repo in sync.
JacobPEvans/secrets-sync is the distribution layer. Doppler holds the source-of-truth values; secrets-sync fans them out to every target repo’s GitHub Actions secrets and variables. It is intentionally not a vault: it never stores plaintext at rest and never logs values.
Doppler is the only writer
Every GitHub Actions secret and variable — org level and repo level alike — is owned by Doppler and arrives by sync. None are authored in GitHub, and none can be set through the API. This is enforced, not merely conventional. The GitHub App behind every minted token deliberately holds nosecrets or variables permission. App permissions are granted once at installation; a per-call request can only narrow what the installation already holds, never widen it. So any read or write against /actions/secrets or /actions/variables returns 403 Resource not accessible by integration at either scope. That 403 is the control working as intended — not a gap to work around by minting a different token or escalating privileges.
Every change here is a manual, human-reviewed step. Rotating a value means editing it in Doppler. Granting an existing secret to a new repository means adding that repo to the appropriate anchor in secrets-config.yml and merging the PR under CODEOWNERS review. There is no automated path, and automation is not the goal: writes to the credential distribution layer are deliberately gated on a human.
Workflow internals
load-config reads secrets-config.yml and emits two JSON arrays (secrets, variables). The fail-fast gate is the most important defence: if the PAT cannot reach any repo named in the config, the run terminates before writing anywhere.
Repo groupings via YAML anchors
The config uses YAML anchors so each repo joins a group exactly once; secrets reference the group, not the repo list.
Anchors are append-only in practice: a repo joins a group by adding it to the anchor’s list, not by referencing it from a single secret. The two-level structure (anchor → secret) keeps the config DRY and reviewable.
Adding a secret — the high-level path
- Add the secret value to Doppler (or directly to
secrets-syncrepo if it is not a Doppler-managed value). - Add an entry under
secrets:insecrets-config.yml, referencing the appropriate anchor forrepositories:. - Open a PR. CODEOWNERS (you) approves; branch protection requires it.
- Merge to
main. The workflow runs and syncs.
gh secret set, dry-run flag, fork-friendly forking), see the secrets-sync README. That is the operational runbook; this page is the architecture.
Source / alias semantics
Secrets can have asource: field separate from name:. When set, the workflow reads the value from secrets[source] and writes it to target repos under name. This is how a single Doppler-held secret can be distributed under different names per target repo without renaming it in Doppler.
Rotation cadence — best practice
GH_PAT_SECRETS_SYNC_ACTION(the PAT the workflow uses): 90 days, aligned with GitHub’s fine-grained PAT default.- Doppler service tokens (the
DOPPLER_TOKENdistributed to infra repos): 90 days, aligned with PAT. - GitHub App private keys: annually or on suspected compromise.
- SSH signing keys: on key rotation, which is rare.
secrets-sync repo secrets) and triggers a fresh workflow run. Target repos pick up the new value automatically.
What secrets-sync is not
- Not a vault. Values are written, never queried by the workflow.
- Not a CI runtime fetcher — Tier 2 infra secrets use
dopplerhq/secrets-fetch-actionat workflow runtime instead. - Not a label / branch-protection / metadata manager — that is
dryvist/tofu-github. - Not a bootstrap tool. New repos must already exist; the workflow validates and writes.
See also
- Doppler — the upstream source of truth for most Tier 1 secrets.
- GitHub access — how GitHub credentials are issued for local work, without storing a token.
- How it fits together — flow diagrams placing
secrets-syncin the larger picture.