Skip to content
Merged
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
196 changes: 196 additions & 0 deletions .github/scripts/compose-chronicle-release-notes.py
Original file line number Diff line number Diff line change
@@ -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'(?<![\w/#])#(\d+)\b')


def version_key(version):
"""Sort key for a semantic version, ordering a prerelease before its release."""
core, _, prerelease = version.lstrip('v').partition('-')
parts = [int(piece) if piece.isdigit() else 0 for piece in core.split('.')]
parts = (parts + [0, 0, 0])[:3]
return (parts[0], parts[1], parts[2], 0 if prerelease else 1, prerelease)


def fetch_releases(repository):
"""All published releases of the repository, newest first. Empty when they can't be read."""
try:
result = subprocess.run(
['gh', 'api', '-H', 'Accept: application/vnd.github+json',
f'repos/{repository}/releases?per_page=100'],
capture_output=True, text=True, timeout=120)
except (OSError, subprocess.SubprocessError) as error:
print(f'Could not reach the GitHub API for {repository}: {error}', file=sys.stderr)
return []

if result.returncode != 0:
print(f'Could not read releases for {repository}: {result.stderr.strip()}', file=sys.stderr)
return []

return json.loads(result.stdout)


def releases_in_range(releases, previous, target):
"""The published releases that the update picks up - everything after previous, up to target.

Without a known previous version there is no lower bound to work from, so the range
narrows to the target release alone rather than folding in the entire history.
"""
target_key = version_key(target)
previous_key = version_key(previous) if previous else None

picked = []
for release in releases:
if release.get('draft') or release.get('prerelease'):
continue
version = (release.get('tag_name') or '').lstrip('v')
if not version:
continue
key = version_key(version)
if key > 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()
145 changes: 129 additions & 16 deletions .github/workflows/chronicle-published.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<unknown>}."
echo "version=$pinned" >> "$GITHUB_OUTPUT"

- name: Update Chronicle package pins
id: bump
run: |
Expand All @@ -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
Expand Down Expand Up @@ -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" <<EOF
### Verification gate

| Step | Result |
|------|--------|
| Build | \`${{ steps.build.outcome }}\` |
| Specs | \`${{ steps.specs.outcome }}\` |
| Bump | \`${{ steps.inputs.outputs.bump }}\` |

$outcome
EOF

- 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')
gh pr comment "${{ steps.pr.outputs.pull-request-number }}" \
--repo "${{ github.repository }}" \
--body-file "$RUNNER_TEMP/verification-gate.md"

- name: Enable auto-merge
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:
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 }}
Loading