From ef68111eb84fc68d0b236a0f348014f861302dfc Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Tue, 25 Aug 2026 18:35:31 +0200 Subject: [PATCH] feat: make a vault manifest's env: optional Some apps genuinely need zero vault-sourced values - config lives in a mounted volume, or first-run setup happens through the app's own UI (beszel, databasus in this catalog; changedetection, home-assistant in a downstream consumer repo). render-env.py previously rejected any manifest whose env: was empty, forcing an invented entry (e.g. a TZ value nobody verified the image even reads) just to satisfy the schema. env: can now be omitted or empty (env: {}) - renders an empty .env, verified end-to-end including a real sops encrypt/decrypt round trip on an empty dotenv file (exits 0, no error). The vault manifest itself is still required - an app's env_refs must still reference at least one - this only relaxes what that vault's own env: mapping can contain. Fixes #157 Co-Authored-By: Claude Sonnet 5 --- .github/actions/encrypt-env/README.md | 2 +- .../actions/encrypt-env/scripts/render-env.py | 9 +++-- .../encrypt-env/tests/test_render_env.py | 39 +++++++++++++++++++ README.md | 2 + 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md index 4198642..8c168d4 100644 --- a/.github/actions/encrypt-env/README.md +++ b/.github/actions/encrypt-env/README.md @@ -38,4 +38,4 @@ env: DISABLE_SIGNUP: true # output name: literal value, no lookup at all ``` -For each name in `keys`, it loads `/.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. +For each name in `keys`, it loads `/.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. diff --git a/.github/actions/encrypt-env/scripts/render-env.py b/.github/actions/encrypt-env/scripts/render-env.py index d93e8d4..e6ae03a 100644 --- a/.github/actions/encrypt-env/scripts/render-env.py +++ b/.github/actions/encrypt-env/scripts/render-env.py @@ -62,13 +62,14 @@ def load_manifest(path): raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown)) asset = manifest.get("asset") keys = manifest.get("keys") - env = manifest.get("env") + env = manifest.get("env") or {} + manifest["env"] = env if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset): raise ManifestError("asset must be named like server.sops.env") if not isinstance(keys, list) or not keys: raise ManifestError("keys must be a non-empty list") - if not isinstance(env, dict) or not env: - raise ManifestError("env must be a non-empty mapping") + if not isinstance(env, dict): + raise ManifestError("env must be a mapping") for key in keys: if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key): raise ManifestError(f"invalid key name: {key!r}") @@ -119,6 +120,8 @@ def render_env(manifest, secrets, variables): lines.append(f"{output_name}={dotenv_value(value)}") if missing: raise ManifestError("missing GitHub Secrets/Variables: " + ", ".join(sorted(missing))) + if not lines: + return "" return "\n".join(lines) + "\n" diff --git a/.github/actions/encrypt-env/tests/test_render_env.py b/.github/actions/encrypt-env/tests/test_render_env.py index c32c3d3..7121490 100644 --- a/.github/actions/encrypt-env/tests/test_render_env.py +++ b/.github/actions/encrypt-env/tests/test_render_env.py @@ -53,6 +53,20 @@ def test_rejects_dict_or_list_literal(self): with self.assertRaises(render_env.ManifestError): render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv:\n TOKEN: [a, b]\n")) + def test_allows_omitted_env(self): + manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\n")) + self.assertEqual(manifest["env"], {}) + self.assertEqual(render_env.render_env(manifest, {}, {}), "") + + def test_allows_empty_env_mapping(self): + manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: {}\n")) + self.assertEqual(manifest["env"], {}) + self.assertEqual(render_env.render_env(manifest, {}, {}), "") + + def test_rejects_non_mapping_env(self): + with self.assertRaises(render_env.ManifestError): + render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: [a, b]\n")) + def test_duplicate_yaml_keys_fail(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.yml" @@ -104,5 +118,30 @@ def test_main_writes_env_and_outputs(self): self.assertIn("keys=master,server\n", outputs_path.read_text()) + def test_main_writes_empty_env_for_vault_less_app(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = root / "manifest.yml" + env_path = root / ".env" + outputs_path = root / "outputs" + manifest_path.write_text("asset: beszel.sops.env\nkeys: [heimdall]\n") + old_env = os.environ.copy() + os.environ.update( + { + "GITHUB_SECRETS_JSON": "{}", + "GITHUB_VARS_JSON": "{}", + "GITHUB_OUTPUT": str(outputs_path), + } + ) + try: + result = render_env.main(["--manifest", str(manifest_path), "--output", str(env_path)]) + finally: + os.environ.clear() + os.environ.update(old_env) + self.assertEqual(result, 0) + self.assertEqual(env_path.read_text(), "") + self.assertIn("asset=beszel.sops.env\n", outputs_path.read_text()) + + if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md index 55fd146..d01bb74 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ env: 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. +`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..env_refs` must reference at least one), it just renders an empty `.env`. + --- ### `build-bundle`