Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions .github/workflows/ant-cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ jobs:
- name: extract changelog entry
id: changelog
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Extract the changelog section for the version being released — NOT
# "the first section". Blindly taking the first section shipped the
Expand All @@ -357,8 +359,8 @@ jobs:
# the dual-stack entry re-shipped for four months). See V2-1107.
#
# `-rc.N` / `-beta.N` builds share the base version's section: the
# section is created when the train cuts rc.1 (rotate `[Unreleased]`
# into `[X.Y.Z] - <date>` as part of the cut), so every prerelease
# section is created when the train cuts rc.1 (`scripts/rotate-changelog.sh
# X.Y.Z` moves `[Unreleased]` into `[X.Y.Z] - <date>`), so every prerelease
# and the final stable of one train ship the same, correct, notes.
version="${{ steps.meta.outputs.version }}"
base_version="${version%%-*}"
Expand All @@ -373,11 +375,43 @@ jobs:
# all". The `::warning::` annotation keeps the omission loud on the run.
# Pre-tag enforcement, if we want it, belongs on the release-prep PR where
# a fix is cheap and nothing is blocked.
#
# Without a section, the notes fall back to GitHub's generated list of the
# PRs merged since the previous stable `ant-cli-v*` release, so a missed
# rotation ships the right list of changes rather than none (0.3.7 to 0.3.11
# all shipped the placeholder). The placeholder is only the last resort, for
# when that API call fails.
if [ -z "$(echo "$changelog" | tr -d '[:space:]')" ]; then
echo "::warning title=Missing changelog section::CHANGELOG.md has no non-empty '## [${base_version}]' section. Publishing with placeholder notes; correct them with 'gh release edit' and rotate '## [Unreleased]' into '## [${base_version}] - $(date -u +%F)'. See V2-1107."
cat > /tmp/changelog_entry.md <<EOF
tag="${{ steps.meta.outputs.tag }}"
# Highest stable tag below this base version. For `X.Y.Z-rc.N` and for
# `X.Y.Z` alike that is the previous train's stable, so every build of
# one train lists the same changes.
previous_tag=$(git ls-remote --tags --refs origin 'ant-cli-v*' \
| sed 's|.*refs/tags/||' \
| grep -E '^ant-cli-v[0-9]+\.[0-9]+\.[0-9]+$' \
| { cat; echo "ant-cli-v${base_version}"; } \
| sort -u -V \
| grep -B1 -x "ant-cli-v${base_version}" \
| grep -v -x "ant-cli-v${base_version}" || true)
generated=""
if [ -n "$previous_tag" ]; then
generated=$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$tag" -f previous_tag_name="$previous_tag" \
--jq .body || true)
fi
if [ -n "$(echo "$generated" | tr -d '[:space:]')" ]; then
echo "::warning title=Missing changelog section::CHANGELOG.md has no non-empty '## [${base_version}]' section. Publishing GitHub's generated notes (${previous_tag}..${tag}); replace them with 'gh release edit' once the section exists, and rotate '## [Unreleased]' with 'scripts/rotate-changelog.sh ${base_version}' at the cut. See V2-1107."
{
echo "_No changelog entry was recorded for ${base_version}. These are the pull requests merged since ${previous_tag#ant-cli-v}; the notes will be corrected retroactively._"
echo
echo "$generated"
} > /tmp/changelog_entry.md
else
echo "::warning title=Missing changelog section::CHANGELOG.md has no non-empty '## [${base_version}]' section, and GitHub's generated notes were unavailable. Publishing with placeholder notes; correct them with 'gh release edit' and rotate '## [Unreleased]' with 'scripts/rotate-changelog.sh ${base_version}' at the cut. See V2-1107."
cat > /tmp/changelog_entry.md <<EOF
_No changelog entry was recorded for ${base_version}. See the commit history for the changes in this release; these notes will be corrected retroactively._
EOF
fi
else
# Write to file for the release body
echo "$changelog" > /tmp/changelog_entry.md
Expand Down
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# AGENTS.md

Instructions for any coding agent working in this repo (Claude Code, Codex, OpenCode,
pi and others). Claude Code loads this file through `CLAUDE.md`.

## Changelog

A PR that changes what `ant` or `ant-core` users see adds an entry under
`## [Unreleased]` in `CHANGELOG.md` (Keep a Changelog headings: `Added`,
`Changed`, `Fixed`, `Security`, `Internal`; end the entry with the PR number).
Never write a versioned `## [X.Y.Z]` heading in a feature PR: the release train
creates it when it cuts rc.1, by running `scripts/rotate-changelog.sh X.Y.Z` on
the rc branch. The release workflow ships the `## [X.Y.Z]` section as the notes
of every `ant-cli-vX.Y.Z*` tag, and falls back to GitHub's generated PR list
when the section is missing (V2-1107).
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,9 @@ CI enforces the Linear link and the template fields (`linear-link` and
Release-promotion PRs — a `chore(release):` title from an `rc-*` head branch —
are exempt from both checks (they are mechanical version-bump PRs generated by
the release process).

## Shared agent instructions

Instructions for every coding agent, not just Claude, live in `AGENTS.md`:

@AGENTS.md
107 changes: 107 additions & 0 deletions scripts/rotate-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Rotate CHANGELOG.md's `## [Unreleased]` entries into a `## [X.Y.Z] - <date>` section.
#
# Run this when a train cuts rc.1, on the rc branch, before tagging. The release workflow
# (`ant-cli-release.yml`) ships the section whose heading matches the tag's base version,
# so `ant-cli-vX.Y.Z-rc.N`, `-beta.N` and the final `ant-cli-vX.Y.Z` all read the
# `## [X.Y.Z]` section this script writes. See V2-1107.
#
# scripts/rotate-changelog.sh <version> [<date>] [<changelog>]
#
# <version> X.Y.Z, or a prerelease X.Y.Z-rc.N (the suffix is dropped)
# <date> YYYY-MM-DD, defaults to today in UTC
# <changelog> defaults to CHANGELOG.md
#
# Exit status:
# 0 rotated; or `## [X.Y.Z]` already has entries (an rc.2 re-cut), so nothing to do;
# or `[Unreleased]` is empty, so nothing to rotate (a warning is printed, and the
# release workflow publishes generated notes for that version instead)
# 1 bad arguments, or the changelog has no `## [Unreleased]` heading
# 2 `## [X.Y.Z]` exists with entries AND `[Unreleased]` has entries too: someone
# added entries after the section was rotated, and a human has to place them
#
# A changelog problem must never block a release (V2-1142): callers in the cut tooling
# should report a non-zero exit, not abort the cut on it.

set -euo pipefail

usage() {
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//' >&2
exit 1
}

[ $# -ge 1 ] && [ $# -le 3 ] || usage

version="${1#v}"
version="${version%%-*}"
date="${2:-$(date -u +%F)}"
changelog="${3:-CHANGELOG.md}"

if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "rotate-changelog: '$1' is not a version (want X.Y.Z or X.Y.Z-rc.N)" >&2
exit 1
fi
if ! [[ "$date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "rotate-changelog: '$date' is not a date (want YYYY-MM-DD)" >&2
exit 1
fi
if [ ! -f "$changelog" ]; then
echo "rotate-changelog: $changelog not found" >&2
exit 1
fi
if ! grep -q '^## \[Unreleased\]' "$changelog"; then
echo "rotate-changelog: $changelog has no '## [Unreleased]' heading" >&2
exit 1
fi

# Print the body of the section whose heading starts with `## [<name>]`.
section() {
awk -v name="$1" '
index($0, "## [" name "]") == 1 { found = 1; next }
/^## \[/ { if (found) exit }
found' "$changelog"
}

has_entries() {
[ -n "$(printf '%s' "$1" | tr -d '[:space:]')" ]
}

unreleased="$(section Unreleased)"
existing="$(section "$version")"

if has_entries "$existing"; then
if has_entries "$unreleased"; then
echo "rotate-changelog: ## [$version] already has entries, and so does ## [Unreleased]." >&2
echo "rotate-changelog: move the [Unreleased] entries that ship in $version by hand." >&2
exit 2
fi
echo "rotate-changelog: ## [$version] already has entries; nothing to do."
exit 0
fi

if ! has_entries "$unreleased"; then
echo "rotate-changelog: WARNING: ## [Unreleased] is empty, so $version has no entries." >&2
echo "rotate-changelog: the release will publish generated notes for it; add entries under" >&2
echo "rotate-changelog: ## [$version] (and 'gh release edit' the release) to replace them." >&2
exit 0
fi

# Keep the `[Unreleased]` heading, open `[X.Y.Z] - <date>` under it and move the entries
# there unchanged. An empty `## [X.Y.Z]` heading left by hand is dropped, not duplicated.
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
awk -v ver="$version" -v date="$date" '
index($0, "## [" ver "]") == 1 { skip_empty = 1; next }
skip_empty && /^[[:space:]]*$/ { next }
{ skip_empty = 0 }
!done && /^## \[Unreleased\]/ {
print
print ""
print "## [" ver "] - " date
done = 1
next
}
{ print }' "$changelog" > "$tmp"
cat "$tmp" > "$changelog"

echo "rotate-changelog: moved ## [Unreleased] into ## [$version] - $date in $changelog."
Loading