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
76 changes: 76 additions & 0 deletions .github/scripts/changelog-section.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/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 missing release copy costs seconds
# rather than a version, and again in the record job, where it becomes the body of
# the drafted github release. Being read is what stops the changelog 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 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())
60 changes: 27 additions & 33 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,15 @@
# 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). 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 its tags afterwards.
#
# 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
# Only a dry run may go without one, on gradle's unversioned fallback: uploading
# that would mean a version code the store refuses. OpenDocument.ios has the same
# script and the same two arguments, differing only in the 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 +43,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 +78,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