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"); - } -}