🪝 yoink

AGE secrets in GitHub Actions

Generate a CI-only AGE identity, paste it into a GitHub secret, and deploy from Actions with a single environment variable.

How to use age-sealed secrets from a CI workflow. The whole integration is one env var.

One-paragraph refresher. age uses an asymmetric keypair. The recipient (age1…, public) goes in yoink.yaml and seals new values. The identity (AGE-SECRET-KEY-1…, private) goes in your secret manager and unseals at deploy time. CI gets its own pair: a fresh identity it owns and never shares, plus the matching recipient added alongside the laptop recipient(s) in yoink.yaml so either can decrypt. See the secrets guide for the full mental model.

Prerequisites

You've already set up sealed secrets locally; see Sealed secrets (age) if not. Specifically:

  • secrets.age is committed to the repo (encrypted)
  • yoink.yaml has a secrets.recipients: list with at least your laptop's recipient
  • Your laptop's identity is saved somewhere readable to you (default: ~/.config/yoink/keys/<recipient>.key, written by yoink secrets key generate)

Setup

Generate a CI-only identity and ship it to GitHub

Each principal that needs to decrypt (your laptop, CI, a teammate's laptop) gets its own age keypair. Don't reuse the laptop pair for CI; if a CI runner is ever compromised you want to revoke its identity without disturbing day-to-day operator workflow.

One command does the generate + ship:

yoink secrets key generate --print | gh secret set YOINK_AGE_KEY --repo you/your-repo

--print sends the secret to stdout; the header, recipient, and follow-up notes go to stderr. The pipe captures only the AGE-SECRET-KEY-1… line, so gh secret set gets a clean key, and you still see the recipient (age1…) on your terminal so you can copy it into yoink.yaml in the next step.

If you'd rather paste manually: run without the pipe, copy the secret out, then Repo Settings → Secrets and variables → Actions → New repository secret named YOINK_AGE_KEY. Clear your scrollback (reset) when done; bare key generate --print leaves the identity in your terminal.

Add the CI recipient to yoink.yaml

recipients: is a list. Add the new CI recipient alongside the existing laptop recipient; don't replace it, or you'll lock yourself out of the file you just sealed. Either identity decrypts the same secrets.age; that's the whole reason recipients is a list.

secrets:
  provider: age
  recipients:
    - age1...your-laptop-recipient                                    # already there
    - age1w8jcq22re378p38nxrudmjqdkyh42cyzsge7snwzqxlzyqt7fgkqmmvy45  # CI

Re-seal so both identities can decrypt:

yoink secrets edit         # save without changes — picks up the new recipient
git add yoink.yaml secrets.age
git commit -m "chore: add CI to age recipients"
git push

Wire it into the workflow

# .github/workflows/deploy.yml [step]
name: Deploy
on:
  push:
    branches: [live]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install yoink
        run: |
          curl -LsSf https://github.com/oddur/yoink/releases/latest/download/yoink-installer.sh | sh
          echo "$HOME/.cargo/bin" >> $GITHUB_PATH

      - name: Deploy
        run: yoink up --tag api=${{ github.sha }} --tag web=${{ github.sha }}
        env:
          YOINK_AGE_KEY: ${{ secrets.YOINK_AGE_KEY }}

Yoink finds YOINK_AGE_KEY in the env, decrypts secrets.age on the runner, and uses the values exactly like a laptop deploy would.

How yoink finds the key

Same code path locally and in CI; yoink tries each source in order, stops at the first match:

OrderSourceWhen
1YOINK_AGE_KEY env (raw key)CI
2YOINK_AGE_KEY_FILE env (path)Explicit override
3~/.config/yoink/keys/*.keyLaptop default; picks the key whose public matches secrets.recipients:
4~/.config/yoink/age.keyLegacy fallback

If none match, yoink errors with a pointer to yoink secrets key generate.

Rotating the CI key

When you want to roll the CI identity (left the company, suspected leak, periodic hygiene):

Run yoink secrets rotate

Generates a new identity, re-seals secrets.age against [old recipients + new recipient], and prints the new private + public key. After this step yoink.yaml and secrets.age are already updated locally; commit them.

yoink secrets rotate

Update the YOINK_AGE_KEY GitHub secret

To the new private key the rotate command printed.

Verify CI decrypts with the new key

Push and watch a deploy run. The transitional state has both identities able to decrypt, so a stale runner doesn't break the deploy during the swap.

Drop the old recipient

Once CI is decrypting fine, remove the old recipient from yoink.yaml and run yoink secrets edit (save without changes) to re-seal against the new recipient only.

The transitional state where both keys can decrypt prevents a deploy outage during the swap.

Checking what's sealed (without revealing values)

In CI debug, useful to confirm yoink can read what you expect:

- name: List sealed keys
  run: yoink secrets show       # masked by default
  env:
    YOINK_AGE_KEY: ${{ secrets.YOINK_AGE_KEY }}

show --reveal prints actual values. Don't use --reveal in CI: workflow logs are visible to anyone with read access to the repo. (Yoink also refuses to do this when $CI is set; override with YOINK_ALLOW_REVEAL_IN_CI=1 if you really mean it, e.g. when piping into a different secret store rather than into build logs.)

Two axes of rotation

Recipients (who can decrypt) and values (what's stored) are independent, an important distinction during incident response. Editing yoink.yaml recipients: rotates who can decrypt; editing secrets.age via yoink secrets edit rotates what's stored. A leaked CI identity needs both: revoke the recipient (so the leaked identity can't decrypt new edits) and rotate every value (because the attacker has a copy of the old secrets.age and the leaked identity already decrypts it).

Failure modes

  • age decryption failed: no matching key: CI's secret doesn't match any recipient in secrets.age. Either the GitHub secret wasn't updated after a rotation, or you forgot to re-seal after adding the new recipient. Run yoink secrets show locally with the same key to confirm.
  • no age identity found: the workflow forgot the env: YOINK_AGE_KEY: block on the deploy step.
  • workflow logs leak the values: never use yoink secrets show --reveal in CI; never echo $DATABASE_URL in a step. GitHub auto-masks values that match registered secrets, but yoink-decrypted plaintext isn't registered.

See also

  • Secrets guide — full mental model for the keypair shape and the provider: command escape hatch when GitHub Secrets isn't where your secrets live.
  • Pre-merge dry-run on every PR — same YOINK_AGE_KEY setup, applied to PR-time validation.

On this page