Skip to content

Commit 5b56bbb

Browse files
release: v0.5.7 run/materialize sealed-source support
1 parent d600671 commit 5b56bbb

13 files changed

Lines changed: 629 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [v0.5.7] - 2026-03-16
11+
12+
### Added
13+
- Extend runtime rule source selection to `[[materialize.rule]]` and `[[run.rule]]` with `source = "store" | "sealed"`
14+
- Add sealed-source loading parity for `gitvault materialize` (config-selected repository files with sealed values)
15+
16+
### Changed
17+
- Update CLI reference documentation with source-selector semantics and examples for `run` and `materialize`
18+
- Add and activate REQ-119 spec coverage for run/materialize sealed-source runtime behavior
19+
1020
## [v0.5.4] - 2026-03-05
1121

1222
### Changed
@@ -161,7 +171,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
161171

162172
- Streamlined release verification flow
163173

164-
[Unreleased]: https://github.com/aheissenberger/gitvault/compare/v0.5.4...HEAD
174+
[Unreleased]: https://github.com/aheissenberger/gitvault/compare/v0.5.7...HEAD
175+
[v0.5.7]: https://github.com/aheissenberger/gitvault/compare/v0.5.6...v0.5.7
176+
[v0.5.6]: https://github.com/aheissenberger/gitvault/compare/v0.5.4...v0.5.6
165177
[v0.5.4]: https://github.com/aheissenberger/gitvault/compare/v0.5.3...v0.5.4
166178
[v0.5.3]: https://github.com/aheissenberger/gitvault/compare/v0.5.2...v0.5.3
167179
[v0.5.2]: https://github.com/aheissenberger/gitvault/compare/v0.5.1...v0.5.2

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gitvault"
3-
version = "0.5.6"
3+
version = "0.5.7"
44
authors = ["Andreas Heissenberger"]
55
repository = "https://github.com/aheissenberger/gitvault"
66
homepage = "https://github.com/aheissenberger/gitvault"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ For CI/CD workflows, gitvault provides two runtime patterns:
6161
Rule of thumb:
6262
- Prefer `run` by default in CI.
6363
- Use `materialize` only for file-bound steps (for example, migrations expecting `.env`).
64+
- Both commands can load from encrypted store artifacts and selected sealed repository files using `[[run.rule]]` / `[[materialize.rule]]` with `source = "sealed"`.
6465

6566
See full CI/CD recipes (GitHub Actions, Docker, Kubernetes): [docs/cicd-recipes.md](docs/cicd-recipes.md)
6667

docs/reference.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ Behavior:
294294
- Decrypts store files for the selected environment and writes merged values to
295295
`[materialize].output_filename` (default: `.env`).
296296
- Supports multi-format store sources (`.env.age`, `.json.age`, `.yaml/.yml.age`, `.toml.age`).
297+
- `[[materialize.rule]]` can also select sealed repository files with `source = "sealed"`.
298+
- For sealed sources, `path` is matched against repository-relative working-tree paths.
297299
- Fails if decryption fails or secret content is invalid for its detected format.
298300

299301
Examples:
@@ -364,6 +366,8 @@ Behavior:
364366
- Decrypts secrets for the selected environment and injects them into the child process env.
365367
- Does not write plaintext files to disk.
366368
- Uses the same multi-format store parsing as `materialize`.
369+
- `[[run.rule]]` can also select sealed repository files with `source = "sealed"`.
370+
- For sealed sources, `path` is matched against repository-relative working-tree paths.
367371

368372
Examples:
369373

@@ -569,13 +573,17 @@ Matcher rules are defined as array-of-table entries with `action`, `path`, and o
569573
| `action` | string | `allow` or `deny` |
570574
| `path` | string | Repo-relative glob path to match |
571575
| `keys` | array of strings | Optional key globs (applies to `allow` rules) |
576+
| `source` | string | Optional (`materialize`/`run` rules): `store` (default) or `sealed` |
572577
| `dir_prefix` | bool | Optional (`materialize`/`run` rules): include directory components as key prefix |
573578
| `path_prefix` | bool | Optional (`materialize`/`run` rules): include filename stem as key prefix |
574579
| `custom_prefix` | string | Optional (`materialize`/`run` rules): append custom token before key |
575580

576581
Notes:
577582
- Rules are evaluated in file order; later matches override earlier matches.
578583
- `keys` filters emitted key/value pairs for matching files.
584+
- `source` defaults to `store` when omitted.
585+
- `source = "store"` matches `.gitvault/store/<env>/**/*.age` inputs.
586+
- `source = "sealed"` matches repository-relative working-tree files (`.env`, `.env.<suffix>`, `<name>.env`, `.json`, `.yaml`, `.yml`, `.toml`; `.envrc` excluded).
579587
- Runtime commands support global prefix defaults via `[materialize]` and `[run]` keys: `dir_prefix` and `path_prefix`.
580588
- Prefix order is deterministic: `<DIR_PREFIX>_<FILENAME_PREFIX>_<CUSTOM_PREFIX>_<KEY>` (missing parts are skipped).
581589
- Unknown keys in rule entries fail config parsing.
@@ -631,13 +639,26 @@ path = "conf/public.json"
631639

632640
[[materialize.rule]]
633641
action = "allow"
642+
source = "sealed"
643+
path = "services/web/.env.local"
644+
custom_prefix = "MAT"
645+
646+
[[materialize.rule]]
647+
action = "allow"
648+
source = "store"
634649
path = ".gitvault/store/dev/*.env.age"
635650
dir_prefix = true
636651
path_prefix = true
637-
custom_prefix = "APP"
638652

639653
[[run.rule]]
640654
action = "allow"
655+
source = "sealed"
656+
path = "services/api/.env.local"
657+
keys = ["DATABASE_URL", "API_*"]
658+
659+
[[run.rule]]
660+
action = "allow"
661+
source = "store"
641662
path = ".gitvault/store/dev/conf/*.json.age"
642663
keys = ["DATABASE_*", "REDIS_*"]
643664
dir_prefix = false
@@ -696,15 +717,28 @@ path = "config/test-*.json"
696717

697718
[[materialize.rule]]
698719
action = "allow"
699-
path = ".gitvault/store/dev/*.env.age"
720+
source = "sealed"
721+
path = "services/web/.env.local"
700722
custom_prefix = "MAT"
701723

724+
[[materialize.rule]]
725+
action = "allow"
726+
source = "store"
727+
path = ".gitvault/store/dev/*.env.age"
728+
702729
[run]
703730
dir_prefix = false
704731
path_prefix = true
705732

706733
[[run.rule]]
707734
action = "allow"
735+
source = "sealed"
736+
path = "services/api/.env.local"
737+
keys = ["DATABASE_URL", "API_*"]
738+
739+
[[run.rule]]
740+
action = "allow"
741+
source = "store"
708742
path = ".gitvault/store/dev/conf/*.json.age"
709743
keys = ["DATABASE_*", "REDIS_*"]
710744

specs/2026-03-01-safesecrets/00-requirements-index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ This index is the canonical source for requirement-to-spec traceability.
9494
## Engineering NFR Specs (Additive)
9595
- `2026-03-04-engineering-nfr/req-110.md` -> REQ-110: Auto-discover encrypted fields in structured files (decrypt without --fields) [status: implemented]
9696
- `2026-03-04-engineering-nfr/req-111.md` -> REQ-111: Encrypt all string fields without explicit listing (--all-fields flag) [status: **superseded by REQ-112**]
97+
- `2026-03-04-engineering-nfr/req-119.md` -> REQ-119: `gitvault run`/`materialize` support config-referenced sealed-source files [status: active]
9798

9899
## Breaking Change Specs
99100
- `2026-03-04-engineering-nfr/req-112.md` -> REQ-112: `seal` and `unseal` — In-place field-level encryption commands (supersedes REQ-111; replaces encrypt --fields, --value-only; replaces decrypt --fields, --value-only) [status: done]

specs/2026-03-04-engineering-nfr/req-118.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ Multiple command surfaces currently evaluate path and key matching with partiall
7979

8080
Prior schema also relied on seal-specific keys that do not generalize well to run/materialize behavior.
8181

82+
## Update Note (2026-03-16)
83+
84+
REQ-119 extends runtime source selection for `[[run.rule]]` and
85+
`[[materialize.rule]]` with an additive optional `source` discriminator (`store` or
86+
`sealed`) while preserving REQ-118's unified
87+
matcher model, deterministic rule evaluation, and strict parse validation.
88+
8289
## Decision
8390

8491
Introduce one structured TOML rule model per command namespace:
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
id: "S-20260316-R119"
3+
title: "REQ-119: gitvault run/materialize support config-referenced sealed-source files"
4+
status: "active"
5+
owners: ["@aheissenberger"]
6+
mode: ["cli"]
7+
platforms: ["Linux", "macOS", "Windows"]
8+
scope:
9+
repoAreas: ["src/**", "docs/**", "specs/**"]
10+
touch:
11+
- "src/config.rs"
12+
- "src/repo/paths.rs"
13+
- "src/commands/effects.rs"
14+
- "src/commands/materialize.rs"
15+
- "src/commands/run_cmd.rs"
16+
- "src/structured/fields.rs"
17+
- "src/structured/env_values.rs"
18+
- "docs/reference.md"
19+
- "README.md"
20+
- "specs/2026-03-04-engineering-nfr/req-118.md"
21+
avoid: ["target/**"]
22+
breaking: false
23+
supersedes: []
24+
acceptance:
25+
- id: "AC1"
26+
text: "`gitvault run` and `gitvault materialize` support two input source classes selected by unified runtime rules: (a) existing store artifacts under `.gitvault/store/<env>/**/*.age`, and (b) repository files containing sealed values. Both classes may be used in the same execution."
27+
- id: "AC2"
28+
text: "`[[run.rule]]` and `[[materialize.rule]]` are extended with optional `source = \"store\" | \"sealed\"`. Omitted `source` defaults to `store` for backward compatibility. Unknown `source` values fail config parsing with a usage error."
29+
- id: "AC3"
30+
text: "For `source = \"sealed\"`, `path` is matched against repository-relative working-tree paths (not store paths). Supported file formats are the same sealed formats as REQ-112: `.env`, `.env.<suffix>`, `<name>.env`, `.json`, `.yaml`, `.yml`, `.toml` (excluding `.envrc`)."
31+
- id: "AC4"
32+
text: "When a selected sealed-source file contains a mix of plaintext and sealed values, `gitvault run` and `gitvault materialize` emit final plaintext key/value pairs for both: plaintext values pass through unchanged; sealed values are decrypted in memory before flattening/export."
33+
- id: "AC5"
34+
text: "For `gitvault run`, `source = \"sealed\"` never writes plaintext files. Decryption, parsing, and flattening happen in memory only. REQ-21 and REQ-22 fileless guarantees remain unchanged."
35+
- id: "AC6"
36+
text: "Runtime `keys` filters and runtime prefix controls (`dir_prefix`, `path_prefix`, `custom_prefix`) continue to apply for both source classes in `run` and `materialize` using REQ-118 matcher semantics and deterministic prefix composition order."
37+
- id: "AC7"
38+
text: "Rule precedence remains REQ-118 compliant (file order, later match wins). For key collisions after flattening/prefixing, merge order is deterministic and documented: later processed entries override earlier entries; processing order is stable across repeated executions with identical inputs."
39+
- id: "AC8"
40+
text: "If a selected sealed-source file cannot be parsed, contains unsupported format, or contains an undecryptable sealed token, `gitvault run` and `gitvault materialize` fail closed with a non-zero exit and an actionable error that includes the file path context."
41+
- id: "AC9"
42+
text: "Existing run/materialize behavior for store sources is preserved unless `source = \"sealed\"` is explicitly configured. Existing configs that do not use the new field remain valid and behaviorally unchanged."
43+
- id: "AC10"
44+
text: "Tests cover: mixed plaintext+sealed `.env`; sealed JSON/YAML/TOML flattening; combined store+sealed sources in one run/materialize execution; deterministic collision handling; clear-env/keep-vars unchanged; fail-closed errors for bad sealed payloads and unsupported sealed-source files."
45+
verification:
46+
commands:
47+
- "cargo fmt --all -- --check"
48+
- "cargo clippy --all-targets --workspace -- -D warnings"
49+
- "cargo test --all"
50+
- "cargo xtask spec-verify"
51+
risk:
52+
level: "medium"
53+
links:
54+
related: "S-20260301-006"
55+
related2: "S-20260304-R112"
56+
related3: "S-20260305-R118"
57+
issue: ""
58+
pr: ""
59+
---
60+
61+
# REQ-119: `gitvault run`/`materialize` support config-referenced sealed-source files
62+
63+
## Context
64+
65+
Current runtime loading is centered on decrypting store artifacts. Teams also keep
66+
repository files with in-place sealed values and want to reference those files from
67+
runtime configuration without introducing intermediate plaintext files.
68+
69+
REQ-118 already defines the unified rule engine and runtime key-prefix behavior.
70+
REQ-119 extends that model so runtime source selection can include sealed working-tree
71+
files for `run` and `materialize` while keeping each command's security guarantees.
72+
73+
## Goal
74+
75+
Allow `gitvault run` and `gitvault materialize` to load values from both store
76+
artifacts and sealed repository files through unified runtime rule configuration,
77+
including files that contain a
78+
mix of plaintext and sealed values.
79+
80+
## Non-goals
81+
82+
- No new CLI subcommand for source selection.
83+
- No reintroduction of legacy matcher schemas.
84+
- No plaintext materialization to disk.
85+
- No change to seal/unseal command semantics.
86+
- No change to the materialize output contract (`.env` generation remains explicit materialization behavior).
87+
88+
## Constraints
89+
90+
- Keep REQ-118 as the governing rule model; extend it additively.
91+
- Maintain deterministic merge behavior and strict config parsing.
92+
- Preserve existing store-only run/materialize behavior by default.
93+
94+
## Decision
95+
96+
Extend `[[run.rule]]` and `[[materialize.rule]]` with optional `source`:
97+
98+
```toml
99+
[[run.rule]]
100+
action = "allow"
101+
source = "sealed"
102+
path = "services/api/.env.local"
103+
keys = ["DATABASE_URL", "API_*"]
104+
105+
[[run.rule]]
106+
action = "allow"
107+
source = "store"
108+
path = ".gitvault/store/prod/services/api/runtime.json.age"
109+
path_prefix = true
110+
custom_prefix = "RUNTIME"
111+
112+
[[materialize.rule]]
113+
action = "allow"
114+
source = "sealed"
115+
path = "services/web/.env.local"
116+
path_prefix = true
117+
custom_prefix = "MAT"
118+
```
119+
120+
When `source` is omitted, behavior is identical to existing store-source behavior.
121+
122+
## Requirement Coverage
123+
124+
- REQ-119.
125+
126+
## Conflict Analysis
127+
128+
| Existing requirement | Potential conflict | Resolution |
129+
|---|---|---|
130+
| REQ-21/22 (fileless run) | run sealed-source loading could imply temp files | Explicitly disallow file writes in AC5 |
131+
| REQ-112 (seal semantics) | runtime could redefine sealed format behavior | Reuse REQ-112 sealed formats and token handling rules |
132+
| REQ-118 AC2 (runtime rule keys) | schema extension might contradict strict keys | Extend AC2 additively with optional `source`; keep strict validation |
133+
| REQ-118 AC7 (order) | two source classes add merge ambiguity | AC7 defines deterministic collision precedence |
134+
135+
No requirement is superseded by REQ-119. This is an additive extension.
136+
137+
## Acceptance Criteria
138+
139+
See frontmatter acceptance list (AC1-AC10).
140+
141+
## Test Plan
142+
143+
- Unit tests for run/materialize rule parsing with `source` defaulting and validation.
144+
- Integration tests covering mixed store+sealed runtime loading and error paths.
145+
- Regression tests to prove unchanged behavior for legacy configs without `source`.
146+
147+
## Notes
148+
149+
This requirement intentionally keeps policy expression inside the existing unified
150+
rule engine instead of introducing command flags or parallel config sections.

src/commands/effects.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,24 @@ impl EffectRunner for DefaultRunner {
165165
self.materialize_path_prefix,
166166
),
167167
};
168-
crate::repo::decrypt_env_secrets_with_rules(
169-
repo_root,
170-
env,
171-
identity,
172-
rules,
173-
dir_prefix,
174-
path_prefix,
175-
)
168+
match scope {
169+
SecretRuleScope::Run => crate::repo::decrypt_runtime_secrets_with_rules(
170+
repo_root,
171+
env,
172+
identity,
173+
rules,
174+
dir_prefix,
175+
path_prefix,
176+
),
177+
SecretRuleScope::Materialize => crate::repo::decrypt_runtime_secrets_with_rules(
178+
repo_root,
179+
env,
180+
identity,
181+
rules,
182+
dir_prefix,
183+
path_prefix,
184+
),
185+
}
176186
}
177187

178188
fn run_command(

src/commands/materialize.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,44 @@ mod tests {
9595
};
9696
assert!(msg.contains("Failed to decrypt"));
9797
}
98+
99+
#[test]
100+
fn test_cmd_materialize_loads_sealed_source_from_materialize_rule() {
101+
let _lock = global_test_lock().lock().unwrap();
102+
let dir = TempDir::new().unwrap();
103+
init_git_repo(dir.path());
104+
let _cwd = CwdGuard::enter(dir.path());
105+
let (identity_file, identity) = setup_identity_file();
106+
107+
let config_dir = dir.path().join(".gitvault");
108+
std::fs::create_dir_all(&config_dir).unwrap();
109+
std::fs::write(
110+
config_dir.join("config.toml"),
111+
"[materialize]\n[[materialize.rule]]\naction = \"allow\"\nsource = \"sealed\"\npath = \"services/web/.env.local\"\n",
112+
)
113+
.unwrap();
114+
115+
let sealed_src = dir.path().join("services/web/.env.local");
116+
std::fs::create_dir_all(sealed_src.parent().unwrap()).unwrap();
117+
let recipients = vec![identity.to_public().to_string()];
118+
let fields = vec!["SECRET".to_string()];
119+
let sealed = crate::commands::seal::seal_content(
120+
"PLAIN=visible\nSECRET=hidden\n",
121+
"env",
122+
Some(&fields),
123+
&recipients,
124+
)
125+
.expect("sealing test input should succeed");
126+
std::fs::write(&sealed_src, sealed).unwrap();
127+
128+
with_identity_env(identity_file.path(), || {
129+
cmd_materialize(None, None, false, false, true, None)
130+
.expect("materialize should include configured sealed source values");
131+
});
132+
133+
let materialized =
134+
std::fs::read_to_string(dir.path().join(".env")).expect(".env should be created");
135+
assert!(materialized.contains("PLAIN=\"visible\""));
136+
assert!(materialized.contains("SECRET=\"hidden\""));
137+
}
98138
}

0 commit comments

Comments
 (0)