Safely store GitHub credentials and project environment archives
Project description
gh-vault
gh-vault keeps named GitHub tokens and secret project values in GPG-encrypted pass entries while allowing explicitly declared public variables in a restrictive XDG archive. It archives and restores per-project environments, syncs declared GitHub Actions values, runs local Actions with ephemeral files, and checks workflow wiring. Secret values never enter public metadata or ordinary command output.
Requirements
- Linux, Python 3.10+,
pass, and GPG ghauthenticated with access to the target repository for Actions commands
sudo apt install pass gnupg
gpg --full-generate-key
pass init YOUR_GPG_KEY_ID
Installation
Install gh-vault from PyPI:
uv tool install forgegod-gh-vault
This installs the released version into the active uv tool environment and provides the gh-vault console script on your PATH.
Releases are tag-driven via GitHub Actions trusted publishing. The full setup checklist, environment rules, and tag conventions live in docs/RELEASING.md. In short: bump gh_vault.__version__, commit, push a v<version> tag — nothing else publishes.
Development installation
When working from a repository checkout — either to develop gh-vault itself or to test a local change — pick the mode that matches how you intend to use the checkout.
Live edit mode (--editable)
uv tool install --editable .
Installs a .pth shim that points back at src/ in this checkout. Edits to the source tree take effect the next time you invoke gh-vault — no reinstall needed. Use this when you are developing gh-vault itself.
Snapshot mode (--force --from .)
uv tool install --force --from . gh-vault
Builds a regular install from this checkout and copies the package into ~/.local/share/uv/tools/gh-vault/. The installed tool is frozen at the current source state; subsequent edits are invisible until you reinstall. Use this when you want the checkout's current state to behave like a release build, or when you do not want local edits to bleed into the running tool.
When to choose which
| Goal | Mode |
|---|---|
Developing or debugging gh-vault |
Live edit (--editable .) |
| Trying the current checkout as a release-like build | Snapshot (--force --from . gh-vault) |
| Switching back to live edits after a snapshot install | uv tool install --editable . (overwrites the snapshot) |
| Returning to the released version after any checkout install | uv tool install --force forgegod-gh-vault |
The --force flag in snapshot mode only matters when gh-vault is already installed in that tool venv — it forces overwrite instead of skipping. Omit it on a clean install.
Storage locations
| Artifact | Location | Mode |
|---|---|---|
| Token values | pass entries under gh-vault/<profile> |
GPG-encrypted |
| Archived secret values and eligible templates | pass entries under gh-vault/projects/<host>/<owner>/<repo>/ |
GPG-encrypted |
| Archived public variable values | ${XDG_CONFIG_HOME:-~/.config}/gh-vault/environments/<host>/<owner>/<repo>/env[.<profile>].variables.json |
0600; parent directories 0700 |
| Value-free environment index | ${XDG_CONFIG_HOME:-~/.config}/gh-vault/environments/<host>/<owner>/<repo>/environments.json |
0600; parent directories 0700 |
| Profile metadata (scopes, notes, expiration) | ${XDG_CONFIG_HOME:-~/.config}/gh-vault/config.json |
0600 |
Generated .env, .secrets, .vars |
Project checkout (gitignored) | 0600 |
The password store root follows ${PASSWORD_STORE_DIR:-~/.password-store}. Metadata contains no secret values — only profile names, scopes, notes, and expiration timestamps.
Token profiles
A profile is a named GitHub token stored in the encrypted vault. Profile names match the pattern [A-Za-z0-9][A-Za-z0-9._-]{0,63} — 1 to 64 characters, the first character must be a letter or digit, and the remaining characters may be letters, digits, ., _, or -. Leading -, _, or . is rejected. The validator's error message names both rules.
Create or replace a profile
# Interactive prompt; creates or replaces the named profile
gh-vault set repo-read
# Explicit scope override (disables automatic scope detection)
gh-vault set repo-read --scopes contents:read,metadata:read
# Add an operator note shown in `gh-vault list`
gh-vault set production --note "org-wide deploy key"
# Read token from stdin for automation
printf '%s' "$TOKEN" | gh-vault set ci --stdin
# Pipe the live GitHub CLI token into a profile. Choose any valid name, e.g.
# the GitHub login or a topic-scoped alias. Profile names cannot start with
# `_`, `-`, or `.`.
gh auth token | gh-vault set ghcli-forgegod --stdin
set always creates or replaces the profile. When --scopes is omitted, set makes one authenticated request to https://api.github.com/user. A successful response validates the token and records:
- Classic PATs: scopes from the
X-OAuth-Scopesheader, expiration fromGitHub-Authentication-Token-Expirationwhen present.
Before contacting GitHub, --stdin rejects inputs that are empty, multiline, shorter than 36 or longer than 255 characters, contain characters outside [A-Za-z0-9_], or match the masked-output sentinel that gh auth status prints without -t (a recognised gh[pousr]_* or github_pat_* prefix followed by run of *). The error message names the failing gate so a misconfigured pipeline fails fast instead of being stored as a worthless token.
- Fine-grained tokens: scope list stays empty (GitHub does not expose classic scopes), expiration is still recorded when GitHub provides it.
When --scopes is supplied, the manual scopes are stored verbatim and scope detection is skipped. If the token cannot be validated against GitHub but --scopes was supplied, the profile is still created without validation. Without --scopes, a failed validation aborts the profile creation.
The first profile created becomes the active profile.
List, select, and inspect profiles
gh-vault list # all profiles, scopes, expiration, notes; * marks active
gh-vault activate repo-read # select the default profile
gh-vault status # show the active profile; exits 1 if none
To check whether a token is already stored without exposing every vault value:
printf '%s' "$TOKEN" | gh-vault find --stdin
find prints each matching profile name and exits 0 when at least one match exists. An unknown token produces no output and exits 1. The token is accepted only through explicit --stdin; empty and multiline values are rejected.
Remove a profile
gh-vault remove ci
Removing the active profile leaves no active profile. Selection never falls back implicitly to another profile.
Run a command with a token
# Uses the active profile
gh-vault run -- gh repo view owner/repo
# Name a specific profile
gh-vault run --name production -- gh repo clone owner/repo
Sets both GH_TOKEN and GITHUB_TOKEN only in the exec'd child environment. The invoking shell is not mutated.
Pipe a token to standard input
gh-vault output | docker login ghcr.io --username USERNAME --password-stdin
gh-vault output --name production | docker login ghcr.io --username USERNAME --password-stdin
output intentionally prints only the selected token plus a trailing newline. This is the credential-output boundary for tools that accept secrets on standard input; do not use it where stdout is logged. GitHub Container Registry requires a classic personal access token with the necessary package scopes (read:packages to pull and write:packages to push).
Git credential helper
git config credential.https://github.com.helper '!gh-vault git-credential'
Responds only to HTTPS requests for github.com. The get operation outputs the standard credential-helper protocol response (username=x-access-token + the token as password). store and erase are no-ops. Outside this protocol, only the explicit output command writes a token to stdout.
To switch from gh auth git-credential:
git config --unset credential.https://github.com.helper
git config credential.https://github.com.helper '!gh-vault git-credential'
Project environment archive
Archives split typed .env and .env.<profile> declarations by sensitivity under the normalized remote.origin.url namespace (<host>/<owner>/<repo>): variable values use restrictive JSON below ${XDG_CONFIG_HOME:-~/.config}/gh-vault/environments/, while secret values remain encrypted in pass. Unmarked local values are never archived. Templates are encrypted only for profiles containing secrets.
Archive
# Run in the project checkout
gh-vault env archive
# Archive named variants; repeat --env-file for multiple profiles
gh-vault env archive --env-file .env.development --env-file .env.production
# An explicit template path is supported for one selected environment
gh-vault env archive --env-file .env.production --example-file deploy/production.template
Named files use their profile name in both stores. gh-vault env list reads the value-free public index and lists every archived variant plus whether an encrypted template exists. gh-vault env show [--env-file .env.<profile>] prints only public variables and never reads pass; an empty profile prints No archived variables.
Restore
gh-vault env restore # uses current .env.example, refuses overwrite
gh-vault env restore --force # overwrite existing .env
gh-vault env restore --restore-example # restore the archived template too
gh-vault env restore --force --restore-example
gh-vault env restore --env-file .env.production
gh-vault env restore --key API_KEY # append only API_KEY to the target .env
gh-vault env restore --key API_KEY --env-file .env.production
gh-vault env list
gh-vault env show
Restore checks that every payload origin matches the current checkout. --env-file .env.<profile> selects that named archive and uses .env.example.<profile> unless --example-file is supplied. It merges public variables and encrypted secrets onto the local template while preserving comments and directives; archived keys absent from the template are appended under # Local additions. Variable-only restores require the local template and never access pass. --restore-example works only when an encrypted template exists. Legacy monolithic archives are not read implicitly by normal commands.
--key NAME writes only the named archived key to the target .env with a synthetic # gh-vault: secret or # gh-vault: variable directive line. The type is read from the archive: the key is in the public variable store if it was archived as a variable, otherwise in the encrypted secret store. If the target .env already exists, the directive line is appended; otherwise the file is created with the directive + assignment only. --key does not require --force and refuses to combine with --restore-example. The key name must match [A-Za-z_][A-Za-z0-9_]*; an unknown key raises StoreError naming the target env file. The appended line is exactly two lines (# gh-vault: <kind>\nKEY=VALUE\n), so it composes cleanly with existing content as long as the file is already newline-terminated.
Run with project environment
gh-vault env run -- ./scripts/report.sh
gh-vault env run -- python exporter.py --test --verbose
Injects only values marked by an adjacent # gh-vault: secret or # gh-vault: variable directive, under their ordinary dotenv key. Unmarked local values are deliberately excluded. The command uses the conservative dotenv parser (see below), never the shell.
GitHub Actions values
An adjacent directive selects the GitHub Actions store while keeping a standard dotenv key:
# gh-vault: variable
REGION=eu-west-1
# gh-vault: secret
API_KEY=synthetic-value
# gh-vault: secret hermes-agent
GITHUB_TOKEN=
LOCAL_ONLY=local
The directive applies only to the immediately following assignment. Unmarked values are local-only and ignored by Actions commands. Legacy GH_SECRET_* and GH_VAR_* declarations are rejected. Names matching GITHUB_*, RUNNER_*, CI, or GH_TOKEN are reserved and skipped — except for profile-referenced secrets, which intentionally override the reserved guard so GITHUB_TOKEN can be resolved from a stored profile.
The # gh-vault: secret <profile> shape references a token stored in pass under gh-vault/<profile>. The value on the next line is irrelevant — leave it empty. Resolution happens at three points:
gh-vault env runinjects the resolved token into the child process environment under the assignment key.gh-vault secret sync(andsecret export-act,run-act) resolve the token and push/write it;secret sync --migrate-typesand--pruneapply the same way as for literal secrets.gh-vault workflow checktreats profile-referenced entries as declared local secrets, so they are not flagged as orphans.
gh-vault env archive and gh-vault env migrate refuse profile-referenced declarations — their values are not real secrets, so archiving a placeholder literal would be wrong. Profile references are valid for secret only; # gh-vault: variable <profile> is rejected because vault profiles hold GitHub tokens, not variables.
The directive is gh-vault's opt-in declaration for archive storage, GitHub synchronization, and workflow validation. GitHub may contain manually managed Secrets or Variables, but gh-vault does not treat them as managed workflow values without the matching local directive.
Migrate legacy declarations and archives
Migration is explicitly two-stage so classification is reviewed before any value enters clear-text storage:
gh-vault actions migrate-env --env-file .env
# Review every generated secret/variable directive.
gh-vault env migrate --env-file .env
actions migrate-env rewrites only GH_SECRET_* and GH_VAR_* assignments in the selected environment and matching template. It preserves comments and commented template assignments, leaves unprefixed values local-only, and refuses collisions or unsupported syntax before replacing either file. env migrate is the only command that reads the legacy encrypted archive. It partitions values by the reviewed directives, verifies the new public and encrypted payloads, excludes local-only values, and removes the legacy payload last. Run both commands separately for each .env.<profile>.
Sync declared values to GitHub
secret sync and variable sync are independent and each set only their own GitHub Actions store. --prune and --migrate-types are mutually exclusive on each command.
# Secret side: preview, set, migrate, prune
gh-vault secret sync --dry-run
gh-vault secret sync
gh-vault secret sync --migrate-types
gh-vault secret sync --prune
gh-vault secret sync --repo owner/repo
# Variable side: matching options for the Variables store
gh-vault variable sync --dry-run
gh-vault variable sync
gh-vault variable sync --migrate-types
gh-vault variable sync --prune
gh-vault variable sync --repo owner/repo
secret sync creates or updates only GitHub Secrets and never touches GitHub Variables. variable sync creates or updates only GitHub Variables and never touches GitHub Secrets. On either side, ordinary sync never deletes. --migrate-types resolves a type change in one direction only: secret sync --migrate-types removes a same-name GitHub Variable before setting the Secret, and variable sync --migrate-types removes a same-name GitHub Secret before setting the Variable. --prune removes remote values in the target store whose names have no typed local declaration; same-name opposite-type local declarations protect their remote counterpart. --dry-run reports counts without touching GitHub.
Check local declarations against GitHub
secret check and variable check are independent and scoped to their own GitHub Actions type. Each one is nonzero-exit until every finding in its scope is resolved and never modifies .env.
# Local secret declarations vs. GitHub Secrets only
gh-vault secret check
gh-vault secret check --repo owner/repo
# Local variable declarations vs. GitHub Variables only
gh-vault variable check
gh-vault variable check --repo owner/repo
secret check reports three categories, all nonzero-exit until resolved:
- Missing secrets (declared locally, absent on GitHub)
- Remote-only secrets (exist on GitHub but not in
.env) - Secret-to-variable drift (declared as Secret locally, exists as Variable remotely)
variable check reports three categories, all nonzero-exit until resolved:
- Missing variables (declared locally, absent on GitHub)
- Remote-only variables (exist on GitHub but not in
.env) - Variable-to-secret drift (declared as Variable locally, exists as Secret remotely)
Findings for the opposite type belong to the other command; they do not affect the current command's exit code.
Before pushing changes that affect Actions declarations, run the matching remote review sequence for each touched type. Local commits use gh-vault workflow check as the offline wiring gate; remote secret/variable checks are not a local-commit prerequisite.
# Secret-side changes
gh-vault secret sync --dry-run
gh-vault secret check
# Variable-side changes (run alongside the secret pair when both types moved)
gh-vault variable sync --dry-run
gh-vault variable check
Type transitions
Changing a directive changes both archive storage and GitHub synchronization eligibility. An unclassified local-only value is not archived by gh-vault. GitHub uses separate Secret and Variable stores, so cross-type remote changes are deliberately explicit.
| Source | Target | Exact directive edit | Resulting archive | Archive command | GitHub behavior and follow-up |
|---|---|---|---|---|---|
secret |
secret |
Keep # gh-vault: secret; edit value only |
Encrypted pass payload |
gh-vault env archive |
Ordinary gh-vault secret sync updates it |
variable |
variable |
Keep # gh-vault: variable; edit value only |
Public XDG payload | gh-vault env archive |
Ordinary gh-vault variable sync updates it |
| local-only | local-only | Keep no directive; edit value only | No gh-vault archive | gh-vault env archive removes any stale archive |
Remote values are untouched |
secret |
variable |
Replace secret with variable |
Public XDG payload; stale encrypted payload removed after verification | gh-vault env archive |
Run variable sync --dry-run, then variable sync --migrate-types |
variable |
secret |
Replace variable with secret |
Encrypted pass payload; stale public payload removed after verification |
gh-vault env archive |
Run secret sync --dry-run, then secret sync --migrate-types |
| local-only | secret |
Add # gh-vault: secret immediately above the assignment |
Encrypted pass payload |
gh-vault env archive |
Review with secret sync --dry-run, then ordinary secret sync |
| local-only | variable |
Add # gh-vault: variable immediately above the assignment |
Public XDG payload | gh-vault env archive |
Review with variable sync --dry-run, then ordinary variable sync |
secret |
local-only | Remove the adjacent secret directive |
No gh-vault archive for that value | gh-vault env archive |
Remote Secret remains. Before secret sync --prune, run the full pre-push review sequence above |
variable |
local-only | Remove the adjacent variable directive |
No gh-vault archive for that value | gh-vault env archive |
Remote Variable remains. Before variable sync --prune, run the full pre-push review sequence above |
Import repository Variables into .env
gh-vault variable import
gh-vault variable import --repo owner/repo
gh-vault variable import --force # overwrite existing variable declarations
Reads repository variables via gh variable list and writes standard keys with # gh-vault: variable directives. Targets .env when it exists, otherwise writes commented assignments in .env.example. Existing entries are retained unless --force is supplied; force overwrites only an existing variable declaration and refuses to reclassify a secret or local-only key.
Run local Actions with ephemeral values
gh-vault run-act -- act workflow_dispatch
# or, equivalently, when only the gh CLI is on PATH:
gh-vault run-act -- gh act workflow_dispatch
run-act creates separate secret and variable files in a mode-0700 temporary directory, appends --secret-file and --var-file to the supplied act command, and removes the files after success or child failure. Both files always exist at mode 0600, even when empty. Unmarked local values are excluded. Supplying either managed file flag manually is rejected. SIGKILL or a host crash can prevent normal cleanup.
gh-vault secret export-act remains available when explicit persistent .secrets and .vars files are required. Multi-line values use the @base64: prefix understood by act.
Validate workflow wiring
gh-vault workflow check
gh-vault workflow check --json # machine-readable output
gh-vault workflow check --fix # print suggested env block for unreferenced values
Scans .github/workflows/*.yml and *.yaml for secrets.NAME and vars.NAME references, then cross-checks against local declarations. Reports four finding types, each as file:line: severity: explanation:
| Severity | Finding | Description |
|---|---|---|
error |
Unreferenced local value | A typed declaration in .env is never referenced by any workflow |
error |
Type mismatch | Workflow uses vars.NAME but .env marks NAME as secret, or vice versa |
error |
Fallback order | Expression puts vars.X before secrets.X in a || chain |
warning |
Orphan reference | Workflow references a name not declared locally and with no fallback default |
Excludes GitHub-provided names like GITHUB_TOKEN. Exits nonzero if any errors exist (warnings alone do not fail). --fix prints a suggested env: block for unreferenced local values. Does not impose repository-specific namespace mappings.
Dotenv syntax reference
gh-vault uses a conservative dotenv parser — never eval, never shell expansion. Accepted syntax:
| Syntax | Behavior |
|---|---|
KEY=value |
Bare assignment |
export KEY=value |
Leading export is stripped |
KEY="value" |
Double-quoted; JSON-style escapes decoded (\n, \t, \", \\) |
KEY='value' |
Single-quoted; content taken verbatim, no escapes |
KEY=@file:path |
Reads the file content (relative to .env directory) |
KEY=@base64:data |
Base64-decodes the data |
# gh-vault: secret |
Marks the immediately following assignment as a GitHub Secret |
# gh-vault: variable |
Marks the immediately following assignment as a GitHub Variable |
# gh-vault: secret <profile> |
Resolves the assignment to the token stored under gh-vault/<profile> in pass; the literal value is ignored |
# comment |
Comment line, ignored |
value # trailing |
Inline comment stripped (space before # required) |
Rejected syntax: $(command), ${variable}, backticks, and any construct requiring shell evaluation. This is deliberate — .env files are data, not executable scripts.
Templates retain classification without activating assignments:
# gh-vault: variable
# REGION=
# gh-vault: secret
# API_KEY=
The directive must remain immediately adjacent to the commented assignment. This keeps conventional .env.example placeholders while preserving type metadata for migration and restore.
Values with embedded newlines are stored as @base64: when written to .env or exported for act.
Security model
- Tokens, secret environment values, and eligible archive templates live only in
passundergh-vault/. - Only values explicitly marked
# gh-vault: variablemay enter the public XDG archive. Operators must classify them as safe for clear-text local storage before archiving or migration. - Public variable payloads and value-free indexes are mode
0600below${XDG_CONFIG_HOME:-~/.config}/gh-vault/environments/, with mode-0700directories. Secret and local-only values never enter those files orconfig.json. .env,.secrets, and.varsare ignored by Git. Generated files are mode0600.- The only token stdout is the exact credential-helper response Git requires.
- Token validation against
https://api.github.com/usersends the token to GitHub over HTTPS; no third party is involved. - Config writes are atomic (temp file +
fsync+os.replace) and always set mode0700directory /0600file.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file forgegod_gh_vault-0.1.1.tar.gz.
File metadata
- Download URL: forgegod_gh_vault-0.1.1.tar.gz
- Upload date:
- Size: 51.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
76a50a72960a6770eef2f250cafe2e89dcac55dc93fcf396f5eb1ff2700d07c3
|
|
| MD5 |
f1d0f01bbea7553aabeed6fbe0b6ac41
|
|
| BLAKE2b-256 |
7131739ff8dc9cfb2e2b14b6e7b7abb01b1b130e27a9a4dc1093eed16d29dd02
|
Provenance
The following attestation bundles were made for forgegod_gh_vault-0.1.1.tar.gz:
Publisher:
publish.yml on forgegod/gh-vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
forgegod_gh_vault-0.1.1.tar.gz -
Subject digest:
76a50a72960a6770eef2f250cafe2e89dcac55dc93fcf396f5eb1ff2700d07c3 - Sigstore transparency entry: 2200945293
- Sigstore integration time:
-
Permalink:
forgegod/gh-vault@c5ce8c5e452b0fea5a957be86ab18104cb8746ea -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/forgegod
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c5ce8c5e452b0fea5a957be86ab18104cb8746ea -
Trigger Event:
push
-
Statement type:
File details
Details for the file forgegod_gh_vault-0.1.1-py3-none-any.whl.
File metadata
- Download URL: forgegod_gh_vault-0.1.1-py3-none-any.whl
- Upload date:
- Size: 32.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2830826e72b5664be23623f8eb56d641ff8e82218a26b2d28a9b996989f5b13c
|
|
| MD5 |
326fea476c4bc82bd3381b8dca1c2f09
|
|
| BLAKE2b-256 |
cfd0ed73e08764383c35828be01e2d1d21c5d6f6b61b325f9aaae474eb60a079
|
Provenance
The following attestation bundles were made for forgegod_gh_vault-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on forgegod/gh-vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
forgegod_gh_vault-0.1.1-py3-none-any.whl -
Subject digest:
2830826e72b5664be23623f8eb56d641ff8e82218a26b2d28a9b996989f5b13c - Sigstore transparency entry: 2200945330
- Sigstore integration time:
-
Permalink:
forgegod/gh-vault@c5ce8c5e452b0fea5a957be86ab18104cb8746ea -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/forgegod
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c5ce8c5e452b0fea5a957be86ab18104cb8746ea -
Trigger Event:
push
-
Statement type: