# Sync to GitHub Actions

> **Warning:** **Read this before setting one up: you probably want the Action instead.** GitHub Actions is a runtime seekrit *can* reach into, which makes it the one destination here where a strictly better option already exists. The published [`seekritdev/github-action`](https://github.com/seekritdev/github-action) resolves and decrypts **inside your workflow run**: ```yaml - uses: seekritdev/github-action@v1 with: token: ${{ secrets.SEEKRIT_TOKEN }} app: storefront env: production ``` Nothing is copied to GitHub, nothing is stored there, a rotated value is picked up on the next run with no re-sync, and seekrit's servers never decrypt anything. Syncing gives all three of those up. Sync is the right answer only where the Action cannot reach — see [when to sync anyway](#when-to-sync-anyway) below.

| At a glance | |
| --- | --- |
| **What seekrit writes** | Actions secrets, at repository, deployment environment, or organization scope |
| **Addressed by** | `owner/repo`, plus an environment name — or an organization login and a visibility |
| **Connection carries** | The token, and a base URL for GitHub Enterprise Server only |
| **Token permission** | Fine-grained **Secrets** repository permission (write), or the organization **Secrets** permission |
| **Takes effect** | Next workflow run |
| **Value visibility** | Write-only — GitHub never discloses a secret value again |

## When to sync anyway

GitHub resolves some things before any step of yours could run, so no Action can
supply them. Those are the cases this connector is for:

- **A third-party action that takes a credential as a `with:` input** — the value
  is interpolated from the `secrets` context when the step is created.
- **`secrets: inherit`** into a reusable workflow, which passes the calling
  workflow's `secrets` context wholesale.
- **Job-level `container:` and `services:` credentials**, resolved before the
  job's first step.
- **Dependabot and Codespaces**, which read their own secret stores and run no
  workflow you control. (Those are separate GitHub APIs; this connector writes
  Actions secrets only.)

If your situation is not one of these, use the Action.

## 1. Create a token

Prefer a **fine-grained** personal access token with the **Secrets** repository
permission set to *Read and write*, scoped to only the repositories this
connection syncs. For organization secrets, it needs the organization **Secrets**
permission instead.

A classic token needs `repo` — or `admin:org` for organization secrets — which
grants vastly more than writing secrets. Use fine-grained where you can.

```bash
printf '%s' "$GITHUB_TOKEN" \
  | seekrit sync connect --name acme-github --provider github-actions
```

A github.com connection has no second half to state: a GitHub token addresses
everything by `owner/repo` or `org`, and those are the binding's business. For a
self-hosted **GitHub Enterprise Server** appliance, add `--base-url
https://github.acme.com/api/v3`. Leave it off for github.com and for Enterprise
Cloud. It must be `https` — that URL carries the token.

## 2. Bind an environment

GitHub has three secret scopes, and a binding picks one. They are three different
endpoints with three different blast radii — and each has its **own** encryption
key, so a value sealed for one cannot be written to another.

### A repository's secrets

Readable by every workflow in it, including one added by a pull request from a
collaborator with write access. That is GitHub's model, not seekrit's — and it is
the reason to prefer the environment scope for anything touching production.

```bash
seekrit sync verify acme-github --provider github-actions \
  --gh-repo acme/storefront

seekrit sync enable --connection acme-github --provider github-actions \
  --gh-repo acme/storefront \
  --app storefront --env production --acknowledge-decryption
```

`--gh-repo` takes the pair as one flag, because that is how GitHub writes a
repository everywhere; asking for it in two invites pasting the pair into one of
them.

### A deployment environment's secrets

The narrowest scope GitHub has, and the one to prefer:

```bash
seekrit sync enable --connection acme-github --provider github-actions \
  --gh-repo acme/storefront --gh-environment production \
  --app storefront --env production --acknowledge-decryption
```

A job reads these only by declaring `environment: production`, which also
subjects it to that environment's protection rules — required reviewers, wait
timers, and the branch policy. That combination is as close as GitHub gets to
"this secret is for production, and reaching it takes an approval".

The environment must already exist. seekrit will not create one: an environment
is a deployment gate, and silently creating an unprotected one because a name was
misspelled would quietly remove the protection you were relying on.

### An organization's secrets

The widest scope in the product, and the only destination on any provider that
can hand a value to repositories nobody named.

```bash
seekrit sync enable --connection acme-github --provider github-actions \
  --gh-org acme --gh-visibility selected --gh-repo-ids 1296269,1296270 \
  --app storefront --env production --acknowledge-decryption
```

> **Warning:** **`--gh-visibility all` hands the value to every repository in the organization** — including ones added tomorrow, and public ones. seekrit deliberately has no default here and defaults the CLI to `private`; state what you mean. `selected` takes numeric repository **IDs**, not names, because that is what GitHub's API takes. Get one with `gh api repos/acme/storefront --jq .id`. seekrit does not resolve names to IDs on your behalf: guessing which repository an ambiguous name meant would widen a secret's reach silently. Passing IDs with any other visibility is refused rather than ignored, so a stored binding never contradicts its own behavior.

## How a push behaves

- **The value is encrypted before it leaves seekrit.** GitHub does not accept a
  plaintext secret: each value is sealed to the scope's own X25519 public key as
  a libsodium sealed box, and GitHub decrypts it on receipt.

  One nice consequence: GitHub never sees a plaintext value at all, so it cannot
  echo one back into an error message — the failure reasons stored on a run row
  cannot carry a secret value even in principle. Other connectors scrub responses
  for exactly that reason; here there is nothing to scrub.
- **Each scope has its own key, fetched once per run.** A value sealed for a
  repository cannot be written to that repository's `production` environment, or
  to its organization — the three are different recipients. So a run is one `GET`
  for the key, then one `PUT` per name.
- **A run is capped at 150 operations**, sized against GitHub's *secondary* rate
  limit rather than its primary one: 900 points a minute, where a `GET` costs 1
  and a `PUT` or `DELETE` costs **5**. That is 180 mutating requests a minute,
  well below the 5,000-an-hour primary limit.
- **seekrit cannot detect value drift here, only presence.** Actions secrets are
  write-only: once set, no one can read a value back through the UI, CLI, or API
   — which is what you want for something a secrets manager owns.

Values take effect on the **next workflow run**; a sync run starts nothing.

## Name and value rules

> **Note:** **Secret names are case-insensitive at GitHub**, which no other destination here is. `db_url` and `DB_URL` are one secret, so a name mapping that produces both would write it twice and report both as pushed while one value silently won. seekrit detects that before it writes anything and fails **every** name in the collision, naming them — it cannot know which one you meant. Watch for it if the binding folds case. Rename or filter all but one.

> **Note:** GitHub will not accept every name seekrit will. Secret names take only letters, numbers, and underscores, must start with a letter or an underscore, and cannot begin with `GITHUB_`, which GitHub reserves for itself. Values stop at **48 KB**, checked against the plaintext — the number you can act on — and before sealing, so an over-size body is never built to be refused. A name that breaks one of those is reported as a failure against that name alone; the rest of the environment still pushes. Watch for it if you use a `--prefix`.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `403` on every name | The token lacks the Secrets permission for that scope, or the fine-grained token doesn't include this repository | Repository, environment, and organization secrets are three permissions — check the one the binding writes |
| `404` on the environment | The deployment environment doesn't exist | Create it in Settings → Environments. seekrit will not create one, because that would silently drop its protection rules |
| `404` on the repository with a valid token | A fine-grained token that does not list this repository | Add it to the token's repository access |
| A whole set of names failed as a collision | Two mapped names differ only by case | GitHub is case-insensitive; rename or filter all but one |
| One name failed, rest landed | The mapped name starts with a digit, begins `GITHUB_`, or the value is over 48 KB | Fix the prefix or rename |
| `422` on an org write | `--gh-visibility selected` with no repository IDs, or IDs given for another visibility | Pass numeric IDs with `selected`, and none otherwise |
| Rate-limited, run reported partial | GitHub's secondary rate limit — mutating calls cost 5 points each | Nothing to do — seekrit backs off and retries the rest |
| A rotated value isn't picked up | Actions read GitHub's copy at run start | The next workflow run has it. Or drop sync and use the Action, which needs no re-sync at all |

## See also

- [`seekritdev/github-action`](https://github.com/seekritdev/github-action) — the option to prefer
- [CI/CD & containers](/docs/guides/ci-cd) — wiring seekrit into pipelines without a copy at the destination
- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
