diff --git a/.github/scripts/compose-chronicle-release-notes.py b/.github/scripts/compose-chronicle-release-notes.py new file mode 100644 index 000000000..916b1d83e --- /dev/null +++ b/.github/scripts/compose-chronicle-release-notes.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Compose the pull request body for a Chronicle dependency update. + +The body doubles as Arc's release notes - `cratis/release-action` publishes the merged +pull request's body verbatim as the GitHub release - so it has to follow +`.github/pull_request_template.md` exactly: an optional short summary plus the +`Added`/`Changed`/`Fixed`/`Removed`/`Security`/`Deprecated` sections that apply, and +nothing else. Anything about CI belongs in a pull request comment, never in the body. + +Chronicle's own release notes follow the same template, so every Chronicle release picked +up by the update contributes its bullets to the matching section here, attributed to the +release it came from. Issue references are rewritten to point at Chronicle's issue tracker +rather than resolving against this repository. +""" + +import argparse +import json +import re +import subprocess +import sys + +SECTIONS = ['Added', 'Changed', 'Fixed', 'Removed', 'Security', 'Deprecated'] +PACKAGES = '`Cratis.Chronicle`, `Cratis.Chronicle.AspNetCore` and `Cratis.Chronicle.Testing`' + +HEADING = re.compile(r'^#{1,6}\s+(.*?)\s*#*\s*$') +TOP_LEVEL_BULLET = re.compile(r'^[-*]\s+') +ISSUE_REFERENCE = re.compile(r'(? target_key: + continue + if previous_key is None: + if key != target_key: + continue + elif key <= previous_key: + continue + picked.append((version, release)) + + picked.sort(key=lambda entry: version_key(entry[0])) + return picked + + +def parse_sections(body): + """The template sections of a release body, each as a list of top-level bullets.""" + sections = {name: [] for name in SECTIONS} + section = None + bullet = None + + def flush(): + nonlocal bullet + if section is not None and bullet: + sections[section].append(bullet) + bullet = None + + for line in (body or '').replace('\r\n', '\n').split('\n'): + heading = HEADING.match(line) + if heading: + flush() + title = heading.group(1).strip() + section = title if title in sections else None + continue + + if section is None: + continue + + if TOP_LEVEL_BULLET.match(line): + flush() + bullet = [line.rstrip()] + elif bullet is not None and line.strip(): + # A wrapped line or a nested bullet - it belongs to the bullet above it. + bullet.append(line.rstrip()) + else: + flush() + + flush() + return sections + + +def render_bullet(lines, version, repository): + """One inherited bullet, attributed to the Chronicle release it came from.""" + text = TOP_LEVEL_BULLET.sub('', lines[0], count=1) + rendered = '\n'.join([f'- Chronicle {version}: {text}'] + lines[1:]) + return ISSUE_REFERENCE.sub(rf'{repository}#\1', rendered) + + +def compose_summary(previous, target, picked): + """The summary line - the cohesive theme of the update, with links to dig into.""" + if previous and previous != target: + sentence = f'{PACKAGES} are updated from `{previous}` to `{target}`' + else: + sentence = f'{PACKAGES} are updated to `{target}`' + + links = [f'[{version}]({release.get("html_url")})' + for version, release in picked if release.get('html_url')] + + if not links: + return f'{sentence}.' + if len(links) == 1: + return f'{sentence}, picking up {links[0]}.' + return f'{sentence}, picking up {", ".join(links[:-1])} and {links[-1]}.' + + +def compose(previous, target, repository, picked): + """The full pull request body, in pull_request_template.md shape.""" + inherited = {name: [] for name in SECTIONS} + for version, release in picked: + for name, bullets in parse_sections(release.get('body')).items(): + inherited[name].extend(render_bullet(bullet, version, repository) for bullet in bullets) + + changed = [f'- {PACKAGES} are updated to `{target}`'] + inherited['Changed'] + + # Without the Chronicle releases to point at, the summary would only restate the single + # bullet below it - the template says to drop it in exactly that case. + body = ['# Summary', '', compose_summary(previous, target, picked)] if picked else [] + + for name in SECTIONS: + bullets = changed if name == 'Changed' else inherited[name] + if bullets: + body += ['', f'## {name}', ''] + bullets + + return '\n'.join(body).strip() + '\n' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repository', default='cratis/chronicle', help='Repository the release notes are read from.') + parser.add_argument('--previous', default='', help='Chronicle version currently pinned. Empty when unknown.') + parser.add_argument('--target', required=True, help='Chronicle version being updated to.') + parser.add_argument('--output', help='File to write the body to. Defaults to standard output.') + arguments = parser.parse_args() + + previous = arguments.previous.strip().lstrip('v') + target = arguments.target.strip().lstrip('v') + + picked = releases_in_range(fetch_releases(arguments.repository), previous, target) + if not picked: + print(f'No {arguments.repository} release notes were found for the update - ' + 'falling back to the bare version bump.', file=sys.stderr) + else: + print(f'Folding in {len(picked)} {arguments.repository} release(s): ' + f'{", ".join(version for version, _ in picked)}.', file=sys.stderr) + + body = compose(previous, target, arguments.repository, picked) + + if arguments.output: + with open(arguments.output, 'w', encoding='utf-8') as file: + file.write(body) + else: + sys.stdout.write(body) + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/chronicle-published.yml b/.github/workflows/chronicle-published.yml index 133bf831f..a30c2bb2d 100644 --- a/.github/workflows/chronicle-published.yml +++ b/.github/workflows/chronicle-published.yml @@ -59,6 +59,19 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "bump=$bump" >> "$GITHUB_OUTPUT" + # Read before the pins are rewritten - it is the lower bound of the range of Chronicle + # releases this update picks up, and it tells the supersede step which open pull + # requests main has already moved past. + - name: Read the currently pinned Chronicle version + id: pinned + run: | + pinned=$(grep -oE 'Include="Cratis\.Chronicle" Version="[^"]+"' Directory.Packages.props \ + | head -1 \ + | grep -oE 'Version="[^"]+"' \ + | cut -d'"' -f2) + echo "Currently pinned at ${pinned:-}." + echo "version=$pinned" >> "$GITHUB_OUTPUT" + - name: Update Chronicle package pins id: bump run: | @@ -73,6 +86,19 @@ jobs: git diff -- "$file" fi + # The pull request body is published verbatim as Arc's release notes when the pull + # request merges, so it is composed to follow pull_request_template.md and carries + # nothing about CI - that goes into a comment further down. + - name: Compose release notes + if: steps.bump.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} + run: | + python3 .github/scripts/compose-chronicle-release-notes.py \ + --previous "${{ steps.pinned.outputs.version }}" \ + --target "${{ steps.inputs.outputs.version }}" \ + --output "${{ runner.temp }}/release-notes.md" + - name: Setup .Net if: steps.bump.outputs.changed == 'true' uses: actions/setup-dotnet@v4 @@ -154,28 +180,115 @@ jobs: commit-message: "Update Cratis.Chronicle to ${{ steps.inputs.outputs.version }}" title: "Update Cratis.Chronicle to ${{ steps.inputs.outputs.version }}" labels: ${{ steps.inputs.outputs.bump }} - body: | - Automated update of `Cratis.Chronicle`, `Cratis.Chronicle.AspNetCore` and `Cratis.Chronicle.Testing` to `${{ steps.inputs.outputs.version }}`, triggered by a Chronicle release. + body-path: ${{ runner.temp }}/release-notes.md - ### Verification gate + # Only the newest Chronicle update is worth reviewing - each one pins the same three + # packages, so an older one has nothing left to contribute once a newer one exists. + # Runs whether or not this run produced a pull request, so that an update landing on + # main also clears out the open pull requests it made redundant. + - name: Supersede older Chronicle updates + id: supersede + env: + GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} + REPOSITORY: ${{ github.repository }} + PINNED: ${{ steps.pinned.outputs.version }} + run: | + set -euo pipefail - | Step | Result | - |------|--------| - | Build | `${{ steps.build.outcome }}` | - | Specs | `${{ steps.specs.outcome }}` | + # Whether $1 is greater than $2, semantically rather than lexically (16.10.0 > 16.9.0). + is_newer_than() { + [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] + } - ${{ (steps.gate.outputs.verified == 'true' && steps.inputs.outputs.bump != 'major') && '✅ All specs passed — auto-merge enabled.' || '⚠️ Requires human review before merging (verification failed or a major bump).' }} + close_with_comment() { + local number="$1" branch="$2" reason="$3" + echo "Closing #$number — $reason" + gh pr close "$number" --repo "$REPOSITORY" --comment "$reason" + # The pull request is what matters; a lingering branch must not fail the run. + gh api -X DELETE "repos/$REPOSITORY/git/refs/heads/$branch" >/dev/null 2>&1 || true + } - - name: Enable auto-merge - if: steps.bump.outputs.changed == 'true' && steps.pr.outputs.pull-request-number && steps.gate.outputs.verified == 'true' && steps.inputs.outputs.bump != 'major' + # Assigned rather than piped, so that a failing `gh` fails the step instead of + # silently looking like "there is nothing open to supersede". + open_updates=$(gh pr list --repo "$REPOSITORY" --state open --limit 100 \ + --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("chronicle-update/")) + | "\(.number) \(.headRefName | sub("^chronicle-update/"; "")) \(.headRefName)"') + + winner_number="" + winner_version="" + candidates=() + + while read -r number version branch; do + [ -n "${number:-}" ] || continue + + # main already carries this version or newer, so the pull request has nothing to add. + if [ -n "$PINNED" ] && ! is_newer_than "$version" "$PINNED"; then + close_with_comment "$number" "$branch" \ + "\`Cratis.Chronicle\` is already at \`$PINNED\` on \`main\`, so this update no longer changes anything. Closing it." + continue + fi + + candidates+=("$number $version $branch") + if [ -z "$winner_version" ] || is_newer_than "$version" "$winner_version"; then + winner_number="$number" + winner_version="$version" + fi + done <<< "$open_updates" + + for candidate in "${candidates[@]:-}"; do + [ -n "$candidate" ] || continue + read -r number version branch <<< "$candidate" + if [ "$number" != "$winner_number" ]; then + close_with_comment "$number" "$branch" \ + "Superseded by #$winner_number, which updates \`Cratis.Chronicle\` to \`$winner_version\` and includes everything this update carried. Closing it so only the newest Chronicle update stays open." + fi + done + + echo "winner=$winner_number" >> "$GITHUB_OUTPUT" + echo "Newest open Chronicle update: ${winner_number:-none} (${winner_version:-n/a})." + + # The verification result is CI bookkeeping, not release notes - it belongs in a + # comment so that it never reaches the published Arc release. + - name: Report the verification gate + if: steps.bump.outputs.changed == 'true' && steps.pr.outputs.pull-request-number env: GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: gh pr merge --auto --squash "${{ steps.pr.outputs.pull-request-number }}" --repo ${{ github.repository }} + SUPERSEDED: ${{ steps.supersede.outputs.winner != steps.pr.outputs.pull-request-number }} + run: | + if [ "$SUPERSEDED" = "true" ]; then + outcome="⏭️ Superseded by a newer Chronicle update — not auto-merged." + elif [ "${{ steps.gate.outputs.verified }}" != "true" ]; then + outcome="⚠️ Requires human review before merging — verification failed." + elif [ "${{ steps.inputs.outputs.bump }}" = "major" ]; then + outcome="⚠️ Requires human review before merging — major bump." + else + outcome="✅ All specs passed — auto-merge enabled." + fi + + cat > "$RUNNER_TEMP/verification-gate.md" <- + steps.bump.outputs.changed == 'true' && + steps.pr.outputs.pull-request-number && + steps.supersede.outputs.winner == steps.pr.outputs.pull-request-number && + steps.gate.outputs.verified == 'true' && + steps.inputs.outputs.bump != 'major' env: GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - gh pr comment "${{ steps.pr.outputs.pull-request-number }}" --repo ${{ github.repository }} --body \ - "This Chronicle update was **not** auto-merged. Build outcome: \`${{ steps.build.outcome }}\`, specs outcome: \`${{ steps.specs.outcome }}\`, bump: \`${{ steps.inputs.outputs.bump }}\`. Please review before merging." + run: gh pr merge --auto --squash "${{ steps.pr.outputs.pull-request-number }}" --repo ${{ github.repository }}