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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ABI references must retain the Kotlin generator's exact trailing blank lines.
api/*.api whitespace=-blank-at-eof
api-full/*.api whitespace=-blank-at-eof
43 changes: 43 additions & 0 deletions .github/actions/setup-build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Set up library build
description: Install the JVM runtimes and restore Gradle and Kotlin Native caches.
inputs:
cache-encryption-key:
description: Encryption key for the Gradle configuration cache
required: false
gradle-cache-read-only:
description: Restore Gradle User Home without saving publication-job state
default: 'false'
target-profile:
description: Separate cached Native and configuration state for normal and full targets
default: default
runs:
using: composite
steps:
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: |
17
25
- uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ inputs.gradle-cache-read-only }}
cache-encryption-key: ${{ inputs.cache-encryption-key }}
- uses: actions/cache@v6
with:
path: ~/.konan
key: ${{ runner.os }}-${{ runner.arch }}-konan-${{ inputs.target-profile }}-${{ hashFiles('build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties', 'gradle/*.versions.toml') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-konan-${{ inputs.target-profile }}-
${{ runner.os }}-${{ runner.arch }}-konan-
# setup-gradle v6 caches Gradle User Home; persist project entries explicitly.
# GRADLE_ENCRYPTION_KEY is exported by setup-gradle and Gradle encrypts these files.
- name: Cache encrypted configuration state
if: ${{ inputs.cache-encryption-key != '' }}
uses: actions/cache@v6
with:
path: |
.gradle/configuration-cache
consumer/.gradle/configuration-cache
key: ${{ runner.os }}-${{ runner.arch }}-configuration-${{ inputs.target-profile }}-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-${{ github.sha }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-configuration-${{ inputs.target-profile }}-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-
49 changes: 49 additions & 0 deletions .github/scripts/release-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Resolve release, snapshot and GitHub-only release-candidate versions."""

import os
import re
import subprocess
from pathlib import Path

CORE = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
STABLE = re.compile(CORE)
RC = re.compile(rf"({CORE})-rc\.(0|[1-9][0-9]*)")
VERSION = re.compile(rf"{CORE}(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?")


def resolve(tags, ref_type, ref_name):
if ref_type == "tag":
if not ref_name.startswith("v"):
raise ValueError("Release tags must start with v")
version = ref_name[1:]
elif ref_type == "branch" and ref_name == "main":
stable = [tuple(map(int, tag[1:].split("."))) for tag in tags
if tag.startswith("v") and STABLE.fullmatch(tag[1:])]
latest = max(stable, default=(0, 0, 0))
version = f"{latest[0]}.{latest[1]}.{latest[2] + 1}-SNAPSHOT"
else:
raise ValueError("Releases require a version tag; automatic snapshots use main")
match = VERSION.fullmatch(version)
if not match or (match[1] and any(part.isascii() and part.isdigit() and len(part) > 1 and part.startswith("0")
for part in match[1].split("."))):
raise ValueError(f"Invalid release version: {version}")
return {
"version": version,
"is_snapshot": str(version.endswith("-SNAPSHOT")).lower(),
"is_prerelease": str(match[1] is not None).lower(),
"test_release": str(RC.fullmatch(version) is not None).lower(),
"release_tag": f"v{version}",
}


if __name__ == "__main__":
tags = subprocess.check_output(["git", "tag", "--list"], text=True).splitlines()
try:
values = resolve(tags, os.environ["GITHUB_REF_TYPE"], os.environ["GITHUB_REF_NAME"])
except ValueError as error:
raise SystemExit(str(error)) from error
values["revision"] = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
output.writelines(f"{key}={value}\n" for key, value in values.items())
print(f"Resolved {values['version']}; GitHub-only RC: {values['test_release']}")
45 changes: 45 additions & 0 deletions .github/scripts/test-release-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Exercise exact release-tag routing and automatic main snapshots."""

import importlib.util
import unittest
from pathlib import Path

spec = importlib.util.spec_from_file_location("versions", Path(__file__).with_name("release-version.py"))
versions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(versions)


class ReleaseVersionTest(unittest.TestCase):
def test_main_snapshot_ignores_prerelease_tags(self):
tags = ["v1.9.0", "v1.10.0", "v2.0.0-rc.9"]
result = versions.resolve(tags, "branch", "main")
self.assertEqual("1.10.1-SNAPSHOT", result["version"])
self.assertEqual("true", result["is_snapshot"])
self.assertEqual("false", result["test_release"])
self.assertEqual("0.0.1-SNAPSHOT", versions.resolve([], "branch", "main")["version"])

def test_rc_tag_is_used_exactly_without_incrementing(self):
result = versions.resolve(["v1.3.0", "v1.3.1-rc.10"], "tag", "v1.3.1-rc.2")
self.assertEqual("1.3.1-rc.2", result["version"])
self.assertEqual("v1.3.1-rc.2", result["release_tag"])
self.assertEqual("true", result["test_release"])
self.assertEqual("true", result["is_prerelease"])
self.assertEqual("false", result["is_snapshot"])

def test_stable_tag_routes_to_central(self):
result = versions.resolve(["v1.3.0"], "tag", "v1.4.0")
self.assertEqual("1.4.0", result["version"])
self.assertEqual("false", result["test_release"])
self.assertEqual("false", result["is_prerelease"])
self.assertEqual("false", result["is_snapshot"])

def test_invalid_tags_and_non_main_branches_fail(self):
for tag in ["v01.3.1", "v1.3.1-rc.01", "v1.3.1-rc..1", "v1.3.1-", "1.3.1"]:
with self.subTest(tag=tag), self.assertRaises(ValueError):
versions.resolve([], "tag", tag)
with self.assertRaisesRegex(ValueError, "require a version tag"):
versions.resolve([], "branch", "feature")


if __name__ == "__main__":
unittest.main()
41 changes: 41 additions & 0 deletions .github/scripts/test-verification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Checks that the CI report guard rejects green builds without passing tests."""

import importlib.util
import tempfile
import unittest
from pathlib import Path

spec = importlib.util.spec_from_file_location("reports", Path(__file__).with_name("verify-test-results.py"))
reports = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reports)


class ReportVerificationTest(unittest.TestCase):
def test_missing_reports(self):
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(ValueError, "No test reports"):
reports.verify(directory)

def test_rejects_empty_skipped_and_failing_results(self):
examples = [
'<testsuite tests="0"/>',
'<testsuite tests="1" skipped="1"><testcase><skipped/></testcase></testsuite>',
'<testsuite tests="1"><testcase><failure/></testcase></testsuite>',
'<testsuite tests="1" errors="1"><testcase/></testsuite>',
]
for xml in examples:
with self.subTest(xml=xml), tempfile.TemporaryDirectory() as directory:
Path(directory, "TEST-example.xml").write_text(xml)
with self.assertRaises(ValueError):
reports.verify(directory)

def test_accepts_executed_tests_alongside_skipped_tests(self):
with tempfile.TemporaryDirectory() as directory:
Path(directory, "TEST-example.xml").write_text(
'<testsuite tests="2" skipped="1"><testcase/><testcase><skipped/></testcase></testsuite>'
)
reports.verify(directory)


if __name__ == "__main__":
unittest.main()
10 changes: 10 additions & 0 deletions .github/scripts/verify-consumer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# The fixture deliberately uses no KBuild conventions or build platform.
set -euo pipefail
version=${1:?usage: verify-consumer.sh VERSION}
repository="$(pwd)/build/staging-repo"
./gradlew -p consumer run "-Pconsumer.repository=$repository" "-Pconsumer.version=$version"
./gradlew -p consumer run "-Pconsumer.repository=$repository" "-Pconsumer.version=$version" -Pconsumer.pomOnly=true
if [[ $# -gt 1 ]]; then
./gradlew -p consumer run "-Pconsumer.version=$version" "-Pconsumer.githubRepository=$2"
fi
80 changes: 80 additions & 0 deletions .github/scripts/verify-publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Verify the complete staged KMP publication before independent consumer resolution."""

import argparse
import hashlib
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from zipfile import ZipFile


def verify(repository, version, full=False, github=False):
settings = Path(__file__).resolve().parents[2] / "settings.gradle.kts"
library = re.search(r'rootProject\.name\s*=\s*"([^"\n]+)"', settings.read_text()).group(1)
repository = Path(repository).resolve()
base = repository if github else repository / "com/eignex"
targets = ["jvm", "linuxx64"]
if full:
targets += ["js", "wasm-js", "wasm-wasi", "linuxarm64", "macosarm64", "mingwx64", "iosx64", "iosarm64", "iossimulatorarm64"]
modules = [library] + [library + "-" + target for target in targets]
for module in modules:
directory = base if github else base / module / version
stem = f"{module}-{version}"
metadata = directory / f"{stem}.module"
pom = directory / f"{stem}.pom"
data = json.loads(metadata.read_text())
ET.parse(pom)
if module == library:
published = {variant["available-at"]["module"] for variant in data["variants"] if "available-at" in variant}
if published != set(modules[1:]):
raise ValueError(f"Publication targets do not match the requested profile: {sorted(published)}")
if "kbuild-platform" in metadata.read_text() or "kbuild-platform" in pom.read_text():
raise ValueError(f"Build platform leaked into {module}")
for variant in data["variants"]:
redirect = variant.get("available-at")
if redirect and not (directory / redirect["url"]).is_file():
raise ValueError(f"Missing target metadata: {redirect['url']}")
for artifact in variant.get("files", []):
path = directory / artifact["url"]
if not path.is_file() or path.stat().st_size != artifact["size"]:
raise ValueError(f"Missing or truncated artifact: {path}")
if hashlib.sha256(path.read_bytes()).hexdigest() != artifact["sha256"]:
raise ValueError(f"Artifact checksum mismatch: {path}")
for dependency in variant.get("dependencies", []):
if not any(dependency.get("version", {}).get(key) for key in ("requires", "strictly", "prefers")):
raise ValueError(f"Versionless dependency in {module}: {dependency}")
for classifier in ("sources", "javadoc"):
with ZipFile(directory / f"{stem}-{classifier}.jar") as archive:
if not any(name != "META-INF/MANIFEST.MF" and not name.endswith("/") for name in archive.namelist()):
raise ValueError(f"Empty {classifier} artifact for {module}")
if module == library + "-jvm":
for variant in data["variants"]:
if (variant.get("attributes", {}).get("org.gradle.category") == "library"
and variant["attributes"].get("org.gradle.usage") in ("java-api", "java-runtime")):
if variant["attributes"].get("org.gradle.jvm.version") != 17:
raise ValueError("Published JVM requirement must remain Java 17")
with ZipFile(directory / f"{stem}.jar") as archive:
for name in archive.namelist():
if name.endswith(".class") and int.from_bytes(archive.read(name)[6:8], "big") > 61:
raise ValueError(f"Bytecode above Java 17: {name}")
if github:
checksums = dict(line.split(" ", 1)[::-1] for line in (base / "SHA256SUMS").read_text().splitlines())
files = {path.name for path in base.iterdir() if path.is_file() and path.name != "SHA256SUMS"}
if set(checksums) != files:
raise ValueError("GitHub checksum manifest does not cover every release asset")
for name, digest in checksums.items():
if hashlib.sha256((base / name).read_bytes()).hexdigest() != digest:
raise ValueError(f"GitHub asset checksum mismatch: {name}")
print(f"Verified {len(modules)} complete publications at {version}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("repository")
parser.add_argument("version")
parser.add_argument("--full", action="store_true", help="Require the complete release target set")
parser.add_argument("--github", action="store_true", help="Verify flat GitHub Release assets and their checksum manifest")
args = parser.parse_args()
verify(args.repository, args.version, args.full, args.github)
33 changes: 33 additions & 0 deletions .github/scripts/verify-test-results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Reject missing, empty, fully skipped, or failing expected test targets."""

import sys
import xml.etree.ElementTree as ET
from pathlib import Path


def verify(directory):
reports = sorted(Path(directory).glob("TEST-*.xml"))
if not reports:
raise ValueError(f"No test reports in {directory}")
executed = 0
for report in reports:
suite = ET.parse(report).getroot()
cases = list(suite.iter("testcase"))
if (int(suite.get("failures", 0)) or int(suite.get("errors", 0))
or any(case.find("failure") is not None or case.find("error") is not None for case in cases)):
raise ValueError(f"Failing test report: {report}")
executed += sum(case.find("skipped") is None for case in cases)
if not executed:
raise ValueError(f"No executed tests in {directory}")
print(f"Verified {executed} tests in {directory}")


if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: verify-test-results.py DIRECTORY...")
try:
for directory in sys.argv[1:]:
verify(directory)
except (ValueError, ET.ParseError) as error:
sys.exit(str(error))
Loading
Loading