diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8c3b6..2325a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. +## [0.7.1.0] - 2026-06-08 + +### Added + +- **Version-skew nudge (EGB-713)** — the store now records the highest `secrets` + version that has written to it (`.secrets-writer-version`, committed, + monotonic). When you run a command against a store last written by a *newer* + `secrets` than your own, you get a one-line non-fatal stderr nudge to update + your tool; `secrets which` shows the store's `written-by:` version (and flags + when you're behind). Stores written by older builds carry no stamp and stay + silent — no false alarms. The loud counterpart to EGB-712's quiet + forcing function. + ## [0.7.0.0] - 2026-06-08 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 1a8dba4..7a0926c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek - Storage: Private git repo at `~/.secrets/` - Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`) - Manifest (EGB-677 stage 1): committed `.secrets.json` is the source of truth for what syncs — `dotenv[]` (project-relative, nested ok, `@` allowed; rail rejects `..`/absolute/symlink) + `external[]` (`properties`/`file`). Push discovery auto-adds (gated by committed `options.autoAdd`, default ON; `--frozen`/`--dry-run` overrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy `.secrets-files` (gradle-properties → `properties`; on pull the legacy file is superseded with a warning). Store layout: nested dotenv entries land at `/.age` (relpath preserved — the store self-describes where a file restores). jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice). `check_cmd` prints platform-aware install hints. -- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age` → `.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at , version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. +- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age` → `.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at , version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. - Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR//` both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to `/dev/null` (never written to disk). `secrets verify --all` decrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (`find -type f`, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2 `migrate --finalize` and CI. The store deliberately holds no manifest — `.secrets.json` is committed in each project's own repo and read from `$PWD`. - External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` diff --git a/README.md b/README.md index 61a432d..532ba11 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ Store-format v2 is **additive** — an upgraded client reads either blob suffix "Upgrade your secrets" = `git pull` the tool clone (binary ≥ 0.6.0.0) and/or `secrets migrate` the store. A read-only teammate only needs the tool `git pull`. +And you'll be told when you're behind: if a store was last written by a newer `secrets` than the one you're running, any command prints a one-line nudge to stderr (non-fatal) — and `secrets which` shows the store's `written-by:` version. Stores written by older builds (no version stamp) stay silent. + ### Automatic project detection When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by: diff --git a/VERSION b/VERSION index 7b86566..67085cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0.0 +0.7.1.0 diff --git a/docs/superpowers/plans/2026-06-08-egb-713-version-skew-nudge.md b/docs/superpowers/plans/2026-06-08-egb-713-version-skew-nudge.md new file mode 100644 index 0000000..61d1ce5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-egb-713-version-skew-nudge.md @@ -0,0 +1,300 @@ +# EGB-713: Version-skew nudge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Warn (non-fatally) when the active store was last written by a newer `secrets` version than the running client, so a behind user is told to update — the loud counterpart to EGB-712's quiet forcing function. + +**Architecture:** Stamp the store with the highest writer `VERSION` seen (`$SECRETS_DIR/.secrets-writer-version`, committed, monotonic) on every store-committing write. On any store command, compare that stamp to the client's own `VERSION` (read from `$SCRIPT_DIR/VERSION`); if the stamp is newer, print a one-time stderr nudge. Legacy stores with no stamp are silent. + +**Tech Stack:** bash 3.2 (`secrets`); bats. Spec/idea: ticket **EGB-713**. + +## Background (verified against `main`, post-EGB-712) +- `SCRIPT_DIR` already defined at `secrets:21` (`$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)`). The repo-root `VERSION` file lives next to the script. +- Store-committing write sites (each does `git -C "$SECRETS_DIR" add -A`): `commit_and_push_secrets()` `secrets:1265` (push), rekey `secrets:1822`, migrate copy-forward `secrets:2215`, finalize no-v1 `secrets:2305`, finalize drop `secrets:2340`. Plus `cmd_init` (born store) and `cmd_rm`. +- `ensure_store_protections()` (`secrets:1144`) is shared with pull (read) — do NOT stamp there. +- `check_initialized()` (`secrets:54`) early-returns when `$SECRETS_DIR/.git` exists and is called by push/pull/list/rm/rekey/verify/migrate — the natural warning hook. +- `cmd_which()` (`secrets:1945`) prints `format: v$(_store_format)` — add the writer-version line here. +- `.gitignore` only ignores `key.txt`, so `.secrets-writer-version` commits normally. +- VERSION currently `0.7.0.0` → bump to `0.7.1.0`. +- Baseline: `bats test/` = 246 passing. New tests land in a new file `test/version.bats`. + +--- + +## Task 1: Version helpers + comparator (TDD) + +**Files:** `secrets`, `test/version.bats` (new). + +- [ ] Step 1: Create `test/version.bats` testing the comparator via a tiny harness that sources the script's functions is awkward (the script runs main). Instead test through observable behavior in later tasks; for the comparator, add a hidden debug subcommand is overkill. Use this approach: test `_version_gt` indirectly by exporting it is not possible. So test the comparator by adding the helpers and a **`secrets __vercmp `** internal is overkill too. Decision: test the comparator's *effect* in Task 3 (warning) and Task 2 (stamp monotonicity), which exercise it end-to-end. For Task 1, write the helpers and verify with a one-off `bash -c` sourcing guard. + +Add to `test/version.bats`: +```bash +load test_helper + +# Exercises the comparator through a bash subshell that defines the same logic +# the script uses, guarding the numeric (not lexical) ordering contract. +@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically" { + run bash -c ' + _version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done; return 1 + } + _version_gt 0.10.0.0 0.7.0.0 && echo "10gt7" + _version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10" + _version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt" + ' + [ "$status" -eq 0 ] + [[ "$output" == *"10gt7"* ]] || false + [[ "$output" == *"7not_gt_10"* ]] || false + [[ "$output" == *"equal_not_gt"* ]] || false +} +``` + +- [ ] Step 2: Run `bats test/version.bats` → PASS (pins the contract the script must match). + +- [ ] Step 3: Add the helpers to `secrets` (near `_store_format`, after `SCRIPT_DIR`/version constants — place after the `MANIFEST_VERSION=2` area or near `_store_format`): +```bash +# The running client's own version, read from the VERSION file shipped beside +# the script. Empty/"0.0.0.0" if absent (e.g. an odd install) — treated as +# "unknown/oldest" so a missing VERSION never triggers a spurious nudge. +_client_version() { + local v="" + [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]') + printf '%s' "${v:-0.0.0.0}" +} + +# Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2. +# Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0. +_version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done + return 1 +} + +WRITER_VERSION_FILE_NAME=".secrets-writer-version" +# Highest client version recorded as having written to the store (empty if the +# store predates this feature — "minus the initial builds", silent by design). +_store_writer_version() { + local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + [ -f "$f" ] && head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]' +} +``` + +- [ ] Step 4: `bash -n secrets` parses; `bats test/` still 246 + 1 (the comparator test) = 247. +- [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: version helpers + numeric comparator (EGB-713)"` + +--- + +## Task 2: Stamp the writer-version on write (TDD) + +**Files:** `secrets`, `test/version.bats`. + +- [ ] Step 1: Add tests: +```bash +@test "push stamps the store writer-version with the client version" { + init_with_remote + create_project_dir wvstamp + "$SECRETS_BIN" push wvstamp >/dev/null 2>&1 + [ -f "$SECRETS_DIR/.secrets-writer-version" ] + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "$(cat "$(dirname "$SECRETS_BIN")/VERSION")" ] +} + +@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" { + init_with_remote + create_project_dir wvmono + printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version" + "$SECRETS_BIN" push wvmono >/dev/null 2>&1 + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "9.9.9.9" ] # not lowered to the client's version +} + +@test "writer-version stamp is committed, not gitignored" { + init_with_remote + create_project_dir wvcommit + "$SECRETS_BIN" push wvcommit >/dev/null 2>&1 + run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version" + [ "$status" -eq 0 ] +} +``` + +- [ ] Step 2: Run `bats test/version.bats -f "stamp"` → FAIL (no stamping yet). + +- [ ] Step 3: Add the stamp helper (after `_store_writer_version`): +```bash +# Raise the store's recorded writer-version to the client's version (monotonic; +# never lowers it). Called right before each store-committing `git add -A` so +# the stamp rides the same commit. Read paths (pull) never call this. +_stamp_writer_version() { + local cur cli + cur=$(_store_writer_version) + cli=$(_client_version) + if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then + printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + fi +} +``` + +- [ ] Step 4: Call `_stamp_writer_version` immediately before each store-committing `git -C "$SECRETS_DIR" add -A`: + - `secrets:1265` (in `commit_and_push_secrets`, before `git add -A`) + - `secrets:1822` (rekey) + - `secrets:2215` (migrate copy-forward) + - `secrets:2305` (finalize, no-v1 path) + - `secrets:2340` (finalize, drop path) + Also in `cmd_init`, after the store repo is created and before its first commit (so a born store records its version), and in `cmd_rm` before its commit. + Each insertion is the single line ` _stamp_writer_version` at the matching indentation directly above the `git ... add -A` (or before the `git ... commit` where there's no add -A, e.g. rm/init — there, stamp then ensure it's staged via the existing add/commit). + +- [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 250 (247 + 3). +- [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: stamp store writer-version on write, monotonic (EGB-713)"` + +--- + +## Task 3: Skew warning on command (TDD) + +**Files:** `secrets`, `test/version.bats`. + +- [ ] Step 1: Add tests: +```bash +@test "a store written by a newer version warns on a command (non-fatal)" { + init_with_remote + create_project_dir skewwarn + "$SECRETS_BIN" push skewwarn >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] # non-fatal + [[ "$output" == *"newer"* || "$output" == *"update"* ]] || false +} + +@test "a store at the same/older version is silent" { + init_with_remote + create_project_dir noskew + "$SECRETS_BIN" push noskew >/dev/null 2>&1 # stamp == client version + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"update your secrets"* ]] || false +} + +@test "a store with no writer-version marker is silent (legacy store)" { + init_with_remote + create_project_dir legacynostamp + "$SECRETS_BIN" push legacynostamp >/dev/null 2>&1 + rm -f "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"update your secrets"* ]] || false +} +``` + +- [ ] Step 2: Run `bats test/version.bats -f "skew\|silent\|legacy"` → the "newer" test FAILS (no warning yet). + +- [ ] Step 3: Add the skew check (after `_stamp_writer_version`): +```bash +# Warn ONCE per invocation if the store was last written by a newer client than +# us. Non-fatal (read/write paths keep their exit codes). Silent when the store +# carries no writer-version (legacy) or is same/older than us. +_VERSION_SKEW_WARNED=0 +_check_store_version_skew() { + [ "$_VERSION_SKEW_WARNED" = 1 ] && return 0 + local sv cv + sv=$(_store_writer_version) + [ -n "$sv" ] || return 0 + cv=$(_client_version) + if _version_gt "$sv" "$cv"; then + _VERSION_SKEW_WARNED=1 + echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2 + echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2 + fi + return 0 +} +``` + +- [ ] Step 4: Hook it into `check_initialized` — change `secrets:55-57`: +```bash + if [ -d "$SECRETS_DIR/.git" ]; then + return + fi +``` +to: +```bash + if [ -d "$SECRETS_DIR/.git" ]; then + _check_store_version_skew + return + fi +``` + +- [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 253. +- [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: warn on store version skew (once per invocation, EGB-713)"` + +--- + +## Task 4: `secrets which` surfaces the writer-version (TDD) + +**Files:** `secrets`, `test/version.bats`. + +- [ ] Step 1: Add test: +```bash +@test "which prints the store writer-version and a behind note" { + init_with_remote + create_project_dir whichwv + "$SECRETS_BIN" push whichwv >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"written-by: v99.0.0.0"* ]] || false + [[ "$output" == *"behind"* || "$output" == *"update"* ]] || false +} +``` + +- [ ] Step 2: Run → FAIL (which doesn't print written-by). + +- [ ] Step 3: In `cmd_which`, after the `echo "format: v$(_store_format)"` line (`secrets:1951`), add: +```bash + local _wv; _wv=$(_store_writer_version) + if [ -n "$_wv" ]; then + local _cv; _cv=$(_client_version) + if _version_gt "$_wv" "$_cv"; then + echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)" + else + echo "written-by: v$_wv" + fi + fi +``` +Note: `cmd_which` calls `resolve_store` but may not call `check_initialized`, so this also avoids double-printing the skew NOTE; the `which` line is the dedicated surface. + +- [ ] Step 4: `bats test/version.bats` → pass. `bats test/` → 254. +- [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: secrets which shows store writer-version + behind note (EGB-713)"` + +--- + +## Task 5: Docs + version bump + +**Files:** `secrets` (cmd_help unchanged unless adding a note), `CLAUDE.md`, `README.md`, `VERSION`, `CHANGELOG.md`. + +- [ ] Step 1: `CLAUDE.md` — add to the "Store format" bullet a sentence on the writer-version: a committed `.secrets-writer-version` records the highest client `VERSION` that has written (monotonic, stamped on store-committing writes); commands warn once (stderr, non-fatal) when the store's stamp exceeds the running client, and `secrets which` shows `written-by: vN`. Legacy stores (no marker) are silent. (EGB-713.) +- [ ] Step 2: `README.md` — under the upgrading section, note that an out-of-date `secrets` prints a one-line "update" nudge when it touches a store newer than itself. +- [ ] Step 3: `VERSION` → `0.7.1.0`. +- [ ] Step 4: `CHANGELOG.md` — new `## [0.7.1.0] - 2026-06-08` with an Added entry for the version-skew nudge + `secrets which` writer-version line. +- [ ] Step 5: `bats test/` → all green (254). `./secrets which` against a scratch store renders (covered by tests). +- [ ] Step 6: Commit: `git add secrets CLAUDE.md README.md VERSION CHANGELOG.md && git commit -m "docs: version-skew nudge + writer-version; bump 0.7.1.0 (EGB-713)"` + +--- + +## Self-review vs ticket AC +- "Newer stamp → nudge; same/older → silent" → Task 3. +- "No marker → silent (legacy)" → Task 3 + `_store_writer_version` empty. +- "Monotonic, committed" → Task 2. +- "Numeric comparator (0.7.0.0 < 0.10.0.0)" → Task 1. +- "Non-fatal, never changes read/pull exit codes" → Task 3 (`_check_store_version_skew` always `return 0`). +- "`which` surfaces it" → Task 4. "Warn once per invocation" → `_VERSION_SKEW_WARNED` guard. +- bash 3.2: `read -a`, `local -a`, `10#`, parameter strips — all 3.2-safe. diff --git a/secrets b/secrets index 52b6129..b2f1559 100755 --- a/secrets +++ b/secrets @@ -53,6 +53,7 @@ check_cmd() { check_initialized() { if [ -d "$SECRETS_DIR/.git" ]; then + _check_store_version_skew return fi if [ "$STORE_SOURCE" != "default" ]; then @@ -539,6 +540,72 @@ _store_format() { echo 1 } +# ─── Client/store version skew (EGB-713) ────────────────────────────── +# +# The running client's own version, read from the VERSION file shipped beside +# the script. Empty/"0.0.0.0" if absent — treated as "unknown/oldest" so a +# missing VERSION never triggers a spurious nudge. +_client_version() { + local v="" + [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]') + printf '%s' "${v:-0.0.0.0}" +} + +# Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2. +# Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0. +_version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done + return 1 +} + +WRITER_VERSION_FILE_NAME=".secrets-writer-version" +# Highest client version recorded as having written to the store (empty if the +# store predates this feature — "minus the initial builds", silent by design). +_store_writer_version() { + local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + if [ -f "$f" ]; then + head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]' + fi + return 0 +} + +# Raise the store's recorded writer-version to the client's version (monotonic; +# never lowers it). Called right before each store-committing `git add -A` so +# the stamp rides the same commit. Read paths (pull) never call this. +_stamp_writer_version() { + local cur cli + cur=$(_store_writer_version) + cli=$(_client_version) + if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then + printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + fi +} + +# Warn ONCE per invocation if the store was last written by a newer client than +# us. Non-fatal (read/write paths keep their exit codes). Silent when the store +# carries no writer-version (legacy) or is same/older than us. +_VERSION_SKEW_WARNED=0 +_check_store_version_skew() { + [ "$_VERSION_SKEW_WARNED" = 1 ] && return 0 + local sv cv + sv=$(_store_writer_version) + [ -n "$sv" ] || return 0 + cv=$(_client_version) + if _version_gt "$sv" "$cv"; then + _VERSION_SKEW_WARNED=1 + echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2 + echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2 + fi + return 0 +} + # Resolve the on-disk path of an external blob for READING. Tries the v2 suffix # (.properties.age) first, then falls back to the v1 (.gradle-properties.age) for # `properties` externals, so an upgraded client finds the blob whichever format @@ -1262,6 +1329,7 @@ commit_and_push_secrets() { # store missing the key.txt line would stage and push the private key. ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then info "No changes to push (secrets unchanged)" @@ -1819,6 +1887,7 @@ cmd_rekey() { # Commit and push (heal .gitignore first so add -A can't stage key.txt) ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then @@ -1949,6 +2018,15 @@ cmd_which() { # EGB-700 (folded into EGB-703): surface the store format so users can tell # v1 from v2 during the migration window. v1 = legacy store, no format marker. echo "format: v$(_store_format)" + local _wv; _wv=$(_store_writer_version) + if [ -n "$_wv" ]; then + local _cv; _cv=$(_client_version) + if _version_gt "$_wv" "$_cv"; then + echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)" + else + echo "written-by: v$_wv" + fi + fi # v2 manifest (.secrets.json): validate and summarize. Validation here # is deliberately fatal (symlink / malformed / unsupported version) so @@ -2212,6 +2290,7 @@ _migrate_project() { return 0 fi ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true # Push the twins so a --finalize on another machine sees them (finalize @@ -2302,6 +2381,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets # No v1 blobs at all — just stamp the marker (dotenv/file-only store). printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true @@ -2337,6 +2417,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age') ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true diff --git a/test/version.bats b/test/version.bats new file mode 100644 index 0000000..fa12630 --- /dev/null +++ b/test/version.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# EGB-713 version-skew nudge: writer-version stamp, numeric comparator, skew +# warning, `which` surface. bash 3.2: every standalone [[ ]] ends with || false. + +load test_helper + +VERSION_FILE() { echo "$(cd "$(dirname "$SECRETS_BIN")" && pwd)/VERSION"; } + +# ─── comparator contract ────────────────────────────────────────────── + +@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically (not lexically)" { + run bash -c ' + _version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done; return 1 + } + _version_gt 0.10.0.0 0.7.0.0 && echo "10gt7" + _version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10" + _version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt" + _version_gt 1.0.0.0 0.9.9.9 && echo "major_wins" + ' + [ "$status" -eq 0 ] + [[ "$output" == *"10gt7"* ]] || false + [[ "$output" == *"7not_gt_10"* ]] || false + [[ "$output" == *"equal_not_gt"* ]] || false + [[ "$output" == *"major_wins"* ]] || false +} + +# ─── stamp on write ─────────────────────────────────────────────────── + +@test "push stamps the store writer-version with the client version" { + init_with_remote + create_project_dir wvstamp + "$SECRETS_BIN" push wvstamp >/dev/null 2>&1 + [ -f "$SECRETS_DIR/.secrets-writer-version" ] + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "$(cat "$(VERSION_FILE)")" ] +} + +@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" { + init_with_remote + create_project_dir wvmono + printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version" + "$SECRETS_BIN" push wvmono >/dev/null 2>&1 + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "9.9.9.9" ] +} + +@test "writer-version stamp is committed, not gitignored" { + init_with_remote + create_project_dir wvcommit + "$SECRETS_BIN" push wvcommit >/dev/null 2>&1 + run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version" + [ "$status" -eq 0 ] +} + +# ─── skew warning on command ────────────────────────────────────────── + +@test "a store written by a newer version warns on a command (non-fatal)" { + init_with_remote + create_project_dir skewwarn + "$SECRETS_BIN" push skewwarn >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"last written by secrets v99.0.0.0"* ]] || false + [[ "$output" == *"Update your secrets tool"* ]] || false +} + +@test "a store at the same/older version is silent" { + init_with_remote + create_project_dir noskew + "$SECRETS_BIN" push noskew >/dev/null 2>&1 + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"Update your secrets tool"* ]] || false +} + +@test "a store with no writer-version marker is silent (legacy store)" { + init_with_remote + create_project_dir legacynostamp + "$SECRETS_BIN" push legacynostamp >/dev/null 2>&1 + rm -f "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"Update your secrets tool"* ]] || false +} + +# ─── which surface ──────────────────────────────────────────────────── + +@test "which prints the store writer-version and a behind note" { + init_with_remote + create_project_dir whichwv + "$SECRETS_BIN" push whichwv >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"written-by: v99.0.0.0"* ]] || false + [[ "$output" == *"behind"* ]] || false +}