From e1af344cb898e424502d017c49cdad72f3f75f28 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Jul 2026 22:32:40 -0500 Subject: [PATCH 01/16] docs: add guide for keeping derived private repositories current Covers schema_version vs template version, versioned template releases, explicit migration tooling, validation pinned to immutable releases, Dependabot update PRs for SHA-pinned actions, the absence of fork ancestry in GitHub template-created repositories, optional ADOPTERS.md or registration issue forms, and no mandatory telemetry. Part of RandomDevelopment/ci-fleet#24. --- README.md | 1 + docs/UPDATING.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 docs/UPDATING.md diff --git a/README.md b/README.md index 5476d06..2df86ad 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Adding workers reduces wall-clock time only while independent shards remain queu | `examples/multi-host/fleet.json` | Fictional two-project, two-location controller topology | | `SECURITY.md` | Secret handling and vulnerability reporting | | `AGENTS.md` | Non-negotiable rules for humans and coding agents | +| `docs/UPDATING.md` | Keeping a derived private repository current: schema vs template versions, releases, migrations, Dependabot | ## Public and private boundary diff --git a/docs/UPDATING.md b/docs/UPDATING.md new file mode 100644 index 0000000..a77867c --- /dev/null +++ b/docs/UPDATING.md @@ -0,0 +1,81 @@ +# Keeping a private configuration repository up to date + +A private configuration repository is created from this public template +and then lives its own life. This guide explains how it stays current. + +## Two different version numbers + +- **`schema_version`** (inside `fleet.json`) is the version of the + *data contract* — the fields the engine and validator understand. It + changes rarely and only through an explicit migration (see below). +- **Template version** is the version of the *starting content* — the + scripts, validators, examples, and documentation you copied from this + template. It is tracked by the template's tagged releases, not by + anything inside `fleet.json`. + +Bumping one never bumps the other. A new template release can ship +validator fixes with an unchanged `schema_version`; a schema migration +can happen without any other template change. + +## Template releases are versioned + +This template publishes tagged releases. Record the release you started +from in your private repository (for example in its README), and review +release notes when updating. Treat template files as vendored code: +update them deliberately, not casually. + +## GitHub template repositories have no fork ancestry + +A repository created with GitHub's "Use this template" button is **not** +a fork: it has no git ancestry link to the template, so +`git pull upstream` does not work and GitHub will never offer sync PRs. +Updating is an explicit operation: + +1. Add the template as a remote and fetch a tag: + + ```bash + git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git + git fetch template --tags + ``` + +2. Cherry-pick or merge the tagged release you want, resolving conflicts + against your local `fleet.json` (which is yours and must never be + overwritten by template examples). +3. Run `./scripts/validate.sh --strict` before committing. + +## Validation is pinned to immutable releases + +Controller `engine_ref` values and any reusable workflow references must +be full reviewed commit SHAs, not moving tags or branches. When you +update the pinned engine, resolve the exact merge commit on the engine's +default branch, review it, and pin that 40-hex SHA. The strict validator +runs against the pinned contract, so validation results are reproducible. + +## Dependabot update PRs + +Keep a `.github/dependabot.yml` in the private repository covering +GitHub Actions. When your workflows pin actions or reusable workflows to +commit SHAs, Dependabot still opens update PRs for them (it understands +SHA-pinned actions with version comments). Review each PR like any +engine update: confirm the new SHA is a reviewed upstream release, then +let the strict validator and CI run before merge. + +## Schema migrations are explicit tooling + +When the engine introduces a new `schema_version`, the migration is a +reviewed, mechanical transformation — not a hand edit: + +1. Read the migration notes for the new schema version. +2. Run the migration tooling shipped with that engine/template release + against a branch of the private repository. +3. Run `./scripts/validate.sh --strict` and review the diff. +4. Merge only when every controller in the fleet runs an engine that + understands the new schema. + +## Optional adopter registration, never telemetry + +This project collects **no telemetry** and there is no phone-home of any +kind. If the community wants visibility into who operates a fleet, it is +strictly optional and opt-in: an `ADOPTERS.md` pull request or a +registration issue form on the public engine repository. Never a +requirement, never automatic. From b1f1dce8005ae7a79e8e84a7fcfc5f99f9d80097 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Jul 2026 22:53:05 -0500 Subject: [PATCH 02/16] docs: address Codex review on updating guide - unrelated-history merge flag or old..new tag range; no lone-tag cherry-pick - clarify vendored validator does not resolve the engine pin; update vendored schema/validator with the matching release Signed-off-by: Hermes Agent --- docs/UPDATING.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index a77867c..6747f0c 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -31,16 +31,32 @@ a fork: it has no git ancestry link to the template, so `git pull upstream` does not work and GitHub will never offer sync PRs. Updating is an explicit operation: -1. Add the template as a remote and fetch a tag: +1. Add the template as a remote and fetch its tags: ```bash git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git git fetch template --tags ``` -2. Cherry-pick or merge the tagged release you want, resolving conflicts - against your local `fleet.json` (which is yours and must never be - overwritten by template examples). +2. Bring in the changes between your recorded release and the target + release. The two repositories have unrelated roots, so either merge + the target tag with the explicit unrelated-history flag (first time + only): + + ```bash + git merge --allow-unrelated-histories template/ + ``` + + or apply the reviewed range as a patch series: + + ```bash + git format-patch --stdout .. -- . ':!fleet.json' | git am -3 + ``` + + Resolve conflicts against your local `fleet.json` (which is yours and + must never be overwritten by template examples). Do not cherry-pick a + lone tag — that applies only its target commit and silently omits + intermediate changes. 3. Run `./scripts/validate.sh --strict` before committing. ## Validation is pinned to immutable releases @@ -48,8 +64,15 @@ Updating is an explicit operation: Controller `engine_ref` values and any reusable workflow references must be full reviewed commit SHAs, not moving tags or branches. When you update the pinned engine, resolve the exact merge commit on the engine's -default branch, review it, and pin that 40-hex SHA. The strict validator -runs against the pinned contract, so validation results are reproducible. +default branch, review it, and pin that 40-hex SHA. + +One limitation to understand: `./scripts/validate.sh --strict` runs the +validator **vendored in your repository**, so it verifies that +`engine_ref` is a well-formed 40-hex SHA but does not fetch that commit +or check it against the engine's actual contract. When you adopt a new +engine release, update the vendored schema/validator from the matching +template release in the same change (per the update procedure above) so +validation actually exercises the pinned contract. ## Dependabot update PRs From 2f7d76f13fcb8fca9e84d5acc2464618204887f1 Mon Sep 17 00:00:00 2001 From: Nickfosts Hermes Date: Sun, 26 Jul 2026 23:07:48 -0500 Subject: [PATCH 03/16] docs: use fetched template tag refs --- docs/UPDATING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 6747f0c..210029d 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -44,13 +44,13 @@ Updating is an explicit operation: only): ```bash - git merge --allow-unrelated-histories template/ + git merge --allow-unrelated-histories refs/tags/ ``` or apply the reviewed range as a patch series: ```bash - git format-patch --stdout .. -- . ':!fleet.json' | git am -3 + git format-patch --stdout refs/tags/..refs/tags/ -- . ':!fleet.json' | git am -3 ``` Resolve conflicts against your local `fleet.json` (which is yours and From b1f0bc0bb6df965050f3ce089283db9942febcbc Mon Sep 17 00:00:00 2001 From: Nickfosts Hermes Date: Sun, 26 Jul 2026 23:18:08 -0500 Subject: [PATCH 04/16] docs: make template updates collision-safe --- docs/UPDATING.md | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 210029d..cdf80f9 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -31,33 +31,38 @@ a fork: it has no git ancestry link to the template, so `git pull upstream` does not work and GitHub will never offer sync PRs. Updating is an explicit operation: -1. Add the template as a remote and fetch its tags: +1. Add the template as a remote and fetch its tags into a private + namespace that cannot clobber adopter-owned tags: ```bash git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git - git fetch template --tags + git fetch template '+refs/tags/*:refs/tags/template/*' ``` -2. Bring in the changes between your recorded release and the target - release. The two repositories have unrelated roots, so either merge - the target tag with the explicit unrelated-history flag (first time - only): +2. Merge the target template release without committing. The explicit + unrelated-history flag is required on the first update and harmless + after the first merge establishes common ancestry: ```bash - git merge --allow-unrelated-histories refs/tags/ + git merge --no-ff --no-commit --allow-unrelated-histories \ + refs/tags/template/ ``` - or apply the reviewed range as a patch series: + If Git reports conflicts, continue with the remaining steps while the + merge is in progress. First restore the adopter-owned configuration, + then resolve every other conflict and verify that `fleet.json` has no + staged change: ```bash - git format-patch --stdout refs/tags/..refs/tags/ -- . ':!fleet.json' | git am -3 + git restore --source=HEAD --staged --worktree -- fleet.json + git diff --cached --exit-code -- fleet.json + git status --short ``` - Resolve conflicts against your local `fleet.json` (which is yours and - must never be overwritten by template examples). Do not cherry-pick a - lone tag — that applies only its target commit and silently omits - intermediate changes. -3. Run `./scripts/validate.sh --strict` before committing. + Review the complete staged template update, then commit the merge. Do + not cherry-pick or format-patch a tag range: either can omit + intermediate or merge-result changes. +3. Run `./scripts/validate.sh --strict` before committing the merge. ## Validation is pinned to immutable releases @@ -92,8 +97,9 @@ reviewed, mechanical transformation — not a hand edit: 2. Run the migration tooling shipped with that engine/template release against a branch of the private repository. 3. Run `./scripts/validate.sh --strict` and review the diff. -4. Merge only when every controller in the fleet runs an engine that - understands the new schema. +4. Merge only when every still-deployed `active` or `drained` controller + runs an engine that understands the new schema. Retained `disabled` + declarations have no running host and do not gate the migration. ## Optional adopter registration, never telemetry From 832b531573d5cf2846cdab5f9e42e0214bb47f1e Mon Sep 17 00:00:00 2001 From: Nickfosts Hermes Date: Mon, 27 Jul 2026 00:02:28 -0500 Subject: [PATCH 05/16] docs: make template updates fail closed --- docs/UPDATING.md | 59 +++++++++++++++++++++++++++++------------- scripts/test_policy.py | 18 +++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index cdf80f9..5c0f6e5 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -31,38 +31,58 @@ a fork: it has no git ancestry link to the template, so `git pull upstream` does not work and GitHub will never offer sync PRs. Updating is an explicit operation: -1. Add the template as a remote and fetch its tags into a private - namespace that cannot clobber adopter-owned tags: +1. Add the template as a remote and fetch its tags into that remote's + tracking namespace. `--no-tags` prevents Git from also creating + adopter-visible tags, and the non-forced refspec fails closed if an + upstream tag is ever rewritten: ```bash git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git - git fetch template '+refs/tags/*:refs/tags/template/*' + git fetch --no-tags template 'refs/tags/*:refs/remotes/template/tags/*' ``` -2. Merge the target template release without committing. The explicit - unrelated-history flag is required on the first update and harmless - after the first merge establishes common ancestry: +2. Record the adopter commit, then merge the target template release + without committing. The explicit unrelated-history flag is required + on the first update and harmless after the first merge establishes + common ancestry: ```bash + ADOPTER_HEAD="$(git rev-parse HEAD)" git merge --no-ff --no-commit --allow-unrelated-histories \ - refs/tags/template/ + refs/remotes/template/tags/ ``` - If Git reports conflicts, continue with the remaining steps while the - merge is in progress. First restore the adopter-owned configuration, - then resolve every other conflict and verify that `fleet.json` has no - staged change: + If Git reports conflicts, leave the merge in progress and continue. + Whether or not it conflicted, restore the adopter-owned configuration + from the recorded pre-merge commit, then resolve and stage every other + conflict: + + ```bash + git restore --source="$ADOPTER_HEAD" --staged --worktree -- fleet.json + git status --short + ``` + + If the release keeps the same `schema_version`, prove `fleet.json` + still has no staged change: ```bash - git restore --source=HEAD --staged --worktree -- fleet.json git diff --cached --exit-code -- fleet.json + ``` + + If the release changes `schema_version`, run the target release's + migration tooling now, while this template merge is still pending, + then stage and review the mechanical `fleet.json` migration. +3. Validate and review the complete staged result before committing: + + ```bash + ./scripts/validate.sh --strict + git diff --cached git status --short + git commit ``` - Review the complete staged template update, then commit the merge. Do - not cherry-pick or format-patch a tag range: either can omit + Do not cherry-pick or format-patch a tag range: either can omit intermediate or merge-result changes. -3. Run `./scripts/validate.sh --strict` before committing the merge. ## Validation is pinned to immutable releases @@ -91,11 +111,14 @@ let the strict validator and CI run before merge. ## Schema migrations are explicit tooling When the engine introduces a new `schema_version`, the migration is a -reviewed, mechanical transformation — not a hand edit: +reviewed, mechanical transformation — not a hand edit. Apply it inside +the pending template merge above so the new schema, validator, migration, +and migrated private configuration are validated and committed together: 1. Read the migration notes for the new schema version. -2. Run the migration tooling shipped with that engine/template release - against a branch of the private repository. +2. Start the template merge, restore the adopter's pre-merge + `fleet.json`, and run the migration tooling shipped with that target + engine/template release before committing the merge. 3. Run `./scripts/validate.sh --strict` and review the diff. 4. Merge only when every still-deployed `active` or `drained` controller runs an engine that understands the new schema. Retained `disabled` diff --git a/scripts/test_policy.py b/scripts/test_policy.py index fc6d038..afad535 100755 --- a/scripts/test_policy.py +++ b/scripts/test_policy.py @@ -410,6 +410,24 @@ def test_standard_task_plan_path_is_required(self) -> None: first_project(config)["ci_contract"]["task_plan"] = "ci/custom.json" self.assert_rejected(config, "standard task-plan path") + def test_updating_guide_preserves_adopter_state_before_commit(self) -> None: + guide = (ROOT / "docs" / "UPDATING.md").read_text(encoding="utf-8") + required_in_order = ( + "git fetch --no-tags template", + "refs/tags/*:refs/remotes/template/tags/*", + 'ADOPTER_HEAD="$(git rev-parse HEAD)"', + 'git restore --source="$ADOPTER_HEAD"', + "migration tooling now", + "./scripts/validate.sh --strict", + "git commit", + ) + positions = [] + for value in required_in_order: + self.assertIn(value, guide) + positions.append(guide.index(value)) + self.assertEqual(positions, sorted(positions)) + self.assertNotIn("git fetch template '+refs/tags/", guide) + if __name__ == "__main__": unittest.main() From 188f62d3641393a0344e6feb4df4821616299193 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:14:24 -0500 Subject: [PATCH 06/16] docs: address seventh Codex review on updating guide - require a clean tree before restoring fleet.json from ADOPTER_HEAD - verify previously fetched tag object IDs; refs/remotes updates do not fail closed on retargeted tags - review staged validator/migration changes before executing any tooling introduced by the merge Signed-off-by: Hermes Agent --- docs/UPDATING.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 5c0f6e5..d769fa2 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -31,14 +31,22 @@ a fork: it has no git ancestry link to the template, so `git pull upstream` does not work and GitHub will never offer sync PRs. Updating is an explicit operation: -1. Add the template as a remote and fetch its tags into that remote's - tracking namespace. `--no-tags` prevents Git from also creating - adopter-visible tags, and the non-forced refspec fails closed if an - upstream tag is ever rewritten: +1. Start from a clean tree — no uncommitted or unstaged changes, + especially to `fleet.json`; the procedure restores `fleet.json` from + the recorded pre-merge commit and would silently discard an + uncommitted edit. Then add the template as a remote and fetch its + tags into that remote's tracking namespace. `--no-tags` prevents Git + from also creating adopter-visible tags. A retargeted upstream tag + updates a `refs/remotes/*` ref silently, so verify the reviewed + object ID yourself before using any previously fetched tag ref: ```bash + git status --porcelain # must be empty git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git git fetch --no-tags template 'refs/tags/*:refs/remotes/template/tags/*' + # If you fetched this tag before, require the object to be unchanged: + # test "$(git rev-parse refs/remotes/template/tags/)" = \ + # "$(git ls-remote template refs/tags/ | awk '{print $1}')" ``` 2. Record the adopter commit, then merge the target template release @@ -69,15 +77,24 @@ Updating is an explicit operation: git diff --cached --exit-code -- fleet.json ``` - If the release changes `schema_version`, run the target release's - migration tooling now, while this template merge is still pending, - then stage and review the mechanical `fleet.json` migration. -3. Validate and review the complete staged result before committing: +3. Review the complete staged result — including any changes the merge + brings to `scripts/validate.sh`, the validator, or migration sources — + **before** executing anything the merge introduced. An erroneous or + compromised release must never run code in your environment + unreviewed: ```bash - ./scripts/validate.sh --strict git diff --cached git status --short + ``` + + If the release changes `schema_version`, run the now-reviewed target + release's migration tooling while this template merge is still + pending, then stage and review the mechanical `fleet.json` migration. +4. Validate and commit: + + ```bash + ./scripts/validate.sh --strict git commit ``` From 0cacdf69dc928ca6c60549f0be5397ad4ac50d30 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:21:40 -0500 Subject: [PATCH 07/16] docs+test: address eighth Codex review on updating guide - policy test assertion now matches the guide's actual migration wording - template remote creation documented as one-time setup Signed-off-by: Hermes Agent --- docs/UPDATING.md | 1 + scripts/test_policy.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index d769fa2..a6493b8 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -42,6 +42,7 @@ Updating is an explicit operation: ```bash git status --porcelain # must be empty + # one-time setup; skip if `git remote` already lists `template`: git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git git fetch --no-tags template 'refs/tags/*:refs/remotes/template/tags/*' # If you fetched this tag before, require the object to be unchanged: diff --git a/scripts/test_policy.py b/scripts/test_policy.py index afad535..79ec5ac 100755 --- a/scripts/test_policy.py +++ b/scripts/test_policy.py @@ -417,7 +417,7 @@ def test_updating_guide_preserves_adopter_state_before_commit(self) -> None: "refs/tags/*:refs/remotes/template/tags/*", 'ADOPTER_HEAD="$(git rev-parse HEAD)"', 'git restore --source="$ADOPTER_HEAD"', - "migration tooling now", + "run the now-reviewed target", "./scripts/validate.sh --strict", "git commit", ) From 2f5d4e137c2ac5696d6908f8c486ee070d3c3c74 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:40:10 -0500 Subject: [PATCH 08/16] docs: address ninth Codex review on updating guide - preserve and compare the prior tag object BEFORE fetching, since refs/remotes/* updates do not fail closed on retargeted tags - two-phase schema rollout: advance engine_ref under the old schema first, migrate configuration once all controllers run the new engine Signed-off-by: Hermes Agent --- docs/UPDATING.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index a6493b8..93e9fbd 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -44,10 +44,15 @@ Updating is an explicit operation: git status --porcelain # must be empty # one-time setup; skip if `git remote` already lists `template`: git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git + # Preserve the previously fetched object BEFORE fetching: a retargeted + # upstream tag silently updates refs/remotes/* refs, so comparing after + # the fetch can never detect a rewrite. + PRIOR_TAG_OID="$(git rev-parse --verify -q refs/remotes/template/tags/ || true)" git fetch --no-tags template 'refs/tags/*:refs/remotes/template/tags/*' - # If you fetched this tag before, require the object to be unchanged: - # test "$(git rev-parse refs/remotes/template/tags/)" = \ - # "$(git ls-remote template refs/tags/ | awk '{print $1}')" + # If the tag was fetched before, require it to be unchanged: + if [ -n "$PRIOR_TAG_OID" ]; then + test "$(git rev-parse refs/remotes/template/tags/)" = "$PRIOR_TAG_OID" + fi ``` 2. Record the adopter commit, then merge the target template release @@ -138,9 +143,20 @@ and migrated private configuration are validated and committed together: `fleet.json`, and run the migration tooling shipped with that target engine/template release before committing the merge. 3. Run `./scripts/validate.sh --strict` and review the diff. -4. Merge only when every still-deployed `active` or `drained` controller - runs an engine that understands the new schema. Retained `disabled` - declarations have no running host and do not gate the migration. +4. Roll out in two phases when controllers run the older engine. The + vendored validator understands one schema version, so importing the + new schema/validator and migrating `fleet.json` in the same change + would reject the configuration still needed by un-upgraded hosts: + - Phase 1 (old schema): merge a configuration commit that only + advances each controller's `engine_ref` to the new reviewed engine + commit, still expressed in the old `schema_version`, and let every + deployed controller upgrade. + - Phase 2 (new schema): once every still-deployed `active` or + `drained` controller runs the new engine, merge the template update + that imports the matching schema/validator and run the migration + above. + Retained `disabled` declarations have no running host and gate + neither phase. ## Optional adopter registration, never telemetry From 2bc20e9a29f077bacf523158ef81678e6962caf5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:47:07 -0500 Subject: [PATCH 09/16] docs: address tenth Codex review on updating guide - fetch tags into a temporary ref and promote only after comparison, so a failed rewrite check never leaves the rewritten object trusted - same-change validator update instruction now applies only to releases with an unchanged schema_version; schema upgrades happen in phase 2 Signed-off-by: Hermes Agent --- docs/UPDATING.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 93e9fbd..55bb6ba 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -44,15 +44,20 @@ Updating is an explicit operation: git status --porcelain # must be empty # one-time setup; skip if `git remote` already lists `template`: git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git - # Preserve the previously fetched object BEFORE fetching: a retargeted - # upstream tag silently updates refs/remotes/* refs, so comparing after - # the fetch can never detect a rewrite. + # Fetch into a temporary ref and promote it only after comparison: + # a retargeted upstream tag updates refs/remotes/* refs silently + # (non-fast-forward is allowed outside refs/tags and refs/heads), so + # comparing after a direct fetch can never detect a rewrite, and a + # failed check would otherwise leave the rewritten object trusted on + # the next run. PRIOR_TAG_OID="$(git rev-parse --verify -q refs/remotes/template/tags/ || true)" - git fetch --no-tags template 'refs/tags/*:refs/remotes/template/tags/*' - # If the tag was fetched before, require it to be unchanged: + git fetch --no-tags template 'refs/tags/:refs/tmp/template-tag-check' + NEW_TAG_OID="$(git rev-parse refs/tmp/template-tag-check)" if [ -n "$PRIOR_TAG_OID" ]; then - test "$(git rev-parse refs/remotes/template/tags/)" = "$PRIOR_TAG_OID" + test "$NEW_TAG_OID" = "$PRIOR_TAG_OID" # fails closed on rewritten tags fi + git update-ref refs/remotes/template/tags/ "$NEW_TAG_OID" + git update-ref -d refs/tmp/template-tag-check ``` 2. Record the adopter commit, then merge the target template release @@ -118,9 +123,12 @@ One limitation to understand: `./scripts/validate.sh --strict` runs the validator **vendored in your repository**, so it verifies that `engine_ref` is a well-formed 40-hex SHA but does not fetch that commit or check it against the engine's actual contract. When you adopt a new -engine release, update the vendored schema/validator from the matching -template release in the same change (per the update procedure above) so -validation actually exercises the pinned contract. +engine release whose `schema_version` is unchanged, update the vendored +schema/validator from the matching template release in the same change +(per the update procedure above) so validation actually exercises the +pinned contract. Releases that introduce a new `schema_version` are the +exception: import the new schema/validator in phase 2 of the two-phase +rollout below, after every deployed controller runs the new engine. ## Dependabot update PRs From 2a820f41ac6aa6901c848d5eba4547304f8e26c9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:48:00 -0500 Subject: [PATCH 10/16] test: match policy assertion to temporary-ref tag verification Signed-off-by: Hermes Agent --- scripts/test_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_policy.py b/scripts/test_policy.py index 79ec5ac..6eb46e6 100755 --- a/scripts/test_policy.py +++ b/scripts/test_policy.py @@ -414,7 +414,7 @@ def test_updating_guide_preserves_adopter_state_before_commit(self) -> None: guide = (ROOT / "docs" / "UPDATING.md").read_text(encoding="utf-8") required_in_order = ( "git fetch --no-tags template", - "refs/tags/*:refs/remotes/template/tags/*", + "refs/tags/:refs/tmp/template-tag-check", 'ADOPTER_HEAD="$(git rev-parse HEAD)"', 'git restore --source="$ADOPTER_HEAD"', "run the now-reviewed target", From d093b1b37049117ec9256a3c669eaaf1ac2dd71c Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 00:55:04 -0500 Subject: [PATCH 11/16] docs: abort before promoting a rewritten template tag The test alone returned nonzero but an interactive shell would continue to the update-ref; exit explicitly so the rewritten object is never promoted. Signed-off-by: Hermes Agent --- docs/UPDATING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 55bb6ba..65707cf 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -53,8 +53,11 @@ Updating is an explicit operation: PRIOR_TAG_OID="$(git rev-parse --verify -q refs/remotes/template/tags/ || true)" git fetch --no-tags template 'refs/tags/:refs/tmp/template-tag-check' NEW_TAG_OID="$(git rev-parse refs/tmp/template-tag-check)" - if [ -n "$PRIOR_TAG_OID" ]; then - test "$NEW_TAG_OID" = "$PRIOR_TAG_OID" # fails closed on rewritten tags + if [ -n "$PRIOR_TAG_OID" ] && [ "$NEW_TAG_OID" != "$PRIOR_TAG_OID" ]; then + # Fail closed: do not promote the rewritten tag; stop and review upstream. + git update-ref -d refs/tmp/template-tag-check + echo "template tag was rewritten upstream; refusing to use it" >&2 + exit 1 fi git update-ref refs/remotes/template/tags/ "$NEW_TAG_OID" git update-ref -d refs/tmp/template-tag-check From dceec93d15ff8675763acddff44675780b893df3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 01:08:50 -0500 Subject: [PATCH 12/16] docs+test: address twelfth Codex review on updating guide - capture NEW_TAG as data, validate format, quote expansions - trusted tag OIDs moved to refs/adopter/* (immune to fetch --prune) - delete stale temporary ref before fetch; promotion requires fetch success - verify the existing template remote's URL on subsequent updates Signed-off-by: Hermes Agent --- docs/UPDATING.md | 37 ++++++++++++++++++++++++------------- scripts/test_policy.py | 2 +- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 65707cf..3c5f7ea 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -42,24 +42,35 @@ Updating is an explicit operation: ```bash git status --porcelain # must be empty - # one-time setup; skip if `git remote` already lists `template`: - git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git - # Fetch into a temporary ref and promote it only after comparison: - # a retargeted upstream tag updates refs/remotes/* refs silently - # (non-fast-forward is allowed outside refs/tags and refs/heads), so - # comparing after a direct fetch can never detect a rewrite, and a - # failed check would otherwise leave the rewritten object trusted on - # the next run. - PRIOR_TAG_OID="$(git rev-parse --verify -q refs/remotes/template/tags/ || true)" - git fetch --no-tags template 'refs/tags/:refs/tmp/template-tag-check' + # one-time setup; on later runs verify the existing remote is really + # the template, not an unrelated remote that happens to share the name: + git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git 2>/dev/null || true + git remote get-url template # must equal the URL above + + # Capture the tag as data and constrain its format. Never interpolate + # an unvalidated tag into shell commands: Git permits metacharacters + # in tag names, which would execute before the release is reviewed. + NEW_TAG= + [[ "$NEW_TAG" =~ ^[0-9A-Za-z][0-9A-Za-z._/-]{0,127}$ ]] || exit 1 + + # Trusted tag OIDs live in refs/adopter/*, a namespace no remote + # refspec manages (a `git fetch --prune template` would delete refs + # under refs/remotes/template/* and silently reopen the rewrite + # window). Fetch into a temporary ref and promote only after + # comparison, so a retargeted upstream tag never becomes trusted. + PRIOR_TAG_OID="$(git rev-parse --verify -q "refs/adopter/template-tags/$NEW_TAG" || true)" + git update-ref -d refs/tmp/template-tag-check 2>/dev/null || true + if ! git fetch --no-tags template "refs/tags/$NEW_TAG:refs/tmp/template-tag-check"; then + echo "tag $NEW_TAG not found upstream" >&2; exit 1 + fi NEW_TAG_OID="$(git rev-parse refs/tmp/template-tag-check)" if [ -n "$PRIOR_TAG_OID" ] && [ "$NEW_TAG_OID" != "$PRIOR_TAG_OID" ]; then # Fail closed: do not promote the rewritten tag; stop and review upstream. git update-ref -d refs/tmp/template-tag-check - echo "template tag was rewritten upstream; refusing to use it" >&2 + echo "template tag $NEW_TAG was rewritten upstream; refusing to use it" >&2 exit 1 fi - git update-ref refs/remotes/template/tags/ "$NEW_TAG_OID" + git update-ref "refs/adopter/template-tags/$NEW_TAG" "$NEW_TAG_OID" git update-ref -d refs/tmp/template-tag-check ``` @@ -71,7 +82,7 @@ Updating is an explicit operation: ```bash ADOPTER_HEAD="$(git rev-parse HEAD)" git merge --no-ff --no-commit --allow-unrelated-histories \ - refs/remotes/template/tags/ + "refs/adopter/template-tags/$NEW_TAG" ``` If Git reports conflicts, leave the merge in progress and continue. diff --git a/scripts/test_policy.py b/scripts/test_policy.py index 6eb46e6..42493dc 100755 --- a/scripts/test_policy.py +++ b/scripts/test_policy.py @@ -414,7 +414,7 @@ def test_updating_guide_preserves_adopter_state_before_commit(self) -> None: guide = (ROOT / "docs" / "UPDATING.md").read_text(encoding="utf-8") required_in_order = ( "git fetch --no-tags template", - "refs/tags/:refs/tmp/template-tag-check", + 'refs/tags/$NEW_TAG:refs/tmp/template-tag-check', 'ADOPTER_HEAD="$(git rev-parse HEAD)"', 'git restore --source="$ADOPTER_HEAD"', "run the now-reviewed target", From 726bc0b4efc5530102be6f041b41268ed73b84d2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 01:16:18 -0500 Subject: [PATCH 13/16] docs: persist trusted tag OIDs in versioned TEMPLATE_RELEASE data Local refs are not shared by clones, so rewrite detection now uses the committed TEMPLATE_RELEASE file as its baseline; the verified tag/OID line is appended in the same commit that merges the release. Signed-off-by: Hermes Agent --- docs/UPDATING.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 3c5f7ea..e6d4fb9 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -20,9 +20,13 @@ can happen without any other template change. ## Template releases are versioned This template publishes tagged releases. Record the release you started -from in your private repository (for example in its README), and review -release notes when updating. Treat template files as vendored code: -update them deliberately, not casually. +from **in versioned repository data** — a `TEMPLATE_RELEASE` file at the +repository root containing the tag name and its reviewed 40-hex object +ID, one per line — and review release notes when updating. Keeping the +reviewed object ID in Git means every clone and every maintainer shares +the same rewrite-detection baseline; a local-only ref cannot do that. +Treat template files as vendored code: update them deliberately, not +casually. ## GitHub template repositories have no fork ancestry @@ -53,12 +57,11 @@ Updating is an explicit operation: NEW_TAG= [[ "$NEW_TAG" =~ ^[0-9A-Za-z][0-9A-Za-z._/-]{0,127}$ ]] || exit 1 - # Trusted tag OIDs live in refs/adopter/*, a namespace no remote - # refspec manages (a `git fetch --prune template` would delete refs - # under refs/remotes/template/* and silently reopen the rewrite - # window). Fetch into a temporary ref and promote only after - # comparison, so a retargeted upstream tag never becomes trusted. - PRIOR_TAG_OID="$(git rev-parse --verify -q "refs/adopter/template-tags/$NEW_TAG" || true)" + # The trusted baseline is the committed TEMPLATE_RELEASE file (shared + # by every clone), not a local ref. Fetch into a temporary ref and + # promote only after comparison, so a retargeted upstream tag never + # becomes trusted. + PRIOR_TAG_OID="$(awk -v t="$NEW_TAG" '$1 == t {print $2}' TEMPLATE_RELEASE 2>/dev/null || true)" git update-ref -d refs/tmp/template-tag-check 2>/dev/null || true if ! git fetch --no-tags template "refs/tags/$NEW_TAG:refs/tmp/template-tag-check"; then echo "tag $NEW_TAG not found upstream" >&2; exit 1 @@ -70,8 +73,8 @@ Updating is an explicit operation: echo "template tag $NEW_TAG was rewritten upstream; refusing to use it" >&2 exit 1 fi - git update-ref "refs/adopter/template-tags/$NEW_TAG" "$NEW_TAG_OID" git update-ref -d refs/tmp/template-tag-check + MERGE_SOURCE="$NEW_TAG_OID" ``` 2. Record the adopter commit, then merge the target template release @@ -82,7 +85,7 @@ Updating is an explicit operation: ```bash ADOPTER_HEAD="$(git rev-parse HEAD)" git merge --no-ff --no-commit --allow-unrelated-histories \ - "refs/adopter/template-tags/$NEW_TAG" + "$MERGE_SOURCE" ``` If Git reports conflicts, leave the merge in progress and continue. @@ -116,10 +119,13 @@ Updating is an explicit operation: If the release changes `schema_version`, run the now-reviewed target release's migration tooling while this template merge is still pending, then stage and review the mechanical `fleet.json` migration. -4. Validate and commit: +4. Validate, record the reviewed release in `TEMPLATE_RELEASE` (the + verified tag and object ID), and commit: ```bash ./scripts/validate.sh --strict + printf '%s %s\n' "$NEW_TAG" "$NEW_TAG_OID" >> TEMPLATE_RELEASE + git add TEMPLATE_RELEASE git commit ``` From 5653d9bca41e256560adf2ef53136d38df58c2d8 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 01:27:22 -0500 Subject: [PATCH 14/16] docs: address thirteenth Codex review on updating guide - clean-tree requirement now aborts instead of only printing status - existing template remote with a wrong URL fails closed - schema migrations always arrive with a template/engine release Signed-off-by: Hermes Agent --- docs/UPDATING.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index e6d4fb9..ba88cdf 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -14,8 +14,9 @@ and then lives its own life. This guide explains how it stays current. anything inside `fleet.json`. Bumping one never bumps the other. A new template release can ship -validator fixes with an unchanged `schema_version`; a schema migration -can happen without any other template change. +validator fixes with an unchanged `schema_version`, but a schema +migration always arrives as part of a template/engine release — never as +an adopter-local edit. ## Template releases are versioned @@ -45,11 +46,17 @@ Updating is an explicit operation: object ID yourself before using any previously fetched tag ref: ```bash - git status --porcelain # must be empty - # one-time setup; on later runs verify the existing remote is really - # the template, not an unrelated remote that happens to share the name: - git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git 2>/dev/null || true - git remote get-url template # must equal the URL above + # Hard stop on any uncommitted state; a dirty tree would be silently + # overwritten by the fleet.json restore below. + test -z "$(git status --porcelain)" || { echo "clean the tree first" >&2; exit 1; } + # one-time setup; on later runs require the existing remote to be the + # template, not an unrelated remote that happens to share the name: + if ! git remote get-url template 2>/dev/null; then + git remote add template https://github.com/RandomDevelopment/ci-fleet-config-template.git + fi + test "$(git remote get-url template)" = \ + "https://github.com/RandomDevelopment/ci-fleet-config-template.git" || \ + { echo "remote 'template' points elsewhere; refusing to continue" >&2; exit 1; } # Capture the tag as data and constrain its format. Never interpolate # an unvalidated tag into shell commands: Git permits metacharacters From c89bd7a1ddf49a099e3464c0708430783b4f8dfa Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 01:43:38 -0500 Subject: [PATCH 15/16] docs: peel annotated release tags to the tagged commit rev-parse on a tag ref returns the tag object OID for annotated tags; merge and TEMPLATE_RELEASE need the commit. Signed-off-by: Hermes Agent --- docs/UPDATING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index ba88cdf..c3edd0e 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -73,7 +73,7 @@ Updating is an explicit operation: if ! git fetch --no-tags template "refs/tags/$NEW_TAG:refs/tmp/template-tag-check"; then echo "tag $NEW_TAG not found upstream" >&2; exit 1 fi - NEW_TAG_OID="$(git rev-parse refs/tmp/template-tag-check)" + NEW_TAG_OID="$(git rev-parse 'refs/tmp/template-tag-check^{commit}')" # peel annotated tags to the tagged commit if [ -n "$PRIOR_TAG_OID" ] && [ "$NEW_TAG_OID" != "$PRIOR_TAG_OID" ]; then # Fail closed: do not promote the rewritten tag; stop and review upstream. git update-ref -d refs/tmp/template-tag-check From 5178748f971a0dbd62c389ad9ef862e48d10d68a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 27 Jul 2026 01:54:49 -0500 Subject: [PATCH 16/16] docs: compare raw tag-object OID, peel separately for the merge source An annotated tag re-created with a new signature or message but the same target commit would pass a peeled-only comparison; the recorded baseline is the tag object, and MERGE_SOURCE is the peeled commit. Signed-off-by: Hermes Agent --- docs/UPDATING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index c3edd0e..79556a4 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -73,7 +73,11 @@ Updating is an explicit operation: if ! git fetch --no-tags template "refs/tags/$NEW_TAG:refs/tmp/template-tag-check"; then echo "tag $NEW_TAG not found upstream" >&2; exit 1 fi - NEW_TAG_OID="$(git rev-parse 'refs/tmp/template-tag-check^{commit}')" # peel annotated tags to the tagged commit + # Compare the raw tag object (catches re-signing/message rewrites of + # annotated tags that still point at the same commit); peel separately + # for the merge source, which must be a commit. + NEW_TAG_OID="$(git rev-parse refs/tmp/template-tag-check)" + MERGE_SOURCE="$(git rev-parse 'refs/tmp/template-tag-check^{commit}')" if [ -n "$PRIOR_TAG_OID" ] && [ "$NEW_TAG_OID" != "$PRIOR_TAG_OID" ]; then # Fail closed: do not promote the rewritten tag; stop and review upstream. git update-ref -d refs/tmp/template-tag-check @@ -81,7 +85,6 @@ Updating is an explicit operation: exit 1 fi git update-ref -d refs/tmp/template-tag-check - MERGE_SOURCE="$NEW_TAG_OID" ``` 2. Record the adopter commit, then merge the target template release