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
79 changes: 79 additions & 0 deletions .github/scripts/changelog-section.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
#
# Prints the CHANGELOG.md section for one version, and fails when there is none.
#
# The release run reads it before building, so a version without release copy fails
# in seconds rather than once both apps are uploaded, and again in the record job,
# where the section becomes the body of the drafted github release.

import argparse
import os
import re
import sys

# `## [1.37] - 2026-08-02`, and `## [1.38]` while the date is still unknown. Not
# `###`, which belongs to whichever section it sits in
HEADING = re.compile(r"^## +\[?([^\]\s]+)\]?(?: *- *.+)?\s*$")

# `[1.37]: https://github.com/...compare/1.36...1.37` at the foot of the file:
# inside the last section, but not release copy
LINK = re.compile(r"^\[[^\]]+\]: +\S+\s*$")


def section(text, version):
"""The body under `## [<version>]`. Raises ValueError if missing or empty."""
# an optional v, so the workflow can hand its input straight over
wanted = version.strip().removeprefix("v")

found = False
collecting = False
body = []
for line in text.splitlines():
heading = HEADING.match(line)
if heading:
if collecting:
break
if heading.group(1).removeprefix("v") == wanted:
found = collecting = True
continue
if collecting and not LINK.match(line):
body.append(line)

if not found:
raise ValueError(
f"CHANGELOG.md has no '## [{wanted}]' section. Cut the Unreleased heading "
f"to '## [{wanted}]' before releasing it - that copy is the release body."
)

body = "\n".join(body).strip("\n")
if not body.strip():
raise ValueError(
f"the '## [{wanted}]' section of CHANGELOG.md is empty. A release with "
"nothing user facing in it should say so rather than say nothing."
)
return body


def main(argv=None):
parser = argparse.ArgumentParser(
description="Print the CHANGELOG.md section of one version."
)
parser.add_argument("--version", required=True, help="version to look up, e.g. 1.38")
parser.add_argument("--file", default="CHANGELOG.md", help="changelog to read")
args = parser.parse_args(argv)

try:
with open(args.file) as changelog:
print(section(changelog.read(), args.version))
except (OSError, ValueError) as reason:
# as in resolve-version.py: the annotation form only counts on stdout
if os.environ.get("GITHUB_ACTIONS"):
print(f"::error::{reason}")
else:
print(reason, file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
32 changes: 13 additions & 19 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
# Works out which version a release run builds, and refuses the runs that cannot
# name one:
#
# tag push the tag; a version input has to agree or stay empty
# dispatched off a branch the version input, which is how a release whose
# upload failed gets finished off its branch
# neither only a dry run, on the 0.0.0 in project.pbxproj
# a version input that version
# no input only a dry run, on the 0.0.0 in project.pbxproj
#
# It comes from a dispatch input rather than a tag the run was pushed on: a tag
# would be a promise made before the upload, and a version often takes more than
# one build to clear review. The workflow writes the tags afterwards instead.
#
# The shape is checked here because xcodebuild never checks it: MARKETING_VERSION
# is a free-form string to the build, so a typo would only surface when App Store
Expand All @@ -22,7 +24,7 @@
import sys

# what CFBundleShortVersionString accepts: one to three numeric parts. A leading
# v is allowed because tags are often written that way, and stripped below
# v is tolerated and stripped below, since the input is typed by hand
VERSION = re.compile(r"^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$")


Expand All @@ -44,22 +46,15 @@ def boolean(value):
raise ValueError(f"'{value}' is not true or false")


def resolve(tag, given, dry_run, log=print):
def resolve(given, dry_run, log=print):
"""The version to build, or "" for none. Raises ValueError with the reason."""
tag, given = tag.strip(), given.strip()

if tag and given and given.removeprefix("v") != tag.removeprefix("v"):
raise ValueError(
f"the version input ({given}) is not the tag this ran on ({tag}). "
"leave it blank to build the tag."
)
version = given.strip()

version = tag or given
if not version:
if not dry_run:
raise ValueError(
"nothing to take a version from. push this as a tag, dispatch it "
"on one, or fill in the version input."
"nothing to take a version from: fill in the version input, or "
"tick dry_run to build without uploading."
)
log("no version given - building the 0.0.0 in project.pbxproj")
return ""
Expand All @@ -77,8 +72,7 @@ def main(argv=None):
parser = argparse.ArgumentParser(
description="Resolve the version a release run builds."
)
parser.add_argument("--tag", default="", help="tag the run was triggered by, if any")
parser.add_argument("--input", default="", help="version input of a dispatched run")
parser.add_argument("--input", default="", help="version input of the run")
parser.add_argument(
"--dry-run",
default="false",
Expand All @@ -87,7 +81,7 @@ def main(argv=None):
args = parser.parse_args(argv)

try:
version = resolve(args.tag, args.input, boolean(args.dry_run))
version = resolve(args.input, boolean(args.dry_run))
except ValueError as reason:
return fail(str(reason))

Expand Down
Loading