Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/encrypt-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ env:
DISABLE_SIGNUP: true # output name: literal value, no lookup at all
```

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.
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.
9 changes: 6 additions & 3 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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"


Expand Down
39 changes: 39 additions & 0 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.env_refs` must reference at least one), it just renders an empty `.env`.

---

### `build-bundle`
Expand Down