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
78 changes: 78 additions & 0 deletions .github/scripts/changelog-section.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
#
# Prints the CHANGELOG.md section for one version, and fails when there is none.
#
# The release run reads it twice: once before building, so a version dispatched
# without release copy fails in seconds rather than once both flavors are on the
# store, and once in the record job, which makes that section the body of the
# drafted github release. Being read by the release it describes is what stops it
# rotting.
#
# OpenDocument.ios has the same script against `## [1.37] - 2026-08-02` headings.

import argparse
import os
import re
import sys

# `## 4.13.0`, not `###`, which belongs to whichever section it sits in
HEADING = re.compile(r"^## +(.+?)\s*$")


def section(text, version):
"""The body under `## <version>`. Raises ValueError if it is 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:
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. v4.8.0")
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())
66 changes: 33 additions & 33 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,21 @@
# Works out which version a release run is building, and refuses the runs that
# cannot sensibly build one.
#
# There is no version number in the repository: it is the git tag, which
# app/build.gradle turns into a version name and a version code (v4.8.0 -> 4.8.0
# and 40800, two digits per part). What is left to decide is which string gradle
# is handed, and that is only interesting when the run has no tag to read:
# There is no version number in the repository: it comes in as the release run's
# `version` input, and app/build.gradle turns it into a version name and a version
# code (v4.8.0 -> 4.8.0 and 40800, two digits per part).
#
# tag push the tag, and the version input has to agree with
# it or stay empty - the apk of a run is attached to
# the release of the tag it ran on, so building
# anything else would file it there under the wrong
# version
# dispatched off a branch the version input, which is how a release whose
# upload half failed gets finished off the branch it
# was cut from
# neither only a dry run, on gradle's unversioned fallback.
# uploading that would mean uploading a version code
# the store refuses, six minutes into the run
# a version input that version
# no input only a dry run, on gradle's unversioned fallback. uploading
# that would mean uploading a version code the store refuses,
# six minutes into the run
#
# It is an input rather than the tag the run was pushed on because a tag written
# before the upload names a commit that may never ship; release.yml writes the
# tags afterwards instead.
#
# OpenDocument.ios has the same script and the same two arguments, differing only
# in the version shape it accepts.
#
# The shape is checked here rather than left to gradle, which checks it again and
# is the one that counts: a typo in a dispatched version should not cost the
Expand Down Expand Up @@ -50,22 +49,24 @@ def fail(message):
return 1


def resolve(tag, given, uploads, log=print):
"""The version to build, or "" for none. Raises ValueError with the reason."""
tag, given = tag.strip(), given.strip()
def boolean(value):
"""A workflow input as it reaches a shell: the string "true" or "false"."""
if value.strip().lower() in ("true", "1"):
return True
if value.strip().lower() in ("false", "0", ""):
return False
raise ValueError(f"'{value}' is not true or false")

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 = tag or given
def resolve(given, dry_run, log=print):
"""The version to build, or "" for none. Raises ValueError with the reason."""
version = given.strip()

if not version:
if uploads != "none":
if not dry_run:
raise ValueError(
"nothing to take a version from. push this as a v* 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 gradle's unversioned fallback")
return ""
Expand All @@ -83,17 +84,16 @@ 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(
"--uploads",
default="none",
help="what the run publishes; only 'none' may go without a version",
"--dry-run",
default="false",
help="whether the run publishes nothing; only a dry run may go without a version",
)
args = parser.parse_args(argv)

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

Expand Down
Loading
Loading