From c7fcd43003504b307a53b8f20354b634fb99f25e Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 14:54:22 +0200 Subject: [PATCH 1/3] Add composer for Chronicle update release notes The body of a Chronicle dependency update pull request is published verbatim as Arc's release notes when it merges, so it has to stand on its own as a changelog entry rather than describe the update mechanically. Chronicle's release notes follow the same pull request template, so the composer reads every Chronicle release between the currently pinned version and the new one and folds each bullet into the matching section, attributed to the release it came from. Issue references are rewritten to point at Chronicle's tracker, which they would otherwise resolve against this repository. Co-Authored-By: Claude Opus 5 --- .../compose-chronicle-release-notes.py | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .github/scripts/compose-chronicle-release-notes.py 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() From 514fe9464148da96cc655084333f34afff0347ae Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 14:54:37 +0200 Subject: [PATCH 2/3] Publish composed release notes and move the CI gate out of the body The pull request body became Arc's release notes verbatim, so the verification gate table and the auto-merge verdict were being published as part of every Chronicle update release. Compose the body from the Chronicle releases the update picks up, and report the gate as a pull request comment instead, where it stays visible to reviewers without reaching the changelog. Co-Authored-By: Claude Opus 5 --- .github/workflows/chronicle-published.yml | 76 ++++++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/.github/workflows/chronicle-published.yml b/.github/workflows/chronicle-published.yml index 133bf831f..9db3813b9 100644 --- a/.github/workflows/chronicle-published.yml +++ b/.github/workflows/chronicle-published.yml @@ -59,6 +59,18 @@ 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. + - 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 +85,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 +179,45 @@ 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 + + # 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: | + if [ "${{ 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.gate.outputs.verified == 'true' && + steps.inputs.outputs.bump != 'major' env: GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} run: gh pr merge --auto --squash "${{ steps.pr.outputs.pull-request-number }}" --repo ${{ github.repository }} - - - name: Comment when review is required - if: steps.bump.outputs.changed == 'true' && 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." From 821ef929fc1fc009ae59ca9b1bb08d5b678c1dab Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 14:54:46 +0200 Subject: [PATCH 3/3] Supersede older Chronicle update pull requests Each Chronicle update pins the same three packages, so once a newer one exists an older one has nothing left to contribute - Arc accumulated several open updates at once, all of them redundant. Reconcile the open updates on every run: close any version main has already passed, keep only the newest of the rest, and gate auto-merge on being that newest one so an out-of-order run cannot merge a stale bump. Co-Authored-By: Claude Opus 5 --- .github/workflows/chronicle-published.yml | 77 ++++++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/.github/workflows/chronicle-published.yml b/.github/workflows/chronicle-published.yml index 9db3813b9..a30c2bb2d 100644 --- a/.github/workflows/chronicle-published.yml +++ b/.github/workflows/chronicle-published.yml @@ -59,8 +59,9 @@ 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. + # 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: | @@ -181,14 +182,83 @@ jobs: labels: ${{ steps.inputs.outputs.bump }} body-path: ${{ runner.temp }}/release-notes.md + # 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 + + # 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" ] + } + + 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 + } + + # 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 }} + SUPERSEDED: ${{ steps.supersede.outputs.winner != steps.pr.outputs.pull-request-number }} run: | - if [ "${{ steps.gate.outputs.verified }}" != "true" ]; then + 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." @@ -216,6 +286,7 @@ jobs: if: >- 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: