bug(config): an absolute changelog: is read as given by decide and manifest-check, but prep and release join it under the repo root #1528

Open
opened 2026-10-03 00:04:08 +02:00 by claude · 0 comments
Member

An absolute changelog: path in release-toolkit.yml is honoured by some verbs and joined under the repository root by others. rt prep and rt release therefore cannot run with one, on every OS, while decide, manifest-check and recover-pending-cut read it as given. Nothing in config validation says which reading is the contract.

Found while verifying #1527 (#1526), which made recover-pending-cut treat a Windows-absolute path as absolute, as decide already did.

Measured

2026-10-03, rt built at dd871e8 in golang:1.25. The fixture is a repo at /tmp/f/repo with changelog: /tmp/f/repo/CHANGELOG.md, and that file exists with an ## [Unreleased] section and a ## [0.1.0] section.

Verb Result
rt manifest-check all checks passed, exit 0
rt recover-pending-cut --dry-run (runs decide) decide reports mode=update; the changelog path raised no error
rt prep --dry-run Error: CHANGELOG file not found: /tmp/f/repo/CHANGELOG.md, exit 1
rt prep the same error and exit 1, at the read, before anything is written
rt release --dry-run --version 0.1.0 Error: CHANGELOG missing: /tmp/f/repo/CHANGELOG.md, exit 1

With changelog: CHANGELOG.md on the same fixture, prep --dry-run exits 0 and release --dry-run gets past the changelog to no git remote 'origin' configured.

Cause

Each verb resolves cfg.Changelog on its own, in one of three ways:

  • joined under the root, unconditionally:

    • cmd/rt/prep.go:189, filepath.Join(repoRoot, cfg.Changelog);
    • cmd/rt/release.go:91, the same;
    • internal/release/cutter.go:109, changelogPath(), the cut's read and seal write.

    On Linux /repo joined with /abs/CHANGELOG.md gives /repo/abs/CHANGELOG.md. On Windows a joined C:\... is an invalid name (not measured for these verbs; #1526 measured the same join in resolveUnderRoot).

  • absolute kept, relative joined:

    • internal/decide/decide.go:1362, decide.resolve with filepath.IsAbs;
    • cmd/rt/recover_pending_cut.go, resolveUnderRoot, the same rule since #1527.
  • used as given, relative to the working directory:

    • cmd/rt/manifest_check.go:170;
    • cmd/rt/prepared_uncut_check.go:439. This one is read from the code only; on this fixture it exited 2 with no output, so it was not graded.

config.Load does not constrain changelog:. docs/architecture/contracts/config.schema.json describes it only as "Path to the changelog file." version_files, in contrast, must be repo-relative (#181).

Why it matters

  • An adopter who sets an absolute changelog: gets green from manifest-check and a normal decide, then a failing prep whose message says the file does not exist, although it does.
  • The repo-relative guard that #181 gives version_files does not cover changelog:. By the code, recover-pending-cut writes the changelog at an absolute path as given, so it can write outside the repository. That was not run here, because the fixture has no pending cut.

Nothing is broken for a repo-relative changelog:, the form this repository's own config uses. Hence priority/low.

Possible directions (for whoever picks this up)

  • Refuse an absolute or traversing changelog: in config.Load, as #181 does for version_files. This is right if the changelog must live in the repository, which prep and the cut already assume because they git add and seal it. Cost: a config that decide and manifest-check accept today would fail validation.
  • Resolve it once, the decide.resolve way, for every verb. This is right if a changelog outside the checkout is a real use case. Cost: prep and the cut would then stage and commit a file outside the repository, which git add refuses. That needs its own answer.

Acceptance criteria

  • Every verb that reads or writes changelog: applies one rule to an absolute path, whether that is refusal at config time or a single resolution. The choice is recorded as a decision tree (AGENTS.md section 7).
  • A test pins that rule for each resolution site named above, and fails on the current code.
  • Behaviour with a repo-relative changelog: is byte-identical before and after.
  • A changelog.d fragment, if adopters can see the change.

Anchor

#1527's PR body, section "Observed, not changed". Filed at alex's request, without a commitment to fix it now.

An absolute `changelog:` path in `release-toolkit.yml` is honoured by some verbs and joined under the repository root by others. `rt prep` and `rt release` therefore cannot run with one, on every OS, while `decide`, `manifest-check` and `recover-pending-cut` read it as given. Nothing in config validation says which reading is the contract. Found while verifying #1527 (#1526), which made `recover-pending-cut` treat a Windows-absolute path as absolute, as `decide` already did. ## Measured 2026-10-03, `rt` built at `dd871e8` in `golang:1.25`. The fixture is a repo at `/tmp/f/repo` with `changelog: /tmp/f/repo/CHANGELOG.md`, and that file exists with an `## [Unreleased]` section and a `## [0.1.0]` section. | Verb | Result | |---|---| | `rt manifest-check` | `all checks passed`, exit 0 | | `rt recover-pending-cut --dry-run` (runs `decide`) | `decide reports mode=update`; the changelog path raised no error | | `rt prep --dry-run` | `Error: CHANGELOG file not found: /tmp/f/repo/CHANGELOG.md`, exit 1 | | `rt prep` | the same error and exit 1, at the read, before anything is written | | `rt release --dry-run --version 0.1.0` | `Error: CHANGELOG missing: /tmp/f/repo/CHANGELOG.md`, exit 1 | With `changelog: CHANGELOG.md` on the same fixture, `prep --dry-run` exits 0 and `release --dry-run` gets past the changelog to `no git remote 'origin' configured`. ## Cause Each verb resolves `cfg.Changelog` on its own, in one of three ways: - **joined under the root, unconditionally**: - `cmd/rt/prep.go:189`, `filepath.Join(repoRoot, cfg.Changelog)`; - `cmd/rt/release.go:91`, the same; - `internal/release/cutter.go:109`, `changelogPath()`, the cut's read and seal write. On Linux `/repo` joined with `/abs/CHANGELOG.md` gives `/repo/abs/CHANGELOG.md`. On Windows a joined `C:\...` is an invalid name (not measured for these verbs; #1526 measured the same join in `resolveUnderRoot`). - **absolute kept, relative joined**: - `internal/decide/decide.go:1362`, `decide.resolve` with `filepath.IsAbs`; - `cmd/rt/recover_pending_cut.go`, `resolveUnderRoot`, the same rule since #1527. - **used as given, relative to the working directory**: - `cmd/rt/manifest_check.go:170`; - `cmd/rt/prepared_uncut_check.go:439`. This one is read from the code only; on this fixture it exited 2 with no output, so it was not graded. `config.Load` does not constrain `changelog:`. `docs/architecture/contracts/config.schema.json` describes it only as "Path to the changelog file." `version_files`, in contrast, must be repo-relative (#181). ## Why it matters - An adopter who sets an absolute `changelog:` gets green from `manifest-check` and a normal `decide`, then a failing `prep` whose message says the file does not exist, although it does. - The repo-relative guard that #181 gives `version_files` does not cover `changelog:`. By the code, `recover-pending-cut` writes the changelog at an absolute path as given, so it can write outside the repository. That was not run here, because the fixture has no pending cut. Nothing is broken for a repo-relative `changelog:`, the form this repository's own config uses. Hence priority/low. ## Possible directions (for whoever picks this up) - **Refuse an absolute or traversing `changelog:` in `config.Load`**, as #181 does for `version_files`. This is right if the changelog must live in the repository, which `prep` and the cut already assume because they `git add` and seal it. Cost: a config that `decide` and `manifest-check` accept today would fail validation. - **Resolve it once, the `decide.resolve` way, for every verb.** This is right if a changelog outside the checkout is a real use case. Cost: `prep` and the cut would then stage and commit a file outside the repository, which `git add` refuses. That needs its own answer. ## Acceptance criteria - [ ] Every verb that reads or writes `changelog:` applies one rule to an absolute path, whether that is refusal at config time or a single resolution. The choice is recorded as a decision tree (AGENTS.md section 7). - [ ] A test pins that rule for each resolution site named above, and fails on the current code. - [ ] Behaviour with a repo-relative `changelog:` is byte-identical before and after. - [ ] A `changelog.d` fragment, if adopters can see the change. ## Anchor #1527's PR body, section "Observed, not changed". Filed at alex's request, without a commitment to fix it now.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1528
No description provided.