Skip to content

Commit 04b33ff

Browse files
ineedjetclaude
andauthored
feat: make a vault manifest's env: optional (#158)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent acf8459 commit 04b33ff

4 files changed

Lines changed: 48 additions & 4 deletions

File tree

‎.github/actions/encrypt-env/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ env:
3838
DISABLE_SIGNUP: true # output name: literal value, no lookup at all
3939
```
4040
41-
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. An `env:` value wrapped as `${NAME}` is a reference - looked up in Secrets first, then Variables, and the action fails if it resolves to neither. Any other value (a bare string, number, or boolean) is a literal, used as-is with no lookup and no way to fail on "missing." The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
41+
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. An `env:` value wrapped as `${NAME}` is a reference - looked up in Secrets first, then Variables, and the action fails if it resolves to neither. Any other value (a bare string, number, or boolean) is a literal, used as-is with no lookup and no way to fail on "missing." `env:` can be empty (`env: {}`) or omitted entirely for an app that needs zero vault-sourced values - it just renders an empty `.env`. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.

‎.github/actions/encrypt-env/scripts/render-env.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ def load_manifest(path):
6262
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
6363
asset = manifest.get("asset")
6464
keys = manifest.get("keys")
65-
env = manifest.get("env")
65+
env = manifest.get("env") or {}
66+
manifest["env"] = env
6667
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
6768
raise ManifestError("asset must be named like server.sops.env")
6869
if not isinstance(keys, list) or not keys:
6970
raise ManifestError("keys must be a non-empty list")
70-
if not isinstance(env, dict) or not env:
71-
raise ManifestError("env must be a non-empty mapping")
71+
if not isinstance(env, dict):
72+
raise ManifestError("env must be a mapping")
7273
for key in keys:
7374
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
7475
raise ManifestError(f"invalid key name: {key!r}")
@@ -119,6 +120,8 @@ def render_env(manifest, secrets, variables):
119120
lines.append(f"{output_name}={dotenv_value(value)}")
120121
if missing:
121122
raise ManifestError("missing GitHub Secrets/Variables: " + ", ".join(sorted(missing)))
123+
if not lines:
124+
return ""
122125
return "\n".join(lines) + "\n"
123126

124127

‎.github/actions/encrypt-env/tests/test_render_env.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ def test_rejects_dict_or_list_literal(self):
5353
with self.assertRaises(render_env.ManifestError):
5454
render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv:\n TOKEN: [a, b]\n"))
5555

56+
def test_allows_omitted_env(self):
57+
manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\n"))
58+
self.assertEqual(manifest["env"], {})
59+
self.assertEqual(render_env.render_env(manifest, {}, {}), "")
60+
61+
def test_allows_empty_env_mapping(self):
62+
manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: {}\n"))
63+
self.assertEqual(manifest["env"], {})
64+
self.assertEqual(render_env.render_env(manifest, {}, {}), "")
65+
66+
def test_rejects_non_mapping_env(self):
67+
with self.assertRaises(render_env.ManifestError):
68+
render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: [a, b]\n"))
69+
5670
def test_duplicate_yaml_keys_fail(self):
5771
with tempfile.TemporaryDirectory() as directory:
5872
path = Path(directory) / "manifest.yml"
@@ -104,5 +118,30 @@ def test_main_writes_env_and_outputs(self):
104118
self.assertIn("keys=master,server\n", outputs_path.read_text())
105119

106120

121+
def test_main_writes_empty_env_for_vault_less_app(self):
122+
with tempfile.TemporaryDirectory() as directory:
123+
root = Path(directory)
124+
manifest_path = root / "manifest.yml"
125+
env_path = root / ".env"
126+
outputs_path = root / "outputs"
127+
manifest_path.write_text("asset: beszel.sops.env\nkeys: [heimdall]\n")
128+
old_env = os.environ.copy()
129+
os.environ.update(
130+
{
131+
"GITHUB_SECRETS_JSON": "{}",
132+
"GITHUB_VARS_JSON": "{}",
133+
"GITHUB_OUTPUT": str(outputs_path),
134+
}
135+
)
136+
try:
137+
result = render_env.main(["--manifest", str(manifest_path), "--output", str(env_path)])
138+
finally:
139+
os.environ.clear()
140+
os.environ.update(old_env)
141+
self.assertEqual(result, 0)
142+
self.assertEqual(env_path.read_text(), "")
143+
self.assertIn("asset=beszel.sops.env\n", outputs_path.read_text())
144+
145+
107146
if __name__ == "__main__":
108147
unittest.main()

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ env:
279279

280280
Secrets take precedence over Variables when both contain the same `${...}` reference. Every reference must resolve to an existing Secret or Variable, or the action fails; literals never fail this way since there's nothing to look up.
281281

282+
`env:` can be empty or omitted entirely for an app that genuinely needs zero vault-sourced values (e.g. its `docker-compose.yml` references no `${VAR}` placeholders at all - config lives in a mounted volume, or first-run setup happens through the app's own UI). The vault manifest is still required (`app.<name>.env_refs` must reference at least one), it just renders an empty `.env`.
283+
282284
---
283285

284286
### `build-bundle`

0 commit comments

Comments
 (0)