diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml
deleted file mode 100644
index e27d53a..0000000
--- a/.github/actions/setup-build/action.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-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') }}-
diff --git a/.github/scripts/release-version.py b/.github/scripts/release-version.py
deleted file mode 100644
index 2667ec5..0000000
--- a/.github/scripts/release-version.py
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/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']}")
diff --git a/.github/scripts/test-release-version.py b/.github/scripts/test-release-version.py
deleted file mode 100644
index 2ba9f48..0000000
--- a/.github/scripts/test-release-version.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""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()
diff --git a/.github/scripts/test-verification.py b/.github/scripts/test-verification.py
deleted file mode 100644
index e71e64b..0000000
--- a/.github/scripts/test-verification.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""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 = [
- '',
- '',
- '',
- '',
- ]
- 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(
- ''
- )
- reports.verify(directory)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/.github/scripts/verify-consumer.sh b/.github/scripts/verify-consumer.sh
deleted file mode 100644
index fc5d544..0000000
--- a/.github/scripts/verify-consumer.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/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
diff --git a/.github/scripts/verify-publication.py b/.github/scripts/verify-publication.py
deleted file mode 100644
index 5d7168b..0000000
--- a/.github/scripts/verify-publication.py
+++ /dev/null
@@ -1,80 +0,0 @@
-#!/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)
diff --git a/.github/scripts/verify-test-results.py b/.github/scripts/verify-test-results.py
deleted file mode 100644
index 557a002..0000000
--- a/.github/scripts/verify-test-results.py
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/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))
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index cabba7e..5bb351c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,12 +2,12 @@ name: build
on:
push:
- branches: [ main ]
+ branches: [main]
pull_request:
workflow_dispatch:
inputs:
fullTargets:
- description: Validate all publication targets and extra native hosts
+ description: Validate all targets on Linux, macOS and Windows
type: boolean
default: false
workflow_call:
@@ -16,7 +16,6 @@ on:
type: string
default: ''
ciVersion:
- description: Version to stage and verify; releases use their actual version
type: string
default: '0.0.0-ci'
fullTargets:
@@ -31,146 +30,94 @@ on:
permissions:
contents: read
-# A newer push supersedes any in-flight run for the same ref.
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
- name: Run tests and collect coverage
- runs-on: ubuntu-latest
+ name: ${{ matrix.os == 'ubuntu-latest' && 'Run tests and collect coverage' || format('Native tests ({0})', matrix.os) }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: ${{ fromJSON(inputs.fullTargets && '["ubuntu-latest","macos-15","windows-latest"]' || '["ubuntu-latest"]') }}
+ runs-on: ${{ matrix.os }}
timeout-minutes: 45
-
+ defaults:
+ run:
+ shell: bash
+ env:
+ HAS_CONFIGURATION_CACHE: ${{ secrets.GRADLE_ENCRYPTION_KEY != '' }}
+ FULL_TARGETS: ${{ inputs.fullTargets || false }}
+ CI_VERSION: ${{ inputs.ciVersion || '0.0.0-ci' }}
steps:
- - name: Checkout
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- fetch-depth: 2
-
- - name: Set up build
- uses: ./.github/actions/setup-build
+ - uses: actions/setup-java@v6
+ with:
+ distribution: temurin
+ # kbuild 1.3.6 requires Java 21; library compilation and tests use 17.
+ java-version: |
+ 17
+ 21
+ - uses: gradle/actions/setup-gradle@v6
with:
+ cache-read-only: false
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- target-profile: ${{ inputs.fullTargets && 'full' || 'default' }}
-
- - name: Validate CI guards
- run: |
- python3 .github/scripts/test-verification.py
- python3 .github/scripts/test-release-version.py
-
- # Routine builds use a stable private version for configuration-cache reuse.
- # Releases rerun this gate at their actual publication version.
- # The independent consumer treats only this local artifact as changing.
- # One graph shares compilation, lint, docs and publication artifacts.
- - name: Check library and stage publications
- env:
- CONSUMER_VERSION: ${{ inputs.ciVersion || '0.0.0-ci' }}
- FULL_TARGETS: ${{ inputs.fullTargets || false }}
- run: |
- target_args=()
- if [[ "$FULL_TARGETS" == true ]]; then target_args+=(-Ptargets.full); fi
- ./gradlew check lintDocs koverXmlReport publishAllPublicationsToLocalStagingRepository \
- -PciVersion="$CONSUMER_VERSION" "${target_args[@]}"
-
- - name: Require executed tests for the selected target set
- env:
- FULL_TARGETS: ${{ inputs.fullTargets || false }}
+ - name: Cache Kotlin Native
+ uses: actions/cache@v6
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-${{ runner.arch }}-konan-${{ inputs.fullTargets && 'full' || 'default' }}-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-konan-
+ - name: Cache encrypted configuration state
+ if: env.HAS_CONFIGURATION_CACHE == 'true'
+ uses: actions/cache@v6
+ with:
+ path: .gradle/configuration-cache
+ key: ${{ runner.os }}-${{ runner.arch }}-configuration-${{ inputs.fullTargets && 'full' || 'default' }}-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-${{ github.sha }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-configuration-${{ inputs.fullTargets && 'full' || 'default' }}-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-
+ - name: Build and test
run: |
- reports=(build/test-results/jvmTest build/test-results/jvmTestJarOnJava17
- build/test-results/jvmTestJarOnJava25 build/test-results/linuxX64Test)
- if [[ "$FULL_TARGETS" == true ]]; then
- reports+=(build/test-results/jsBrowserTest build/test-results/jsNodeTest
- build/test-results/wasmJsBrowserTest build/test-results/wasmJsNodeTest
- build/test-results/wasmWasiNodeTest)
+ case "$RUNNER_OS" in
+ Linux) tasks=(check lintDocs koverXmlReport) ;;
+ macOS) tasks=(macosArm64Test) ;;
+ Windows) tasks=(mingwX64Test) ;;
+ esac
+ args=(-PciVersion="$CI_VERSION")
+ if [[ "$FULL_TARGETS" == true ]]; then args+=(-Ptargets.full); fi
+ if [[ "$RUNNER_OS" != Linux ]]; then
+ args+=(--max-workers=2 -Pkotlin.native.parallelThreads=2)
fi
- python3 .github/scripts/verify-test-results.py "${reports[@]}"
-
- - name: Verify staged publications and independent consumers
- env:
- CONSUMER_VERSION: ${{ inputs.ciVersion || '0.0.0-ci' }}
- FULL_TARGETS: ${{ inputs.fullTargets || false }}
+ ./gradlew "${tasks[@]}" "${args[@]}"
+ - name: Test iOS simulator
+ if: runner.os == 'macOS'
run: |
- target_args=()
- if [[ "$FULL_TARGETS" == true ]]; then target_args+=(--full); fi
- python3 .github/scripts/verify-publication.py build/staging-repo "$CONSUMER_VERSION" "${target_args[@]}"
- bash .github/scripts/verify-consumer.sh "$CONSUMER_VERSION"
-
+ device=$(xcrun simctl list devices available --json | jq -er '
+ [.devices | to_entries[] | select(.key | contains(".iOS-")) | .value[] |
+ select(.name | startswith("iPhone")) | .udid][0] // error("No iPhone simulator available")')
+ ./gradlew iosSimulatorArm64Test -Ptargets.full -PciVersion="$CI_VERSION" \
+ --max-workers=2 -Pkotlin.native.parallelThreads=2 -PiosSimulatorDevice="$device"
- name: Require committed JavaScript and Wasm lockfiles
+ if: runner.os == 'Linux' && inputs.fullTargets
run: |
git diff --exit-code -- kotlin-js-store
test -z "$(git ls-files --others --exclude-standard -- kotlin-js-store)"
-
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v7
with:
- name: test-reports
+ name: test-reports-${{ runner.os }}
path: |
- **/build/reports/tests/
- **/build/test-results/
+ build/reports/tests/
+ build/test-results/
if-no-files-found: ignore
-
- - name: Upload results to Codecov (main only)
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ - name: Upload coverage
+ if: runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: build/reports/kover/report.xml
fail_ci_if_error: false
-
- native:
- if: ${{ inputs.fullTargets == true }}
- name: Native tests (${{ matrix.os }})
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: macos-15
- target: macosArm64
- python: python3
- - os: windows-latest
- target: mingwX64
- python: python
- runs-on: ${{ matrix.os }}
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v7
- with:
- ref: ${{ inputs.ref || github.sha }}
- persist-credentials: false
- - name: Set up build
- uses: ./.github/actions/setup-build
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- target-profile: full
- # Native checks only: lint, coverage and web tests belong to the Linux job.
- - name: Run native tests
- shell: bash
- run: ./gradlew ${{ matrix.target }}Test -Ptargets.full --max-workers=2 -Pkotlin.native.parallelThreads=2
- - name: Require executed native tests
- shell: bash
- run: ${{ matrix.python }} .github/scripts/verify-test-results.py build/test-results/${{ matrix.target }}Test
- # Extra native hosts and simulator linking are part of explicit full validation only.
- - name: Run iOS simulator tests
- if: runner.os == 'macOS'
- shell: bash
- run: |
- device=$(xcrun simctl list devices available --json | python3 -c '
- import json, sys
- devices = json.load(sys.stdin)["devices"]
- print(next(d["udid"] for runtime, items in devices.items() if ".iOS-" in runtime
- for d in items if d["name"].startswith("iPhone")))')
- ./gradlew iosSimulatorArm64Test -Ptargets.full --max-workers=2 -Pkotlin.native.parallelThreads=2 -PiosSimulatorDevice="$device"
- python3 .github/scripts/verify-test-results.py build/test-results/iosSimulatorArm64Test
- - name: Upload native test reports
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: test-reports-${{ matrix.target }}
- path: |
- build/reports/tests/
- build/test-results/
- if-no-files-found: ignore
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8655118..ae08019 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,6 @@ on:
permissions:
contents: read
-# New snapshots supersede older runs; a tag publication must never be cancelled.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}
@@ -29,23 +28,49 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
is_snapshot: ${{ steps.version.outputs.is_snapshot }}
- is_prerelease: ${{ steps.version.outputs.is_prerelease }}
test_release: ${{ steps.version.outputs.test_release }}
- release_tag: ${{ steps.version.outputs.release_tag }}
revision: ${{ steps.version.outputs.revision }}
steps:
- - name: Checkout
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false
fetch-depth: 0
- name: Resolve publication version
id: version
- run: python3 .github/scripts/release-version.py
+ shell: bash
+ run: |
+ core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
+ is_snapshot=false
+ test_release=false
+ if [[ "$GITHUB_REF_TYPE" == tag ]]; then
+ if [[ ! "$GITHUB_REF_NAME" =~ ^v${core}(-rc\.(0|[1-9][0-9]*))?$ ]]; then
+ echo '::error::Use a vX.Y.Z or vX.Y.Z-rc.N release tag' >&2
+ exit 1
+ fi
+ version=${GITHUB_REF_NAME#v}
+ if [[ "$version" == *-rc.* ]]; then test_release=true; fi
+ elif [[ "$GITHUB_REF_TYPE" == branch && "$GITHUB_REF_NAME" == main ]]; then
+ version=0.0.1-SNAPSHOT
+ while IFS= read -r tag; do
+ if [[ "$tag" =~ ^v${core}$ ]]; then
+ version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-SNAPSHOT"
+ break
+ fi
+ done < <(git tag --list 'v*' --sort=-v:refname)
+ is_snapshot=true
+ else
+ echo '::error::Releases require a version tag; snapshots use main' >&2
+ exit 1
+ fi
+ {
+ echo "version=$version"
+ echo "is_snapshot=$is_snapshot"
+ echo "test_release=$test_release"
+ echo "revision=$(git rev-parse HEAD)"
+ } >> "$GITHUB_OUTPUT"
- # Releases rerun Linux checks at the release version alongside macOS/iOS and Windows.
- # Publication waits for every platform. Snapshots reuse the normal main build.
+ # Tagged releases rerun Linux checks alongside Windows and macOS/iOS.
validate:
if: github.event_name != 'workflow_run'
needs: prepare
@@ -69,55 +94,45 @@ jobs:
permissions:
contents: write
env:
+ HAS_CONFIGURATION_CACHE: ${{ secrets.GRADLE_ENCRYPTION_KEY != '' }}
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
IS_SNAPSHOT: ${{ needs.prepare.outputs.is_snapshot }}
IS_TEST_RELEASE: ${{ needs.prepare.outputs.test_release }}
- RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
steps:
- - name: Checkout
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.revision }}
persist-credentials: false
- - name: Set up build
- uses: ./.github/actions/setup-build
+ - uses: actions/setup-java@v6
+ with:
+ distribution: temurin
+ # kbuild 1.3.6 requires Java 21; library compilation and tests use 17.
+ java-version: |
+ 17
+ 21
+ - uses: gradle/actions/setup-gradle@v6
with:
+ cache-read-only: true
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- gradle-cache-read-only: 'true'
- target-profile: full
-
- # The reusable validation job already ran the full checks and uploaded test reports.
- # Recheck the publication job's staged artifacts and consumer resolution before publishing.
- - name: Verify release publication
- if: env.IS_SNAPSHOT == 'false'
- run: |
- ./gradlew publishAllPublicationsToLocalStagingRepository -Ptargets.full -PciVersion="$RELEASE_VERSION"
- python3 .github/scripts/verify-publication.py build/staging-repo "$RELEASE_VERSION" --full
- bash .github/scripts/verify-consumer.sh "$RELEASE_VERSION"
- - name: Stage and verify GitHub RC assets
- if: env.IS_TEST_RELEASE == 'true'
- run: |
- ./gradlew githubReleaseAssets -Ptargets.full -PciVersion="$RELEASE_VERSION"
- python3 .github/scripts/verify-publication.py build/github-release-assets "$RELEASE_VERSION" --full --github
- bash .github/scripts/verify-consumer.sh "$RELEASE_VERSION" "$PWD/build/github-release-assets"
- - name: Upload validated RC assets for inspection
- if: env.IS_TEST_RELEASE == 'true'
- uses: actions/upload-artifact@v7
+ - name: Restore Kotlin Native
+ uses: actions/cache/restore@v6
with:
- name: test-release-assets
- path: build/github-release-assets/
- if-no-files-found: error
+ path: ~/.konan
+ key: ${{ runner.os }}-${{ runner.arch }}-konan-full-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-konan-
+ - name: Restore encrypted configuration state
+ if: env.HAS_CONFIGURATION_CACHE == 'true'
+ uses: actions/cache/restore@v6
+ with:
+ path: .gradle/configuration-cache
+ key: ${{ runner.os }}-${{ runner.arch }}-configuration-full-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-${{ github.sha }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-configuration-full-${{ hashFiles('*.gradle.kts', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }}-
- name: Publish RC to GitHub only
if: env.IS_TEST_RELEASE == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: ./gradlew publishToGitHubRelease -Ptargets.full -PciVersion="$RELEASE_VERSION"
- - name: Verify anonymous consumer from GitHub
- if: env.IS_TEST_RELEASE == 'true'
- run: >-
- ./gradlew -p consumer run -Pconsumer.version="$RELEASE_VERSION"
- -Pconsumer.githubRepository="https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG"
- - name: Publish to Maven Central (Portal)
+ - name: Publish stable release to Maven Central
if: env.IS_SNAPSHOT == 'false' && env.IS_TEST_RELEASE != 'true'
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_PORTAL_USER }}
@@ -125,7 +140,7 @@ jobs:
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }}
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }}
run: ./gradlew publishToMavenCentral -Ptargets.full -PciVersion="$RELEASE_VERSION"
- - name: Publish to Sonatype Snapshots
+ - name: Publish snapshot to Sonatype
if: env.IS_SNAPSHOT == 'true'
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_PORTAL_USER }}
@@ -137,5 +152,4 @@ jobs:
if: github.ref_type == 'tag' && env.IS_SNAPSHOT == 'false' && env.IS_TEST_RELEASE != 'true'
uses: softprops/action-gh-release@v3
with:
- prerelease: ${{ needs.prepare.outputs.is_prerelease }}
generate_release_notes: true
diff --git a/.gitignore b/.gitignore
index 27d164e..3ed6d5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,3 @@ out/
.specstory/
.aider*
*.local.md
-
-### Python CI helpers ###
-__pycache__/
diff --git a/README.md b/README.md
index 829d670..83bc961 100644
--- a/README.md
+++ b/README.md
@@ -112,47 +112,30 @@ characters.
## Build validation
-`./gradlew check lintDocs koverXmlReport` checks JVM and Linux x64.
-JS, Wasm, WASI and other native targets are configured only with `-Ptargets.full`. JVM checks also
-run the packaged JAR on Java 17 and 25. CI stages the configured publications,
-checks their metadata and artifacts, and runs an independent Java consumer using
-both Gradle metadata and POM-only resolution.
-
-Normal pull-request and main builds use one Linux job. Tagged stable and RC releases
-enable `-Ptargets.full`, with focused macOS ARM64, iOS simulator and Windows checks.
-The build workflow's manual `fullTargets` input runs the same gate without publishing.
-Automatic snapshots publish all targets without repeating the full runtime gate.
-Releases rerun the full Linux gate at the actual release version, in parallel with
-the macOS/iOS and Windows jobs. Publication waits for every platform to pass and
-rechecks its staged artifacts and independent consumers before publishing.
-
-ABI references live in `api/` for normal checks and `api-full/` for full checks.
-When intentionally updating the API, run `./gradlew updateKotlinAbi` and
-`./gradlew updateKotlinAbi -Ptargets.full`. JavaScript and Wasm dependency lockfiles
-under `kotlin-js-store/` must be committed; use
-`./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock -Ptargets.full` to update them.
-
-CI caches Gradle dependency/task outputs, the host-specific Native toolchain, and
-Gradle-encrypted configuration state separately. Configuration state is persisted
-only when the encryption secret is available. The local CI publication uses a
-stable version so source-only commits can reuse configuration state; the consumer
-always refreshes that local module, while external dependencies remain cached.
+Routine builds run `./gradlew check lintDocs koverXmlReport` on JDK 17 for JVM
+and Linux x64. Gradle runs on JDK 21, as required by kbuild 1.3.6.
+Tagged releases enable `-Ptargets.full`, rerunning Linux/web
+checks alongside Windows and macOS/iOS simulator tests in parallel. Publication
+waits for every platform to pass. The build workflow's `fullTargets` input runs
+the same checks without publishing.
+
+Gradle dependencies, task outputs, the host's Kotlin Native toolchain and encrypted
+configuration state are cached. Configuration state requires the encryption secret.
+Automatic main snapshots publish all targets after the routine build passes.
+
+ABI references live in `api/` and `api-full/`. Update both with
+`./gradlew updateKotlinAbi` and `./gradlew updateKotlinAbi -Ptargets.full`.
+Commit JavaScript and Wasm lockfiles under `kotlin-js-store/`; update them with
+`./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock -Ptargets.full`.
### Tagged releases
-Push an explicit SemVer RC tag to run the full parallel release gate and publish
-all 12 publications with `SHA256SUMS` to a GitHub prerelease:
+Push an explicit SemVer tag: `vX.Y.Z` publishes to Maven Central and creates a
+GitHub release; `vX.Y.Z-rc.N` publishes all targets and checksums to a GitHub
+prerelease only. RCs never publish to Maven. Versions come directly from tags;
+there is no manual release mode or automatic RC numbering.
```sh
git tag v1.4.1-rc.1
git push origin v1.4.1-rc.1
```
-
-The version comes directly from the tag. Release workflows never create tags or
-increment RC numbers. RCs publish to GitHub only; stable tags such as `v1.4.1`
-publish to Maven Central and create a GitHub release after validation.
-There is no manual release dispatch.
-
-GitHub RC assets include JVM/native/web artifacts, sources, documentation and
-Gradle metadata. They can be consumed anonymously using the flat Ivy repository
-pattern in [the independent consumer](consumer/settings.gradle.kts).
diff --git a/build.gradle.kts b/build.gradle.kts
index e31bad9..4d31013 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -20,15 +20,7 @@ eignexPublish {
githubRepo.set("Eignex/kencode")
}
-// Run the real JVM artifact on both the supported floor and the current LTS.
-eignexBuild {
- packagedTestJdks.set(listOf(17, 25))
-}
-
-tasks.withType().configureEach {
- // Do not reuse results produced with the old aggregate-only failure policy.
- inputs.property("kotlin.tests.individualTaskReports", providers.gradleProperty("kotlin.tests.individualTaskReports"))
-}
+eignexBuild { jvmToolchain.set(17) }
// Routine checks cover JVM + Linux x64; release validation opts into every target.
val fullTargets = providers.gradleProperty("targets.full").isPresent
diff --git a/consumer/build.gradle.kts b/consumer/build.gradle.kts
deleted file mode 100644
index 61213ba..0000000
--- a/consumer/build.gradle.kts
+++ /dev/null
@@ -1,21 +0,0 @@
-plugins { application }
-
-val pomOnly = providers.gradleProperty("consumer.pomOnly").getOrElse("false").toBoolean()
-val library = providers.gradleProperty("consumer.library").get()
-val module = if (pomOnly) "$library-jvm" else library
-val version = providers.gradleProperty("consumer.version").get()
-
-dependencies {
- // No KBuild plugin, platform, or Kotlin dependency declaration: published metadata must suffice.
- implementation("com.eignex:$module:$version") {
- // CI reuses a private staging version; always inspect its current local metadata/artifact.
- isChanging = true
- }
-}
-
-java { toolchain.languageVersion.set(JavaLanguageVersion.of(17)) }
-application { mainClass.set("Consumer") }
-
-configurations.configureEach {
- resolutionStrategy.cacheChangingModulesFor(0, "seconds")
-}
diff --git a/consumer/gradle.properties b/consumer/gradle.properties
deleted file mode 100644
index c0d290c..0000000
--- a/consumer/gradle.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-org.gradle.configuration-cache=true
-org.gradle.caching=true
-org.gradle.warning.mode=fail
-consumer.library=kencode
diff --git a/consumer/settings.gradle.kts b/consumer/settings.gradle.kts
deleted file mode 100644
index f0f5104..0000000
--- a/consumer/settings.gradle.kts
+++ /dev/null
@@ -1,29 +0,0 @@
-rootProject.name = "library-consumer"
-
-plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }
-
-dependencyResolutionManagement {
- repositories {
- exclusiveContent {
- forRepository {
- val githubRepository = providers.gradleProperty("consumer.githubRepository")
- if (githubRepository.isPresent) {
- ivy {
- url = uri(githubRepository.get())
- patternLayout { artifact("[artifact]-[revision](-[classifier]).[ext]") }
- metadataSources { gradleMetadata() }
- }
- } else {
- maven {
- url = uri(providers.gradleProperty("consumer.repository").get())
- if (providers.gradleProperty("consumer.pomOnly").getOrElse("false").toBoolean()) {
- metadataSources { mavenPom(); artifact(); ignoreGradleMetadataRedirection() }
- }
- }
- }
- }
- filter { includeGroup("com.eignex") }
- }
- mavenCentral()
- }
-}
diff --git a/consumer/src/main/java/Consumer.java b/consumer/src/main/java/Consumer.java
deleted file mode 100644
index 40f9961..0000000
--- a/consumer/src/main/java/Consumer.java
+++ /dev/null
@@ -1,24 +0,0 @@
-import com.eignex.kencode.Base62;
-import com.eignex.kencode.Base64;
-import com.eignex.kencode.Base64Url;
-import com.eignex.kencode.Base85;
-import com.eignex.kencode.ByteEncoding;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-
-public final class Consumer {
- public static void main(String[] args) {
- var bytes = "Published encoding 0123456789".getBytes(StandardCharsets.UTF_8);
- ByteEncoding[] codecs = {Base64.Default, Base64Url.INSTANCE, Base85.Default, Base62.INSTANCE};
- for (var codec : codecs) {
- var encoded = codec.encode(bytes, 0, bytes.length);
- if (!Arrays.equals(bytes, codec.decode(encoded))) {
- throw new AssertionError("Published encoding failed to round trip");
- }
- }
- if (!Base64.Default.encode(bytes, 0, bytes.length).equals(java.util.Base64.getEncoder().encodeToString(bytes))) {
- throw new AssertionError("Published Base64 differs from JDK Base64");
- }
- System.out.println("Published Java consumer passed");
- }
-}