docs: correct the version-bump gate and branch-refresh documentation
ADR-0022 and gates.md now state the merge-base baseline, the fail-closed cases, the PyYAML requirement and the multi-ref push gap (shared with check-release-needed); the new hook gets its own gates.md group. Both ADR additions follow each file's amendment format. README and AGENTS.md now say to discard a refreshed apm.lock.yaml on a feature branch, and that an .apm/ edit is live only once it is on the remote's main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,9 +115,8 @@ A test pins the reference.
|
||||
> emptied it. `tests/test-apm-current-hook.sh` still pins the literal string.
|
||||
|
||||
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated`
|
||||
check when everything is current, ~10.4 s when six packages are behind and the refresh runs
|
||||
(re-measured 2026-09-16: ~24–26 s for the same six-behind refresh, warm, on a LAN remote — still
|
||||
well inside the budget below). The hook declares `timeout: 380` to cover a cold multi-package fetch. That number is not free-standing:
|
||||
check when everything is current, ~10.4 s when six packages are behind and the refresh runs. The
|
||||
hook declares `timeout: 380` to cover a cold multi-package fetch. That number is not free-standing:
|
||||
the script imposes its own `timeout 60` on `apm outdated` and `timeout 300` on `apm update`, so the
|
||||
host-side timeout has to exceed their sum or the host kills the hook mid-update and leaves
|
||||
`.claude/skills/` half-deployed with no notice emitted. An earlier revision declared `320`, which
|
||||
@@ -125,6 +124,10 @@ was below the 360 s the script can legitimately take. A test asserts the invaria
|
||||
literal — it parses every `timeout N` out of the script, sums them, and requires the `hooks.json`
|
||||
value to be larger — so changing either side without the other fails the suite.
|
||||
|
||||
> **Amendment (2026-09-16) — the refresh is slower than first measured, still inside the budget.**
|
||||
> Re-measured: ~24–26 s for the same six-behind refresh, warm, on a LAN remote — well inside the
|
||||
> 380 s above.
|
||||
|
||||
**Reading a human-readable CLI for a control decision cost a silent failure, again.** `apm outdated`
|
||||
has no `--json` or other machine-readable flag (confirmed against 0.28.0), so the hook must match
|
||||
its prose. The first attempt matched `outdated dependencies found` — plural only. apm emits
|
||||
@@ -146,20 +149,21 @@ to plural-only fails it.
|
||||
deploy until this change is merged and `apm update` has run once against the new default branch.
|
||||
Until then the repo has the mechanism in source and not in effect.
|
||||
|
||||
**On a feature branch, the refresh installs `main`, not the branch.** Added 2026-09-16, after it
|
||||
happened. The dependencies resolve against the remote default branch, so a session opened on a
|
||||
branch that changes `plugins/` loads `main`'s content, refreshed or not — the branch's own edits
|
||||
are never live until merged. Two visible effects follow. Content the branch *removes* comes back in
|
||||
the deployed install: on `docs/simplification-audit` a refresh redeployed `main`'s `skill-audit`
|
||||
and `agent-audit` over the branch's merged `factory-audit`, and re-materialised `main`'s
|
||||
`plugins/bin/.mcp.json` into `apm_modules/`, so the gitignored root `.mcp.json` regained the
|
||||
`obsidian` server the branch deleted — invisible to `git status`. And the rewritten
|
||||
`apm.lock.yaml` records `main`'s commit, so on a branch it should be discarded
|
||||
(`git checkout -- apm.lock.yaml`, then `apm install` to bring the deployed tree back in line with
|
||||
the lock, or `apm pack --check-clean` refuses to run), not committed. Skipping the refresh off the
|
||||
default branch was considered and rejected: it would not make the branch live, only freeze the
|
||||
session on an older `main` — the silent staleness this ADR exists to prevent. Both effects end when
|
||||
the branch merges; apm removes a server that is no longer declared on its next update.
|
||||
> **Amendment (2026-09-16) — on a feature branch, the refresh installs `main`, not the branch.**
|
||||
> Recorded after it happened. The dependencies resolve against the remote default branch, so a
|
||||
> session opened on a branch that changes `plugins/` loads `main`'s content, refreshed or not — a
|
||||
> branch's own `.apm/` edits are live only once they are on the remote's `main`. Two visible
|
||||
> effects follow. Content the branch *removes* comes back in the deployed install: on
|
||||
> `docs/simplification-audit` a refresh redeployed `main`'s `skill-audit` and `agent-audit` over
|
||||
> the branch's merged `factory-audit`, and re-materialised `main`'s `plugins/bin/.mcp.json` into
|
||||
> `apm_modules/`, so the gitignored root `.mcp.json` regained the `obsidian` server the branch
|
||||
> deleted — invisible to `git status`. And the rewritten `apm.lock.yaml` records `main`'s commit,
|
||||
> so on a branch it should be discarded (`git checkout -- apm.lock.yaml`, then `apm install` to
|
||||
> bring the deployed tree back in line with the lock, or `apm pack --check-clean` refuses to run),
|
||||
> not committed. Skipping the refresh off the default branch was considered and rejected: it would
|
||||
> not make the branch live, only freeze the session on an older `main` — the silent staleness this
|
||||
> ADR exists to prevent. Both effects end when the branch merges; apm removes a server that is no
|
||||
> longer declared on its next update.
|
||||
|
||||
**`.claude/settings.json` stops being `{"hooks": {}}`.** apm merges the hook into it and tracks
|
||||
ownership in a `.claude/apm-hooks.json` sidecar, with the script copied to
|
||||
|
||||
@@ -77,38 +77,49 @@ presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatte
|
||||
to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule
|
||||
with nothing enforcing it drifts the same way an unstated one does.
|
||||
|
||||
## Amendment (2026-09-16) — the bump is enforced at push, not only required to exist
|
||||
## Amendment (2026-09-16): the bump is enforced at push, not only required to exist
|
||||
|
||||
**Context.** Making the field mandatory did not make it move. The only thing that bumped it was
|
||||
`skill-author` Step 4, so every hand edit and every trim pass skipped the bump: on
|
||||
`docs/simplification-audit`, 17 of the 40 skill directories that changed against `main` carried
|
||||
the same `metadata.version` as `main`, and `gitea` alone sat at six different values. Both
|
||||
validators checked presence and semver shape, never movement, so the field could not answer the
|
||||
question this ADR gives it — "did this change since I last read it". (Simplification audit
|
||||
finding 33.)
|
||||
Making the field mandatory did not make it move. The only thing that bumped it was `skill-author`
|
||||
Step 4, so every hand edit and every trim pass skipped the bump: on `docs/simplification-audit`, 17
|
||||
of the 40 skill directories that changed against `main` carried the same `metadata.version` as
|
||||
`main`, and `gitea` alone sat at six different values. Both validators checked presence and semver
|
||||
shape, never movement, so the field could not answer the question this ADR gives it — "did this
|
||||
change since I last read it". (Simplification audit finding 33.)
|
||||
|
||||
**Decision.** `scripts/check-skill-version-bump.sh` runs as a pre-push hook on every push. For each
|
||||
skill directory under `plugins/*/.apm/skills/` that differs between the pushed ref and its
|
||||
merge-base with `main`, ignoring `tests/`, the pushed `metadata.version` must be strictly greater
|
||||
than `main`'s.
|
||||
`scripts/check-skill-version-bump.sh` now runs as a pre-push hook on every push, whatever the
|
||||
target branch. It takes its baseline from the merge-base of the pushed commit with `origin/main`
|
||||
(local `main` if `origin/main` does not resolve). For each skill directory under
|
||||
`plugins/*/.apm/skills/` that differs between the pushed commit and that merge-base, ignoring
|
||||
`tests/`, the pushed `metadata.version` must be strictly greater than the version the skill had at
|
||||
the merge-base — not the version on `main`'s current tip.
|
||||
|
||||
- **Baseline is the merge-base with `main`, not the previous commit.** Readers only ever see
|
||||
`main` — installs resolve against the default branch (ADR-0018) — so one bump per branch is what
|
||||
the field owes them. A per-commit check would bump a skill once per commit and inflate the
|
||||
number past meaning.
|
||||
- **"Greater", not "exactly one patch higher".** A second `skill-author` pass on the same branch
|
||||
that bumps again still passes.
|
||||
- **`tests/` is excluded.** No agent loads it; a fixture-only change does not change the skill.
|
||||
- **Skills absent from either side are exempt.** New, renamed and merged skills start fresh under
|
||||
the rules above; deleted skills have nothing to check.
|
||||
- **Every plugin is covered, `bin` included,** and the gate is repo-local — it is not exported
|
||||
through `.pre-commit-hooks.yaml`.
|
||||
The baseline is the merge-base, not the previous commit. Readers only ever see `main` — installs
|
||||
resolve against the default branch (ADR-0018) — so one bump per branch is what the field owes
|
||||
them. A per-commit check would bump a skill once per commit and inflate the number past meaning.
|
||||
The rule is "greater", not "exactly one patch higher", so a second `skill-author` pass on the same
|
||||
branch that bumps again still passes. `tests/` is excluded because no agent loads it; a
|
||||
fixture-only change does not change the skill. Skills absent from either side are exempt: new,
|
||||
renamed and merged skills start fresh under the rules above, and deleted skills have nothing to
|
||||
check. Every plugin is covered, `bin` included, and the gate is repo-local — it is not exported
|
||||
through `.pre-commit-hooks.yaml`.
|
||||
|
||||
**Considered options.** *Declare the field advisory* — cheapest, but concedes the field cannot do
|
||||
its job. *Drop the field* — rejected by this ADR already, and costlier now. *Check at commit
|
||||
time against `HEAD`* — rejected for the inflation above.
|
||||
The gate fails closed rather than passing when it has no trustworthy baseline: when neither
|
||||
`origin/main` nor `main` resolves, when the pushed commit shares no merge-base with it, and when
|
||||
only local `main` resolves and the pushed commit is that merge-base, since a local `main` the
|
||||
pushed commit already contains is no independent record of what shipped. It reads versions with
|
||||
`python3` and PyYAML and fails with a clear message if either is missing.
|
||||
|
||||
**Consequences.** The 17 unbumped skills took a patch bump in the commit that added the gate.
|
||||
Like `check-release-needed`, the gate only fires on a local `git push` through pre-commit: a merge
|
||||
made with Gitea's merge button runs no local hooks and is not checked. A typo fix in a skill now
|
||||
costs a version bump; that is the rule working, not noise.
|
||||
Three alternatives were rejected. Declaring the field advisory is the cheapest, but concedes the
|
||||
field cannot do its job. Dropping the field was rejected by this ADR already, and costs more now.
|
||||
Checking at commit time against `HEAD` was rejected for the inflation described above.
|
||||
|
||||
The 17 unbumped skills took a patch bump in the commit that added the gate. A typo fix in a skill
|
||||
now costs a version bump; that is the rule working, not noise.
|
||||
|
||||
The gate differs from `check-release-needed` in when it runs: that hook acts only when pre-commit
|
||||
reports a push to `main`, so a manual `pre-commit run --hook-stage pre-push` skips it, while this
|
||||
gate runs there too and checks `HEAD`. The two hooks share both known gaps. A merge made with
|
||||
Gitea's merge button runs no local hooks, so it is not checked. And pre-commit's pre-push
|
||||
integration checks only the first ref with new commits in a multi-ref push (`git push origin a b`,
|
||||
`git push --all`): `_pre_push_ns` in pre-commit's `hook_impl.py` returns after that ref, so the
|
||||
other refs are pushed unchecked.
|
||||
|
||||
Reference in New Issue
Block a user