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
14 changes: 14 additions & 0 deletions .github/release-contract/java-ui.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"schema": 1,
"coordinates": "ly.count.sdk:java-ui",
"pomDependencies": [
"ly.count.sdk:java:{version}:compile",
"org.json:json:20250517:runtime",
"org.openjfx:javafx-controls:21.0.5:runtime",
"org.openjfx:javafx-web:21.0.5:runtime"
],
"moduleVariantAttributes": {
"org.gradle.jvm.version": 17
},
"maxClassFileMajor": 61
}
12 changes: 12 additions & 0 deletions .github/release-contract/java.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema": 1,
"coordinates": "ly.count.sdk:java",
"pomDependencies": [
"com.google.code.findbugs:jsr305:3.0.2:runtime",
"org.json:json:20250517:runtime"
],
"moduleVariantAttributes": {
"org.gradle.jvm.version": 8
},
"maxClassFileMajor": 52
}
2 changes: 2 additions & 0 deletions .github/release/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
consumers/maven/target/
22 changes: 22 additions & 0 deletions .github/release/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Checks on the tag commit: its release branch and the required status checks."""

import subprocess


def branch_problems(commit, branch, repo_root, runner=subprocess.run):
"""Problems unless the commit is an ancestor of origin/<branch>."""
result = runner(["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, f"origin/{branch}"], capture_output=True)
return [] if result.returncode == 0 else [f"{commit} is not on origin/{branch}; the tag must point to that branch"]


def missing_checks(check_runs, required):
"""Required check names that did not succeed on the commit, from the GitHub check-runs API response: a name without
any run, or with any run that did not succeed or has not finished. A commit can carry several runs of one check,
for example one from the push to the release branch and one from a pull request whose head it is, which tests a
merge with another branch; each of them must have passed."""
problems = []
for name in required:
conclusions = [run.get("conclusion") for run in check_runs.get("check_runs", []) if run["name"] == name]
if not conclusions or any(conclusion != "success" for conclusion in conclusions):
problems.append(name)
return problems
33 changes: 33 additions & 0 deletions .github/release/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"repository": "Countly/countly-sdk-java",
"publicBaseUrl": { "production": "https://maven.countly.com/", "test": "https://maven-test.countly.com/" },
"centralBaseUrl": "https://repo1.maven.org/maven2/",
"publishedModules": ":sdk-java,:sdk-java-ui",
"platformSpecificGroups": ["org.openjfx"],
"requiredChecks": ["Build and Test", "Test on ubuntu-latest", "Test on windows-latest", "OSV scan (all modules)", "Analyze (java)"],
"tagRules": [
{ "tag": "{version}", "branch": "staging", "artifacts": ["java", "java-ui"] }
],
"artifacts": {
"java": {
"group": "ly.count.sdk", "artifact": "java", "module": ":sdk-java",
"contract": ".github/release-contract/java.json", "consumerJava": 8,
"versionSources": [
{ "file": "gradle.properties", "regex": "^VERSION_NAME=(.+)$" },
{ "file": "build.gradle", "regex": "ext\\.CLY_VERSION = \"([^\"]+)\"" },
{ "file": "sdk-java/src/main/java/ly/count/sdk/java/Config.java", "regex": "protected String sdkVersion = \"([^\"]+)\"" }
],
"changelog": "CHANGELOG.md"
},
"java-ui": {
"group": "ly.count.sdk", "artifact": "java-ui", "module": ":sdk-java-ui",
"contract": ".github/release-contract/java-ui.json", "consumerJava": 17,
"versionSources": [
{ "file": "gradle.properties", "regex": "^VERSION_NAME=(.+)$" },
{ "file": "build.gradle", "regex": "ext\\.CLY_VERSION = \"([^\"]+)\"" },
{ "file": "sdk-java/src/main/java/ly/count/sdk/java/Config.java", "regex": "protected String sdkVersion = \"([^\"]+)\"" }
],
"changelog": "CHANGELOG.md"
}
}
}
50 changes: 50 additions & 0 deletions .github/release/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Commands for the clean projects in .github/release/consumers that build against a staged release."""

from pathlib import Path

GRADLE_PROJECT = ".github/release/consumers/gradle"
MAVEN_POM = ".github/release/consumers/maven/pom.xml"
GRADLE_FLAGS = ["--no-daemon", "--no-configuration-cache", "--stacktrace"]
PROBE_CLASS = "ly.count.consumer.Probe"
REPOSITORY_ID = "countly"


def gradle_command(gradlew, repository, artifact, version):
"""Builds the Gradle consumer against one artifact version and installs it with its runtime classpath."""
return [
gradlew, "-p", GRADLE_PROJECT, *GRADLE_FLAGS, "clean", "installDist",
f"-PcountlyRepository={repository}", f"-PcountlyDependency={artifact.coordinates}:{version}",
f"-PcountlyRelease={artifact.consumer_java}", f"-PcountlyProbe={artifact.artifact}",
]


def maven_command(mvn, repository, artifact, version, local_repository):
"""Compiles the Maven consumer against one artifact version, with an empty local repository so nothing is reused."""
return [
mvn, "-B", "-f", MAVEN_POM, "clean", "compile",
f"-Dcountly.repository={repository}", f"-Dcountly.artifact={artifact.artifact}", f"-Dcountly.version={version}",
f"-Dcountly.release={artifact.consumer_java}", f"-Dmaven.repo.local={local_repository}",
]


def smoke_command(java_home, repo_root):
"""Runs the probe the Gradle consumer installed, on the given Java runtime, with the installed runtime classpath."""
java = Path(java_home) / "bin" / "java"
classpath = Path(repo_root) / GRADLE_PROJECT / "build/install/countly-consumer/lib/*"
return [str(java), "-cp", str(classpath), PROBE_CLASS]


def maven_source_problems(local_repository, plan, consumed):
"""Problems when Maven did not take the consumed artifact, and every other artifact of the plan it downloaded, from
the staged release; Maven records the source repository of every downloaded file in _remote.repositories."""
problems = []
for artifact in plan.artifacts:
record = Path(local_repository) / artifact.folder(plan.version) / "_remote.repositories"
if not record.is_file():
if artifact == consumed:
problems.append(f"Maven did not download {artifact.coordinates}:{plan.version}")
continue
sources = [line.split(">", 1)[1] for line in record.read_text(encoding="utf-8").splitlines() if ">" in line and not line.startswith("#")]
if not sources or any(source != f"{REPOSITORY_ID}=" for source in sources):
problems.append(f"Maven resolved {artifact.coordinates}:{plan.version} from another repository than the staged release")
return problems
20 changes: 20 additions & 0 deletions .github/release/consumers/gradle/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
id 'java'
id 'application'
}

def probe = findProperty('countlyProbe')

sourceSets.main.java.srcDirs = ["../probes/${probe}"]

tasks.withType(JavaCompile).configureEach {
options.release = Integer.parseInt(findProperty('countlyRelease'))
}

dependencies {
implementation findProperty('countlyDependency')
}

application {
mainClass = 'ly.count.consumer.Probe'
}
16 changes: 16 additions & 0 deletions .github/release/consumers/gradle/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
def countlyRepository = gradle.startParameter.projectProperties['countlyRepository']

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
exclusiveContent {
forRepository {
maven { url = countlyRepository }
}
filter { includeGroupByRegex 'ly\\.count\\..*' }
}
mavenCentral()
}
}

rootProject.name = 'countly-consumer'
39 changes: 39 additions & 0 deletions .github/release/consumers/maven/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ly.count.consumer</groupId>
<artifactId>countly-consumer</artifactId>
<version>1</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>${countly.release}</maven.compiler.release>
</properties>

<repositories>
<repository>
<id>countly</id>
<url>${countly.repository}</url>
</repository>
</repositories>

<dependencies>
<dependency>
<groupId>ly.count.sdk</groupId>
<artifactId>${countly.artifact}</artifactId>
<version>${countly.version}</version>
</dependency>
</dependencies>

<build>
<sourceDirectory>${project.basedir}/../probes/${countly.artifact}</sourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ly.count.consumer;

import ly.count.sdk.java.ui.CountlyWebView;
import ly.count.sdk.java.ui.JavaFxContentDisplay;

/**
* Compiles against the UI artifact the way an integrator would.
*/
public class Probe {
/**
* Reads a setting of the UI artifact and names its content display class.
*
* @param args unused
*/
public static void main(String[] args) {
System.out.println("widgets within app " + CountlyWebView.isShowingWidgetsWithinApp() + ", display " + JavaFxContentDisplay.class.getName());
}
}
20 changes: 20 additions & 0 deletions .github/release/consumers/probes/java/ly/count/consumer/Probe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ly.count.consumer;

import ly.count.sdk.java.Config;
import ly.count.sdk.java.Countly;

/**
* Loads the core SDK the way an integrator would, without contacting a server.
*/
public class Probe {
/**
* Creates a configuration and reads the shared instance; a class the runtime cannot load stops here.
*
* @param args unused
*/
public static void main(String[] args) {
Config config = new Config("https://consumer.invalid", "consumer-probe");
System.out.println("Countly " + config.getSdkVersion() + " loaded on Java " + System.getProperty("java.version")
+ ", instance " + Countly.instance().getClass().getName() + ", initialized " + Countly.isInitialized());
}
}
126 changes: 126 additions & 0 deletions .github/release/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Compares what a staged artifact promises integrators with its committed contract file."""

import io
import json
import zipfile
import xml.etree.ElementTree as ElementTree
from pathlib import Path

POM_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"}
JVM_VERSION = "org.gradle.jvm.version"
RELEASE_VERSION = "{version}"


def _child(element, tag, default=""):
return (element.findtext(f"m:{tag}", default, POM_NAMESPACE) or default).strip()


def pom_dependencies(pom):
"""Sorted group:artifact:version:scope strings of the POM's direct dependencies."""
root = ElementTree.fromstring(pom)
return sorted(
f"{_child(d, 'groupId')}:{_child(d, 'artifactId')}:{_child(d, 'version')}:{_child(d, 'scope', 'compile')}"
for d in root.findall("m:dependencies/m:dependency", POM_NAMESPACE)
)


def release_dependencies(dependencies, group, version):
"""Dependencies with the version of the release itself written as {version} for artifacts of the same group (java-ui
depends on java of its own release), so a contract holds for every release."""
shown = []
for dependency in dependencies:
parts = dependency.split(":")
if parts[0] == group and parts[2] == version:
parts[2] = RELEASE_VERSION
shown.append(":".join(parts))
return shown


def module_variants(module):
"""Library variants (not sources or javadoc) of a Gradle module file."""
return [variant for variant in json.loads(module).get("variants", []) if variant.get("attributes", {}).get("org.gradle.category") == "library"]


def module_dependencies(module):
"""Sorted group:module:version strings declared by the library variants of a Gradle module file."""
found = set()
for variant in module_variants(module):
for dependency in variant.get("dependencies", []):
version = dependency.get("version", {})
number = version.get("requires") or version.get("strictly") or version.get("prefers") or ""
found.add(f"{dependency['group']}:{dependency['module']}:{number}")
return sorted(found)


def max_class_major(jar):
"""Highest class-file major version in a jar (52 is Java 8), ignoring multi-release folders."""
highest = 0
with zipfile.ZipFile(io.BytesIO(jar)) as archive:
for name in archive.namelist():
if name.endswith(".class") and not name.startswith("META-INF/versions/"):
head = archive.read(name)[:8]
if len(head) == 8 and head[:4] == b"\xca\xfe\xba\xbe":
highest = max(highest, (head[6] << 8) | head[7])
return highest


def _common(variants, key):
"""The attribute value all variants share (None when absent), or a note that they differ."""
values = {json.dumps(variant.get("attributes", {}).get(key)) for variant in variants}
if len(values) == 1:
return json.loads(values.pop())
return None if not values else "differs between variants"


def actual_contract(staging_dir, artifact, version):
"""The contract the staged files of one artifact version fulfil, in the shape of the contract files (schema 1)."""
folder = Path(staging_dir) / artifact.folder(version)
base = artifact.base_name(version)
return {
"schema": 1,
"coordinates": artifact.coordinates,
"pomDependencies": release_dependencies(pom_dependencies((folder / f"{base}.pom").read_bytes()), artifact.group, version),
"moduleVariantAttributes": {JVM_VERSION: _common(module_variants((folder / f"{base}.module").read_bytes()), JVM_VERSION)},
"maxClassFileMajor": max_class_major((folder / f"{base}.jar").read_bytes()),
}


def compare(expected, actual):
"""Differences between the committed contract and the staged artifact, as readable problems."""
problems = []
if expected["coordinates"] != actual["coordinates"]:
problems.append(f"coordinates are {actual['coordinates']}, the contract says {expected['coordinates']}")
if expected["pomDependencies"] != actual["pomDependencies"]:
problems.append(f"POM dependencies are {actual['pomDependencies']}, the contract says {expected['pomDependencies']}")
for key, value in expected.get("moduleVariantAttributes", {}).items():
found = actual["moduleVariantAttributes"].get(key)
if found != value:
problems.append(f"{key} is {found}, the contract says {value}")
if actual["maxClassFileMajor"] > expected["maxClassFileMajor"]:
problems.append(f"class files reach major version {actual['maxClassFileMajor']}, the contract allows {expected['maxClassFileMajor']}")
return problems


def module_dependency_problems(staging_dir, artifact, version):
"""Problems when the .module declares other dependencies than the POM; Gradle builds read the .module, Maven builds the POM."""
folder = Path(staging_dir) / artifact.folder(version)
base = artifact.base_name(version)
in_module = module_dependencies((folder / f"{base}.module").read_bytes())
in_pom = sorted(entry.rsplit(":", 1)[0] for entry in pom_dependencies((folder / f"{base}.pom").read_bytes()))
return [] if in_module == in_pom else [f"the .module declares {in_module} but the POM declares {in_pom}"]


def _shown(value):
return "absent" if value is None else str(value)


def summary_rows(expected, actual):
"""(property, contract, built) rows for the job summary table."""
def listing(values):
return ", ".join(values) if values else "none"

return [
("POM dependencies", listing(expected["pomDependencies"]), listing(actual["pomDependencies"])),
(JVM_VERSION, _shown(expected.get("moduleVariantAttributes", {}).get(JVM_VERSION)), _shown(actual["moduleVariantAttributes"].get(JVM_VERSION))),
("highest class file version", f"at most {expected['maxClassFileMajor']}", str(actual["maxClassFileMajor"])),
]
Loading
Loading