fix: assert no shipped changelog heading is deleted or duplicated #134
3 changed files with 78 additions and 3 deletions
13
.github/scripts/changelog-monotonic.sh
vendored
13
.github/scripts/changelog-monotonic.sh
vendored
|
|
@ -234,4 +234,17 @@ EOF
|
|||
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 now 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 (#133) —
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -82,7 +82,14 @@ actually cutting it, and this file starts there.
|
|||
Fixed by moving, not rewriting: uniqueness now runs directly after the file
|
||||
exists, before any git access. The skip messages say *containment* skipped
|
||||
and that uniqueness already passed, so a skip no longer claims nothing was
|
||||
checked. The guard is also no longer gated to `pull_request` — deletion is
|
||||
checked — and the success line got the same treatment, because dropping the
|
||||
gate made `merge_base == HEAD` a routine path rather than a degradation. On a
|
||||
push to main containment compares the file against itself and asserts
|
||||
nothing, so the line now reports containment *vacuous* and names uniqueness
|
||||
as the half that ran, instead of claiming N headings were verified present by
|
||||
a comparison that could not have detected their absence.
|
||||
|
||||
The guard is also no longer gated to `pull_request` — deletion is
|
||||
vacuous on a push to main, but duplication is vacuous on no tree, so a
|
||||
duplicate reaching main by any other route went unasserted. That gate could
|
||||
not simply be dropped: `github.base_ref` is empty on a push, and a bare
|
||||
|
|
|
|||
|
|
@ -435,9 +435,53 @@ describe("changelog-monotonic.sh — release headings are append-only (#133)", (
|
|||
) => run("bash", [MONOTONIC, base], env, repo);
|
||||
|
||||
it("a branch that touches nothing passes, and says how many headings it checked", async () => {
|
||||
// A branch that touches nothing has HEAD as its own merge base, which is
|
||||
// now the VACUOUS-containment path (#133), so the count this asserts moved
|
||||
// to uniqueness's — which serves the stated intent better anyway: it says
|
||||
// the parser read the file and found real headings in it, rather than that
|
||||
// a comparison of the file against itself came out equal.
|
||||
const r = await check(repoWith());
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain(
|
||||
"uniqueness on HEAD checked 2 release heading(s)",
|
||||
);
|
||||
});
|
||||
|
||||
// --- the push-to-main shape: containment vacuous, uniqueness real --------
|
||||
// With the pull_request gate gone (#133), merge_base == HEAD is a ROUTINE
|
||||
// path, not a degradation. Containment compares the file against itself and
|
||||
// asserts nothing, so a line reading "all N still present" would claim a
|
||||
// check that did no work — the same dishonesty the skip messages were fixed
|
||||
// for. The success line therefore has two forms, and these pin which one
|
||||
// each event shape gets, including that they do not collapse into one.
|
||||
|
||||
it("HEAD as its own base reports containment VACUOUS, not verified", async () => {
|
||||
const r = await check(repoWith(), {}, "HEAD");
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain("containment vacuous");
|
||||
});
|
||||
|
||||
it("...and names uniqueness as the half that actually ran", async () => {
|
||||
const r = await check(repoWith(), {}, "HEAD");
|
||||
expect(r.output).toContain("uniqueness on HEAD checked");
|
||||
});
|
||||
|
||||
it("...and does NOT claim the headings were still present", async () => {
|
||||
const r = await check(repoWith(), {}, "HEAD");
|
||||
expect(r.output).not.toContain("are still present");
|
||||
});
|
||||
|
||||
it("...while a REAL base still reports containment, naming the count", async () => {
|
||||
// The PR shape. The two wordings must not collapse into one.
|
||||
const good = BASE.replace(
|
||||
"## Unreleased\n",
|
||||
"## Unreleased\n\n### Fixed\n\n- **A new entry**\n",
|
||||
);
|
||||
const r = await check(repoWith(good));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain("all 2 release heading(s)");
|
||||
expect(r.output).toContain("are still present");
|
||||
expect(r.output).not.toContain("containment vacuous");
|
||||
});
|
||||
|
||||
it("adding an entry the CORRECT way — above the heading, never over it — passes", async () => {
|
||||
|
|
@ -645,7 +689,18 @@ describe("changelog-monotonic.sh — release headings are append-only (#133)", (
|
|||
// #133: NOT pull-request-only. Deletion is vacuous on a push to main, but
|
||||
// duplication is vacuous on no tree — gating the whole script left a
|
||||
// duplicate that reached main by any other route unasserted forever.
|
||||
expect(CI).not.toContain("if: github.event_name == 'pull_request'");
|
||||
//
|
||||
// Scoped to the step's OWN block, deliberately. As a file-wide negative it
|
||||
// would forbid any FUTURE step in ci.yml from being pull_request-gated and
|
||||
// would fail citing #133 when one legitimately is — #133 constrains this
|
||||
// step, not the file. The companion assert below keeps the extractor from
|
||||
// silently matching nothing and turning the negative into a tautology.
|
||||
const monoBlock = CI.split(/^ {6}- name: /m).find((b) =>
|
||||
b.startsWith("no shipped changelog heading"),
|
||||
);
|
||||
expect(monoBlock).toBeDefined();
|
||||
expect(monoBlock).toContain("changelog-monotonic.sh");
|
||||
expect(monoBlock).not.toContain("if:");
|
||||
// ...and dropping that gate is only safe WITH the fallback: on a push
|
||||
// `github.base_ref` is empty, a bare `origin/` does not resolve, and
|
||||
// STRICT promotes that to a hard failure on every push to main.
|
||||
|
|
|
|||
Loading…
Reference in a new issue