From 37962402f0477675c24e095928ee20e15671ab02 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 21:09:59 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20actions/changelog-monotonic=20?= =?UTF-8?q?=E2=80=94=20shipped=20headings=20are=20append-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of box's changelog-monotonic.sh (box#122, caught in review of box#118) as a composite action, folding in cast's port notes (cast#133). Uniqueness on HEAD runs before anything that needs history (box#143); containment degrades loudly, and the action defaults strict to 1 so CI can never take the skip path. Co-Authored-By: Claude Fable 5 --- actions/changelog-monotonic/action.yml | 41 +++ .../changelog-monotonic.sh | 241 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 actions/changelog-monotonic/action.yml create mode 100644 actions/changelog-monotonic/changelog-monotonic.sh diff --git a/actions/changelog-monotonic/action.yml b/actions/changelog-monotonic/action.yml new file mode 100644 index 0000000..6063f64 --- /dev/null +++ b/actions/changelog-monotonic/action.yml @@ -0,0 +1,41 @@ +name: Changelog monotonic +description: >- + Assert shipped release headings are append-only — no '## X.Y.Z' heading + present at the merge base may be deleted, and none may be duplicated on + HEAD (box#122, caught in review of box#118; ported per cast#133). Needs + the HISTORY: the caller's checkout must use fetch-depth: 0, or the base + ref will not resolve and strict mode (the default — CI must never skip) + fails red with a message naming that fix. Reads no version source — this + guard is about the diff, not the tree's version state. +inputs: + base-ref: + description: >- + The ref containment compares HEAD against. The default resolves the + event's base branch — the PR's target on pull_request, the pushed + branch itself on push (where containment is vacuous by construction + and uniqueness is the half that runs). + required: false + default: origin/${{ github.base_ref || github.ref_name }} + changelog: + description: Path to the changelog, relative to the workspace + required: false + default: CHANGELOG.md + strict: + description: >- + "1" (the default) makes an unresolvable base ref a hard failure + instead of a loud skip — a guard that can quietly stop guarding is + the failure shape this family of checks exists to refuse. The local + default in the script itself stays "0", so a plain working-copy run + degrades sensibly. + required: false + default: "1" +runs: + using: composite + steps: + - name: changelog monotonic + shell: bash + env: + CHANGELOG_MONOTONIC_BASE: ${{ inputs.base-ref }} + CHANGELOG: ${{ inputs.changelog }} + CHANGELOG_MONOTONIC_STRICT: ${{ inputs.strict }} + run: bash "$GITHUB_ACTION_PATH/changelog-monotonic.sh" diff --git a/actions/changelog-monotonic/changelog-monotonic.sh b/actions/changelog-monotonic/changelog-monotonic.sh new file mode 100644 index 0000000..24e531d --- /dev/null +++ b/actions/changelog-monotonic/changelog-monotonic.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-monotonic.sh [] [] — assert that no SHIPPED +# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings +# on HEAD must be a SUPERSET of the set at the merge base. +# +# Ported from box .github/scripts/changelog-monotonic.sh (the guard's origin: +# box#122, caught in review of box#118), folding in cast's port notes +# (cast#133) — all three repos carried mechanically-identical copies, which +# is issue #1's whole argument for this repo. +# +# The failure it exists to catch leaves no trace either. An author adding an +# entry under '## Unreleased' REPLACES the line below it instead of +# inserting above it: +# +# -## 0.8.0 — 2026-07-19 +# +## Unreleased +# + +# +### Fixed +# + +# +- **An entry** +# +# git merges that cleanly — it is a one-line edit inside a file nobody has +# touched concurrently — and the shipped section's whole body is silently +# absorbed into '## Unreleased'. 0.8.0 no longer HAS a section; the heading +# changelog_section (lib/changelog.sh, #4) extracts by is gone, and the next +# release cut from that state republishes 0.8.0's prose as if it were new. +# +# actions/changelog-armed (#5) is green on exactly that tree, correctly: it +# asks only whether the TOP section agrees with the tree's version, and +# deleting '## 0.8.0' leaves '## Unreleased' on top. It is not wrong, it is +# narrow — it guards ONE heading, the one a PR is about to write under. This +# guards the REST of the file, the part no single tree can be asked about at +# all, because "a heading disappeared" is not a property of a tree — it is a +# property of a DIFF. +# +# The rule, and why it needs no tuning: release headings are APPEND-ONLY. +# The ceremony (box#96, #1) adds one and never removes one; nothing else in +# the documented flow touches them. So SUPERSET is exact — it has no +# legitimate violation to carve an exception for. The stamp is covered for +# free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS X.Y.Z and +# removes no X.Y.Z heading, because 'Unreleased' is not one. '## Unreleased' +# is deliberately NOT in the set this guards — changelog-armed owns that +# heading, keyed on the tree's version, and the ceremony legitimately +# consumes it. +# +# A file of its own, NOT a clause inside changelog-armed.sh, for three +# reasons. Its input is different (a git history, not two files). Its +# degradation is different (no base ref is a SKIP, not a failure). And +# changelog-armed.sh is driven by test/changelog-armed.test.sh against +# constructed two-file trees that are not git repos at all — folding a +# git-dependent assert into it would make every one of those cases either +# skip or lie. Same discipline as lib/changelog.sh: its own file so a test +# can drive it. + +base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}" +changelog="${2:-${CHANGELOG:-CHANGELOG.md}}" + +# Fail-closed switch: CI sets it (the action defaults strict to "1"), so a +# SKIP that would be a sensible local degradation becomes a red run there +# instead. A guard that can silently stop guarding is the failure shape this +# whole family of checks exists to refuse, so the skip path is loud and CI +# refuses to take it at all. +strict="${CHANGELOG_MONOTONIC_STRICT:-0}" + +skip() { + if [ "$strict" = "1" ]; then + echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2 + echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2 + echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2 + echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2 + exit 1 + fi + echo "changelog-monotonic: containment SKIPPED — $*" + echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading" + echo " half needs the history. In CI this same condition is a hard failure.)" + exit 0 +} + +[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; } + +# The set of RELEASE headings: '## ...' where looks like a +# version. Field $2, the same split changelog_section (lib/changelog.sh) and +# changelog-armed.sh use, so the three cannot disagree about what a section +# header is. 'Unreleased' fails the shape and is excluded by construction. +# The WHOLE field is the set member, so 0.7.0 and 0.7.0-rc1 are distinct and +# one can never stand in for the other. +headings_raw() { + awk ' + /^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 } + ' +} +headings() { headings_raw | sort -u; } + +# --- uniqueness on HEAD (the box#118 class) ---------------------------------- +# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the +# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to +# extras on the head side — with or without `sort -u`, base {0.8.0} minus head +# {0.8.0, 0.8.0} is empty. Multiset comparison does not close it either, for +# the same reason. The assert that does is uniqueness of version headings ON +# HEAD, kept alongside containment rather than replacing it. +# +# This is the shape box#118's bad rebase actually produced: two +# `## 0.8.0 — 2026-07-19` headings with the incoming entry between them. Every +# other guard stayed green — markers absent, the arming rule happy (the top +# section was still right), tests and shellcheck clean — while box's +# extractor, which had no `exit` and re-armed its grab on every matching +# '## ' line, folded post-cut prose into the shipped release body. This +# repo's changelog_section stops at the second heading (`if (found) exit`) — +# same class, milder symptom: the body TRUNCATES at the first copy, and an +# entry stranded between the copies is silently dropped from this release's +# notes and the next's alike (cast#133 documents both extractor shapes). +# +# Nothing legitimate repeats a version heading: the ceremony stamps a NEW +# version, and 'Unreleased' fails the version shape and never reaches here. +dupes="$(headings_raw < "$changelog" | sort | uniq -d)" +if [ -n "$dupes" ]; then + { + echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):" + echo + printf '%s\n' "$dupes" | sed 's/^/ ## /' + echo + cat <&2 + exit 1 +fi + +# --- everything below needs the HISTORY ------------------------------------ +# Uniqueness is settled. What follows is containment, which compares HEAD +# against the merge base and therefore genuinely depends on the base ref, the +# merge base, and the base blob. Each of those can be unavailable for reasons +# that are not the author's fault (a shallow clone, a fork checkout without +# the upstream remote, the commit that first adds the changelog), so each +# degrades rather than failing — which is exactly why the uniqueness half must +# NOT live down here (box#143). It asks nothing of the history, and gating it +# behind these conditions let a duplicate exit 0 on a message about deletion. + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || skip "not inside a git work tree, so there is no history to compare against" + +git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \ + || skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)" + +merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)" +[ -n "$merge_base" ] \ + || skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)" + +# The changelog may not exist at the merge base at all (the commit that adds +# it). Nothing to have deleted, so nothing to assert. +base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)" +[ -n "$base_file" ] || { + echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)." + exit 0 +} + +base_headings="$(printf '%s\n' "$base_file" | headings)" +head_headings="$(headings < "$changelog")" + +# comm -23: lines in the base set that are NOT in the head set — exactly the +# headings this branch removed. +missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))" + +if [ -n "$missing" ]; then + { + echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:" + echo + printf '%s\n' "$missing" | sed 's/^/ ## /' + echo + cat <&2 + exit 1 +fi + +count="$(printf '%s\n' "$base_headings" | grep -c . || true)" +head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)" + +# The success line has two honest forms, because this step runs on two shapes +# of event. On a push to main the merge base IS HEAD: containment compared +# the file against itself and asserted nothing, and deletion is undetectable +# on that event by construction. Reporting "all N still present" there would +# be the same dishonesty the skip messages were fixed for — a log claiming a +# check that did no work. Uniqueness is the half that actually ran, so that +# is the half the line names. +if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then + echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)." +else + echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog" +fi From 7424ef21bf53beee815d92e19c95ea780e6230d3 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 21:11:46 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test:=20changelog-monotonic=20contract=20su?= =?UTF-8?q?ite=20=E2=80=94=20constructed=20git=20histories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every case from #6's test list: the documented-flow passes (insert-above, the stamp, prose-only), the box#122 deletion shape, the box#118 duplicate (proven to run before every history gate, the box#143 ordering), rc distinctness, both STRICT degradations, the file-first-added edge, the vacuous push-to-main shape, and the action's env wiring. Co-Authored-By: Claude Fable 5 --- test/changelog-monotonic.test.sh | 274 +++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 test/changelog-monotonic.test.sh diff --git a/test/changelog-monotonic.test.sh b/test/changelog-monotonic.test.sh new file mode 100644 index 0000000..f0e50c6 --- /dev/null +++ b/test/changelog-monotonic.test.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Contract tests for actions/changelog-monotonic (issue #6). Unlike the +# armed guard's two-file trees, every containment case here needs a real +# constructed git repo — "a heading disappeared" is a property of a DIFF, +# so the fixture is a history: base commit, mutation commit, compare. The +# uniqueness cases deliberately run WITHOUT usable history, because running +# before anything history-dependent is the box#143 lesson under test. +# set -u, not -e: failing commands are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +SCRIPT="$ROOT/actions/changelog-monotonic/changelog-monotonic.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# repo — a git repo whose first commit holds the changelog arriving +# on stdin, with a 'base' branch pinned there. Later mutations move HEAD; +# 'base' stays put, standing in for origin/main. +repo() { + local dir="$TMP/$1" + mkdir -p "$dir" + git -C "$dir" init -q -b main + git -C "$dir" config user.email ci@example.invalid + git -C "$dir" config user.name ci + cat >"$dir/CHANGELOG.md" + git -C "$dir" add CHANGELOG.md + git -C "$dir" commit -qm base + git -C "$dir" branch base +} + +# mutate — replace the repo's changelog with stdin and commit on HEAD. +mutate() { + local dir="$TMP/$1" + cat >"$dir/CHANGELOG.md" + git -C "$dir" commit -qam mutate +} + +run() { local dir="$1"; shift; (cd "$TMP/$dir" && bash "$SCRIPT" "$@"); } +run_strict() { local dir="$1"; shift; (cd "$TMP/$dir" && CHANGELOG_MONOTONIC_STRICT=1 bash "$SCRIPT" "$@"); } + +# The shared base shape: an armed changelog with two shipped releases. +base_changelog() { + cat <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +} + +# --- the documented flow passes ---------------------------------------------- + +repo insert-above < <(base_changelog) +mutate insert-above <<'EOF' +# Changelog + +## Unreleased + +### Fixed + +- **A new entry, inserted above.** + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "entry inserted above an existing heading passes" 0 "still present" \ + run insert-above base + +repo stamp < <(base_changelog) +mutate stamp <<'EOF' +# Changelog + +## Unreleased + +## 0.9.0 — 2026-07-22 + +- The section that was Unreleased, now stamped. + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "the ceremony stamp (adds a heading, removes none) passes" 0 "still present" \ + run stamp base + +repo prose-only < <(base_changelog) +mutate prose-only <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +- Shipped entry, reworded without touching any heading. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "an unrelated heading-less edit passes" 0 "still present" \ + run prose-only base + +# --- deletion (the box#122 shape) ---------------------------------------------- + +# The exact bad edit: the entry's '## Unreleased' block REPLACED the +# '## 0.8.0' heading line, absorbing that release's body. +repo deleted < <(base_changelog) +mutate deleted <<'EOF' +# Changelog + +## Unreleased + +## Unreleased + +### Fixed + +- **An entry.** + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "a deleted shipped heading fails" 1 "DELETES" run deleted base +check "the deletion failure names the missing version" 1 "0.8.0" run deleted base +check "the deletion failure teaches insert-above-never-over" 1 "INSERT above" \ + run deleted base + +# --- duplication (the box#118 class) — runs even with NO usable base ---------- + +repo duped < <(base_changelog) +mutate duped <<'EOF' +# Changelog + +## Unreleased + +## 0.8.0 — 2026-07-19 + +### Fixed + +- **An entry that belonged under Unreleased.** + +## 0.8.0 — 2026-07-19 + +- Shipped entry. + +## 0.7.0 — 2026-07-01 + +- Older entry. +EOF +check "a duplicated heading on HEAD fails" 1 "DUPLICATE" run duped base +# Ordering is the box#143 lesson: uniqueness must not sit behind the history +# gates. An unresolvable base ref plus STRICT=1 would be a strict failure — +# the duplicate must win, because it needs no history at all. +check "the duplicate fails even when the base ref cannot resolve" 1 "DUPLICATE" \ + run duped no-such-ref +check "the duplicate beats the STRICT failure for the same broken ref" 1 "DUPLICATE" \ + run_strict duped no-such-ref + +mkdir -p "$TMP/duped-plain" +cp "$TMP/duped/CHANGELOG.md" "$TMP/duped-plain/CHANGELOG.md" +check "the duplicate fails even outside a git repo entirely" 1 "DUPLICATE" \ + run duped-plain + +# --- whole-version set members ------------------------------------------------- + +# 0.7.0 and 0.7.0-rc1 are distinct members: "renaming" the rc section to the +# bare version deletes the rc heading, and the bare one cannot stand in. +repo rc < <(cat <<'EOF' +# Changelog + +## Unreleased + +## 0.7.0-rc1 — 2026-06-20 + +- The candidate's entry. +EOF +) +mutate rc <<'EOF' +# Changelog + +## Unreleased + +## 0.7.0 — 2026-07-01 + +- The candidate's entry. +EOF +check "0.7.0 never satisfies a deleted 0.7.0-rc1" 1 "0.7.0-rc1" run rc base + +# --- degradation: the loud skip and the STRICT refusal ------------------------- + +repo no-base < <(base_changelog) +check "base ref missing, STRICT=0: loud skip, exit 0" 0 "SKIPPED" \ + run no-base does-not-exist +check "base ref missing, STRICT=1: hard failure" 1 "FAILURE" \ + run_strict no-base does-not-exist +check "the STRICT failure names the checkout fix, not the script" 1 "fetch-depth: 0" \ + run_strict no-base does-not-exist + +mkdir -p "$TMP/plain" +base_changelog >"$TMP/plain/CHANGELOG.md" +check "not a git repo, STRICT=0: loud skip" 0 "not inside a git work tree" \ + run plain +check "not a git repo, STRICT=1: hard failure" 1 "FAILURE" run_strict plain + +# --- the honest edges ----------------------------------------------------------- + +# The commit that first ADDS the changelog: nothing existed at the merge +# base, so nothing could have been deleted. +mkdir -p "$TMP/newfile" +git -C "$TMP/newfile" init -q -b main +git -C "$TMP/newfile" config user.email ci@example.invalid +git -C "$TMP/newfile" config user.name ci +printf 'seed\n' >"$TMP/newfile/README" +git -C "$TMP/newfile" add README +git -C "$TMP/newfile" commit -qm seed +git -C "$TMP/newfile" branch base +base_changelog >"$TMP/newfile/CHANGELOG.md" +git -C "$TMP/newfile" add CHANGELOG.md +git -C "$TMP/newfile" commit -qm add-changelog +check "changelog absent at the merge base passes with the honest notice" 0 \ + "does not exist at the merge base" run newfile base + +# Push-to-main shape: the merge base IS HEAD, containment is vacuous by +# construction, and the success line must say which half actually ran. +repo vacuous < <(base_changelog) +check "merge base IS HEAD: vacuous containment named honestly" 0 "vacuous" \ + run vacuous HEAD + +check "missing changelog fails" 1 "no such file" run vacuous base NOPE.md + +# --- the action's wiring: inputs arrive as env vars ------------------------------ + +# A non-default changelog name and base env prove the env vars are honored +# the way action.yml passes them, not the defaults. +mkdir -p "$TMP/env-tree" +git -C "$TMP/env-tree" init -q -b main +git -C "$TMP/env-tree" config user.email ci@example.invalid +git -C "$TMP/env-tree" config user.name ci +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Entry.\n' >"$TMP/env-tree/NOTES.md" +git -C "$TMP/env-tree" add NOTES.md +git -C "$TMP/env-tree" commit -qm base +git -C "$TMP/env-tree" branch fixture-base +printf '# Changelog\n\n## Unreleased\n\n## 0.1.0 — 2026-07-01\n\n- Entry.\n' >"$TMP/env-tree/NOTES.md" +git -C "$TMP/env-tree" commit -qam mutate +env_tree() { + (cd "$TMP/env-tree" && \ + CHANGELOG_MONOTONIC_BASE=fixture-base CHANGELOG=NOTES.md CHANGELOG_MONOTONIC_STRICT=1 \ + bash "$SCRIPT") +} +check "env vars drive the script the way action.yml does" 0 "still present" env_tree + +summary From a0bea573a14fcc4a0b43eaacc3bbfc583b047171 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 21:12:44 +0000 Subject: [PATCH 3/3] ci: exercise changelog-monotonic with a real containment run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action-exercise job commits a scratch base, marks it as an in-job fixture branch, commits an insert-above edit, and runs the composite action against that history — proving the wiring AND a genuine merge-base comparison, not just an action.yml parse. Base ref passed explicitly: the shallow PR checkout has no origin/main to resolve. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b56200..7d774ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,27 @@ jobs: - uses: ./actions/changelog-armed with: changelog: CHANGELOG.scratch.md + - name: Construct a scratch history for the monotonic guard + # The monotonic guard's input is a DIFF, so its exercise needs + # history, not just a file: commit a scratch changelog, mark that + # commit as the fixture base, then commit an insert-above edit on + # top — a real containment run, not just an action.yml parse. The + # base ref is the in-job branch, passed explicitly, because this + # job's shallow PR checkout carries no origin/main for the input's + # default to resolve (consumers get that via fetch-depth: 0, per + # the action's description). Scratch-named file for the same + # shadowing reason as above; the commits live only in this job's + # checkout and are never pushed. + run: | + git config user.name ceremony-ci + git config user.email ceremony-ci@users.noreply.github.com + printf '# Changelog\n\n## Unreleased\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md + git add CHANGELOG.monotonic.scratch.md + git commit -m 'fixture: monotonic base' + git branch monotonic-fixture-base + printf '# Changelog\n\n## Unreleased\n\n- Entry inserted above.\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md + git commit -am 'fixture: insert above' + - uses: ./actions/changelog-monotonic + with: + changelog: CHANGELOG.monotonic.scratch.md + base-ref: monotonic-fixture-base