Commit b34fe873 authored by xuwang's avatar xuwang
Browse files

validate: guard against orphaned Changelog: trailers



Git only recognizes the last contiguous block of trailers, so a blank line
between `Changelog: <x>` and a following trailer (e.g. Co-Authored-By:)
silently drops the Changelog entry from the generated changelog -- as happened
in the v1.2.1 range, which shipped "No changes" despite real entries.

Add otica-changelog-lint.sh to flag such commits in the unreleased range, wire
it into validate.sh as a new `changelog` check (so `make validate` and the
release flow, which runs `validate.sh all`, both catch it), and expose
`make validate-changelog`.

Co-Authored-By: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 6f90303c
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
@@ -11,7 +11,7 @@ include $(OTICA_DIR)/makefiles/help.mk
include $(OTICA_DIR)/makefiles/release.mk

.PHONY: validate
validate: ## run all validation checks (parse + deps + scripts)
validate: ## run all validation checks (parse + deps + scripts + changelog)
	@tests/validate.sh all

.PHONY: validate-parse
@@ -26,6 +26,10 @@ validate-deps: ## detect target prerequisites with no matching target
validate-scripts: ## detect *.sh referenced in recipes that don't exist in scripts/
	@tests/validate.sh scripts

.PHONY: validate-changelog
validate-changelog: ## detect orphaned Changelog: trailers in the unreleased range
	@tests/validate.sh changelog

## Agentic workflow (operator) — see agent-instructions/agentic-workflow.md

.PHONY: agents
+68 −0
Original line number Diff line number Diff line
#!/bin/bash -eu
#
# Lint the `Changelog:` git trailers in a commit range.
#
# Git only treats the LAST contiguous block of "key: value" lines as trailers.
# A blank line between `Changelog: <category>` and a following trailer (e.g.
# `Co-Authored-By:`) pushes Changelog out of the trailer block, so git's parser
# never sees it and it is SILENTLY DROPPED from the generated changelog. This
# catches that class of bug — a commit whose message carries a `Changelog:` line
# that the trailer parser does not recognize — before it reaches a release.
#
# It does NOT flag commits with no Changelog trailer at all (docs/chores
# legitimately omit it); only present-but-unparseable trailers.
#
# Usage: otica-changelog-lint.sh [<from>] [<to>]
#   <from>  range start, exclusive (default: latest v* tag, else whole history)
#   <to>    range end           (default: HEAD)
#
# Exit 0 if clean, 1 if any orphaned Changelog trailer is found.

if ! git rev-parse --git-dir >/dev/null 2>&1; then
    echo "otica-changelog-lint: not a git repository — skipping." >&2
    exit 0
fi

FROM=${1:-}
TO=${2:-HEAD}

if [ -z "${FROM}" ]; then
    FROM=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
fi
if [ -z "${FROM}" ]; then
    range="${TO}"                     # no prior tag: scan all ancestors of TO
else
    range="${FROM}..${TO}"
fi

bad=()
while IFS= read -r sha; do
    [ -z "${sha}" ] && continue
    # A `Changelog:` line present in the raw message...
    git log -1 --format='%B' "${sha}" | grep -qiE '^Changelog:' || continue
    # ...but invisible to git's trailer parser => it's orphaned (not contiguous).
    parsed=$(git log -1 --format='%(trailers:key=Changelog,valueonly)' "${sha}")
    [ -z "${parsed}" ] && bad+=("${sha}")
done < <(git rev-list "${range}")

if [ ${#bad[@]} -eq 0 ]; then
    exit 0
fi

{
    echo "Orphaned 'Changelog:' trailer(s) — git cannot parse them, so they will"
    echo "be DROPPED from the changelog. Trailers must form ONE contiguous block at"
    echo "the end of the message (no blank line between Changelog: and a following"
    echo "trailer such as Co-Authored-By:)."
    echo
    for sha in "${bad[@]}"; do
        printf '  %s  %s\n' \
            "$(git log -1 --format='%h' "${sha}")" \
            "$(git log -1 --format='%s' "${sha}")"
    done
    echo
    echo "Fix: reword each commit so the trailers are adjacent, e.g."
    echo "     git rebase ${FROM:-<root>} --exec 'git commit --amend' and edit, or"
    echo "     amend if it is the latest commit."
} >&2
exit 1
+29 −9
Original line number Diff line number Diff line
@@ -3,11 +3,12 @@
#   parse     — every *.mk must parse cleanly when included after help.mk
#   deps      — every target prerequisite must resolve to a known target
#   scripts   — every *.sh referenced in a recipe must exist on PATH or in scripts/
#   changelog — no orphaned `Changelog:` trailers in the unreleased commit range
#
# Exit non-zero if any check fails.
#
# Usage:
#   tests/validate.sh [parse|deps|scripts|all]   (default: all)
#   tests/validate.sh [parse|deps|scripts|changelog|all]   (default: all)

set -u

@@ -186,14 +187,33 @@ validate_scripts() {
    fi
}

# ── changelog ───────────────────────────────────────────────────────────────
# Flag commits in the unreleased range whose `Changelog:` line git cannot parse
# as a trailer (broken out of the contiguous trailer block) — they would be
# silently dropped from the generated changelog. Delegates detection to
# otica-changelog-lint.sh so the same check guards both `make validate` and the
# release flow (otica-release.sh runs `validate.sh all`). The lint writes its
# diagnostic to stderr, which stays visible even when the release silences
# validate's stdout.
validate_changelog() {
    printf "${BOLD}== changelog ==${OFF}\n"
    if "${SCRIPTS_DIR}/otica-changelog-lint.sh"; then
        printf "  ${GREEN}ok${OFF}    Changelog: trailers well-formed\n"
    else
        printf "  ${RED}fail${OFF}  orphaned Changelog: trailer(s) — see message above\n"
        FAIL=1
    fi
}

# ── main ──────────────────────────────────────────────────────────────────────
cmd="${1:-all}"
case "$cmd" in
    parse)     validate_parse ;;
    deps)      validate_deps ;;
    scripts)   validate_scripts ;;
    all)     validate_parse; validate_deps; validate_scripts ;;
    *)       echo "Usage: $0 [parse|deps|scripts|all]" >&2; exit 2 ;;
    changelog) validate_changelog ;;
    all)       validate_parse; validate_deps; validate_scripts; validate_changelog ;;
    *)         echo "Usage: $0 [parse|deps|scripts|changelog|all]" >&2; exit 2 ;;
esac

if [ "$FAIL" -ne 0 ]; then