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
42 changes: 42 additions & 0 deletions .github/scripts/public-api.init.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Applies metalava to the modules listed in -DapiModules (for example :sdk,:sdk-native) and points each module's
// signature file at <-DapiDir>/<module name>.txt: `metalavaGenerateSignature` writes the module's public API
// there, and `metalavaCheckCompatibility` checks the module's code against the signature found there.
// Only .github/workflows/public_api.yml applies it, through --init-script, so no build file changes.
def apiModules = (System.getProperty('apiModules') ?: '').tokenize(',') as Set
def apiDir = System.getProperty('apiDir')

rootProject {
// On the root build script classpath the plugin shares a class loader with the Android Gradle Plugin it calls.
buildscript {
repositories {
gradlePluginPortal {
content {
includeGroup 'me.tylerbwong.gradle.metalava'
}
}
}
dependencies {
classpath 'me.tylerbwong.gradle.metalava:plugin:0.5.1'
}
}
}

allprojects { project ->
if (!apiModules.contains(project.path)) {
return
}
if (!apiDir) {
throw new org.gradle.api.GradleException('Pass -DapiDir=<directory> to choose where the API signatures live.')
}
['java-library', 'com.android.library'].each { pluginId ->
project.pluginManager.withPlugin(pluginId) {
project.pluginManager.apply('me.tylerbwong.gradle.metalava')
project.metalava {
filename.set(new File(apiDir, "${project.name}.txt").absolutePath)
enforceCheck.set(false)
// Public methods that return package-private types are lint findings, not compatibility breaks.
arguments.addAll('--hide', 'ReferencesHidden,HiddenTypeParameter')
}
}
}
}
142 changes: 142 additions & 0 deletions .github/scripts/public_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Keeps the metalava compatibility errors that touch the public API integrators can reach."""
import argparse
import glob
import os
import re
import sys

ERROR = re.compile(r"^(?P<path>\S+?\.txt):(?P<line>\d+): error: (?P<message>.*)$")
PACKAGE = re.compile(r"^package (?P<name>[\w.]+) \{$")
CLASS = re.compile(r"^ (?:@\S+\s+)*(?:(?:public|protected|static|final|abstract|sealed)\s+)*"
r"(?:class|interface|enum|@interface)\s+(?P<name>[\w.$]+)")
REFERENCE = re.compile(r"\bly\.count(?:\.[A-Za-z_$][\w$]*)+")
ANSI = re.compile(r"\x1b\[[0-9;]*m")


def _in_package(name, package):
"""Returns whether the class name, nested or not, is declared directly in package and not in a subpackage."""
return name.startswith(f"{package}.") and name[len(package) + 1:][:1].isupper()


class Signature:
"""One metalava signature file: its class blocks and the classes each class's signature names."""

def __init__(self, path):
"""Parses the signature file at path."""
self.blocks = []
self.references = {}
self.headers = {}
self.members = {}
package, current, start = None, None, 0
with open(path, encoding="utf-8") as handle:
lines = handle.read().splitlines()
for number, text in enumerate(lines, start=1):
package_match, class_match = PACKAGE.match(text), CLASS.match(text)
if package_match:
package = package_match.group("name")
elif class_match and package:
current, start = f"{package}.{class_match.group('name')}", number
self.references[current] = set(REFERENCE.findall(text))
self.headers[current] = text.strip()
self.members[current] = []
elif current and text == " }":
self.blocks.append((start, number, current))
current = None
elif current:
self.references[current].update(REFERENCE.findall(text))
if text.strip():
self.members[current].append(text.strip())

def class_at(self, line):
"""Returns the class whose block contains line, or None for a line outside every class."""
for start, end, name in self.blocks:
if start <= line <= end:
return name
return None


def reachable(signatures, root_packages):
"""Returns the root package classes, the classes their signatures name, transitively, and nested classes."""
references = {}
for signature in signatures:
for name, named in signature.references.items():
references.setdefault(name, set()).update(named)
classes = set(references)
pending = [name for name in classes if any(_in_package(name, root) for root in root_packages)]
found = set()
while pending:
name = pending.pop()
if name in found:
continue
found.add(name)
pending.extend(other for other in classes if other.startswith(f"{name}."))
pending.extend(reference for reference in references.get(name, ()) if reference in classes)
return found


def added(base_dir, head_dir, root_packages):
"""Returns "class: declaration" lines for the public API the head signatures add to the base ones."""
heads = {os.path.basename(path): Signature(path) for path in sorted(glob.glob(os.path.join(head_dir, "*.txt")))}
public = reachable(heads.values(), root_packages)
lines = []
for name, head in heads.items():
base_path = os.path.join(base_dir, name)
if not os.path.exists(base_path):
lines.append(f"{name[:-len('.txt')]}: a new module, all of its public API is new")
continue
base = Signature(base_path)
for owner in sorted(cls for cls in head.members if cls in public):
known = set(base.members.get(owner, ()))
if owner not in base.members:
lines.append(f"{owner}: {head.headers[owner]}")
lines.extend(f"{owner}: {member}" for member in head.members[owner] if member not in known)
return lines


def main():
"""Reads a metalava check log and prints the compatibility errors that touch reachable public API."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root-package", action="append", required=True,
help="a package whose classes are public API; repeat it for each root package")
parser.add_argument("--signatures", required=True,
help="every module's base signatures: one module's public API can name another's classes")
parser.add_argument("--module-dir",
help="the module's directory: metalava names this pull request's signature relative to it")
parser.add_argument("--added", metavar="HEAD_DIR",
help="print what the head signatures in this directory add to --signatures")
parser.add_argument("log", nargs="?", help="the metalava compatibility check log")
arguments = parser.parse_args()
if arguments.added:
for line in added(arguments.signatures, arguments.added, set(arguments.root_package)):
print(line)
return
if not arguments.module_dir or not arguments.log:
parser.error("--module-dir and the log are needed unless --added is given")
base = [Signature(path) for path in sorted(glob.glob(os.path.join(arguments.signatures, "*.txt")))]
if not base:
sys.exit(f"No base signatures in {arguments.signatures}.")
public = reachable(base, set(arguments.root_package))
signatures = {}
found = 0
with open(arguments.log, encoding="utf-8", errors="replace") as handle:
for raw in handle:
match = ERROR.match(ANSI.sub("", raw.strip()))
if not match:
continue
found += 1
path = os.path.join(arguments.module_dir, match.group("path"))
message = match.group("message")
if path not in signatures:
signatures[path] = Signature(path)
owner = signatures[path].class_at(int(match.group("line")))
if owner is None or owner in public:
print(message)
else:
# A class that is public only so other SDK packages can call it is not integrator API.
print(f"Ignored, {owner} is not reachable from the public API: {message}", file=sys.stderr)
if found == 0:
sys.exit("No metalava error lines found in the log.")


if __name__ == "__main__":
main()
37 changes: 37 additions & 0 deletions .github/scripts/public_api_record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Compares the public API this run extracted with the record committed in the repository. On a difference it lists the
# changed lines in the job summary, copies the extracted API to $RUNNER_TEMP/public-api-record/ under the record's path,
# for the workflow to upload, and fails: a deliberate change is committed together with a changelog line.
# $@ pairs "<extracted file>=<record path>"
set -euo pipefail
out="$RUNNER_TEMP/public-api-record"
status=0
current="$RUNNER_TEMP/public-api-current.txt"
recorded="$RUNNER_TEMP/public-api-recorded.txt"
for pair in "$@"; do
extracted=${pair%%=*}
record=${pair#*=}
# Line endings do not count: .NET writes CRLF on Windows, and a Windows checkout can give the record CRLF too.
tr -d '\r' < "$extracted" > "$current"
if [ -f "$record" ] && tr -d '\r' < "$record" | cmp -s - "$current"; then
echo "The public API is the one $record records."
continue
fi
status=1
mkdir -p "$out/$(dirname "$record")"
cp "$current" "$out/$record"
{
printf '### The public API differs from %s\n\n' "$record"
printf "Commit \`%s\` from the \`public-api-record\` artifact of this run, with a changelog line for the change.\n\n" "$record"
if [ -f "$record" ]; then
printf '```diff\n'
tr -d '\r' < "$record" > "$recorded"
diff -u "$recorded" "$current" | tail -n +3 | head -n 300 || true
printf '```\n\n'
else
printf 'There is no record yet.\n\n'
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "::error title=Public API record::The public API differs from $record. Commit the file from this run's public-api-record artifact and add a changelog line."
done
exit "$status"
46 changes: 46 additions & 0 deletions .github/scripts/public_api_report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Writes the report of a pull request's public API changes to the job summary, and to the step output "report", which
# the workflow's comment job posts on the pull request. The comment lists at most 200 lines per section.
# $1 a file with one removed or changed declaration per line
# $2 a file with one declaration per line that the pull request adds to the public API
set -euo pipefail
breaking=$1
added=$2
marker='<!-- countly-public-api -->'

# Prints a section titled $1 and explained by $2 with the lines of file $3, at most $4 of them when $4 is not 0.
section() {
local count
count=$(grep -c . "$3" || true)
[ "$count" -gt 0 ] || return 0
printf '**%s: %s.** %s\n\n```\n' "$1" "$count" "$2"
if [ "$4" -gt 0 ]; then
head -n "$4" "$3"
else
cat "$3"
fi
printf '```\n'
if [ "$4" -gt 0 ] && [ "$count" -gt "$4" ]; then
printf '\n%s more in the job summary.\n' "$((count - $4))"
fi
printf '\n'
}

# Prints the whole report, with at most $1 lines per section when $1 is not 0.
report() {
printf '%s\n### Public API changes\n\n' "$marker"
if ! grep -q . "$breaking" && ! grep -q . "$added"; then
printf 'No public API change.\n'
return
fi
section 'Removed or changed' 'Each needs a CHANGELOG entry that marks it as a breaking change.' "$breaking" "$1"
section 'Added' 'Check that each one is meant to be public.' "$added" "$1"
}

report 0 >> "$GITHUB_STEP_SUMMARY"
delimiter="EOF_$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
{
echo "report<<$delimiter"
report 200
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
52 changes: 52 additions & 0 deletions .github/workflows/api-changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Public API changelog

# A pull request that changes or removes a record of the public API must also change the CHANGELOG.md that API_FILES
# pairs it with, so that the release notes tell integrators about every such change. A record the pull request adds needs
# no line. A path ending in "/" stands for every file under it. The API check keeps each record in step with the SDK.
on:
pull_request:
branches: [master, staging]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
pull-requests: read # to list the files the pull request changes

jobs:
changelog:
name: A public API change comes with a CHANGELOG line
runs-on: ubuntu-24.04
steps:
- name: Check the files the pull request changes
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
API_FILES: sdk-java/api/sdk-java.txt=CHANGELOG.md sdk-java-ui/api/sdk-java-ui.txt=sdk-java-ui/CHANGELOG.md
run: |
changed=$(gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[] | select(.status != "added") | .filename')
changes() {
case "$1" in
*/) printf '%s\n' "$changed" | awk -v prefix="$1" 'index($0, prefix) == 1 { found = 1 } END { exit !found }' ;;
*) printf '%s\n' "$changed" | grep -qxF "$1" ;;
esac
}
recorded_changed=no
for pair in $API_FILES; do
record=${pair%%=*}
changelog=${pair#*=}
if changes "$record"; then
recorded_changed=yes
if ! changes "$changelog"; then
echo "::error::This pull request changes $record, which records the public API, but not $changelog. Add a line that tells integrators what changed."
exit 1
fi
fi
done
if [ "$recorded_changed" = yes ]; then
echo "The recorded public API changes, and the CHANGELOG changes with it."
else
echo "This pull request changes no recorded public API."
fi
Loading
Loading