feat(secrets): rotation scripts open the chore(secrets) PR themselves #151

Merged
jfraeys merged 2 commits from jfraeys/feat/vault-change-pr into main 2026-10-14 12:59:14 -04:00
Owner

After a rotation, the vault change used to need a hand-made chore(secrets) PR, and the manifest's rotated_at was updated separately (#143). Now every vault-writing script does both itself.

scripts/vault-pr.py --what <label> --key <KEYS> [--restore-local]

  • Commits the checkout's secrets/vault.sops.yml plus rotated_at: <today> in secrets/secrets-manifest.yml for the rotated keys, on a new chore/rotate-<label>-<timestamp> branch made in a temporary worktree off origin/main (the operator's checkout is never switched), pushes it and opens the PR with tea. Prints the PR number. Never merges, never reads a value; the commit lists key names only.
  • Refuses (and prints the manual steps, leaving the checkout's vault as it was) when main's vault moved since the checkout's commit, when tea is missing, or when commits are SSH-signed and no key is loaded (a commit would otherwise hang silently).
  • --restore-local: after the PR is open, back up the rotated vault (0600, ~/.local/state/infra/) and reset the checkout's vault to main's, so a deploy before the merge ships nothing new. Order becomes merge, git pull, deploy; it also removes the git pull conflict after the merge.

Wired in

Script --restore-local Why
rotate-secret.sh (default on; --no-pr skips) yes old values stay valid until the deploy
regenerate-ci-ephemeral-ssh-key.sh yes the deployed provisioner keeps the old key until the deploy
rotate-ovh-s3-credentials.sh no new credentials are already live at OVH
provision-headscale-api-key.sh no the previous key is expired on the server
provision-ci-state-key.py no the new Linode key is live now

Each prints "merge PR #N" when it worked and the old manual instructions when it did not.

Tests: scripts/tests/test-vault-pr.sh (throwaway bare remote + clone, fake files, fake tea): 17 checks covering the happy path, restore + backup, no restore, a failed PR open after the push, main's vault moved, nothing to commit, bad input; added to the lint workflow. test-infra-cli, test-rotation-lock, test-bitwarden-save, test-promotion pass. Not run: a real rotation.

scripts/CLAUDE.md gets the rule for future vault writers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ

After a rotation, the vault change used to need a hand-made `chore(secrets)` PR, and the manifest's `rotated_at` was updated separately (#143). Now every vault-writing script does both itself. **`scripts/vault-pr.py --what <label> --key <KEYS> [--restore-local]`** - Commits the checkout's `secrets/vault.sops.yml` plus `rotated_at: <today>` in `secrets/secrets-manifest.yml` for the rotated keys, on a new `chore/rotate-<label>-<timestamp>` branch made in a temporary worktree off `origin/main` (the operator's checkout is never switched), pushes it and opens the PR with `tea`. Prints the PR number. **Never merges**, never reads a value; the commit lists key names only. - Refuses (and prints the manual steps, leaving the checkout's vault as it was) when main's vault moved since the checkout's commit, when `tea` is missing, or when commits are SSH-signed and no key is loaded (a commit would otherwise hang silently). - `--restore-local`: after the PR is open, back up the rotated vault (0600, `~/.local/state/infra/`) and reset the checkout's vault to main's, so a deploy before the merge ships nothing new. Order becomes merge, `git pull`, deploy; it also removes the `git pull` conflict after the merge. **Wired in** | Script | `--restore-local` | Why | |---|---|---| | `rotate-secret.sh` (default on; `--no-pr` skips) | yes | old values stay valid until the deploy | | `regenerate-ci-ephemeral-ssh-key.sh` | yes | the deployed provisioner keeps the old key until the deploy | | `rotate-ovh-s3-credentials.sh` | no | new credentials are already live at OVH | | `provision-headscale-api-key.sh` | no | the previous key is expired on the server | | `provision-ci-state-key.py` | no | the new Linode key is live now | Each prints "merge PR #N" when it worked and the old manual instructions when it did not. **Tests:** `scripts/tests/test-vault-pr.sh` (throwaway bare remote + clone, fake files, fake `tea`): 17 checks covering the happy path, restore + backup, no restore, a failed PR open after the push, main's vault moved, nothing to commit, bad input; added to the lint workflow. test-infra-cli, test-rotation-lock, test-bitwarden-save, test-promotion pass. Not run: a real rotation. `scripts/CLAUDE.md` gets the rule for future vault writers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
feat(secrets): rotation scripts open the chore(secrets) PR themselves
Some checks failed
Lint / lint (pull_request) Failing after 55s
Secret scan / scan (pull_request) Successful in 13s
d11ffba820
scripts/vault-pr.py commits the checkout's vault change plus `rotated_at: <today>`
in the secrets manifest for the rotated keys, on a new branch in a temporary
worktree off origin/main (the operator's checkout is not switched), pushes it and
opens the PR with tea. It never merges and never reads a value. It refuses when
main's vault moved since the checkout's commit, when tea is missing, or when
commits are SSH-signed and no key is loaded (a commit would hang); on any failure
the checkout's vault is left as it was and the manual steps are printed.

--restore-local then resets the checkout's vault to main's (after a 0600 backup
under ~/.local/state/infra), so a deploy before the merge ships nothing new:
merge, git pull, deploy. Used only where the old value stays valid until the
deploy: rotate-secret.sh and regenerate-ci-ephemeral-ssh-key.sh. The OVH S3,
Headscale API key and CI state key scripts open the PR but keep the local vault,
because the old credential is already revoked or expired at the provider.

rotate-secret.sh opens it by default (--no-pr to skip) and its next steps say
"merge PR #N". test-vault-pr.sh (throwaway bare remote + clone, fake tea): 17
checks, added to the lint workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
test(vault-pr): portable mode check (GNU stat -f is filesystem status, so the BSD form passed on macOS only)
All checks were successful
Lint / lint (pull_request) Successful in 2m3s
Secret scan / scan (pull_request) Successful in 15s
ac2b8c08d8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!151
No description provided.