diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c080aa..beeb699 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,12 @@ jobs: - name: Verify the publishable artifacts assemble run: ./gradlew publishToMavenLocal + - name: Check the contract metadata matches the lock file + run: | + set -euo pipefail + ./gradlew generateContractInfo + scripts/check-contract-info.sh + - name: Report generated file counts run: | set -euo pipefail @@ -99,7 +105,10 @@ jobs: run: | set -euo pipefail scripts/generate-swift.sh - if git diff --quiet -- Sources; then + # ContractInfo.swift is excluded below: its version line is stamped by publish.yml + # just before tagging, so on a released tag it will not match what codegen produces + # here. Its commit field is what matters and check-contract-info.sh guards that. + if git diff --quiet -- Sources ':(exclude)Sources/OCPClientProtocol/ContractInfo.swift'; then echo "Sources/ matches the protos." exit 0 fi @@ -109,5 +118,5 @@ jobs: echo "::error::Sources/ does not match what codegen produces." echo "Run scripts/generate-swift.sh and commit the result. Pinned toolchain:" cat scripts/toolchain.env | grep -v '^#' - git --no-pager diff --stat -- Sources + git --no-pager diff --stat -- Sources ':(exclude)Sources/OCPClientProtocol/ContractInfo.swift' exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 25223b8..8fc3c15 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,10 +92,10 @@ jobs: set -euo pipefail scripts/install-swift-toolchain.sh scripts/generate-swift.sh - if ! git diff --quiet -- Sources; then + if ! git diff --quiet -- Sources ':(exclude)Sources/OCPClientProtocol/ContractInfo.swift'; then echo "::error::Sources/ does not match what codegen produces — refusing to tag it." echo "The tag is the Swift release, so it has to point at generated code that is current." - git --no-pager diff --stat -- Sources + git --no-pager diff --stat -- Sources ':(exclude)Sources/OCPClientProtocol/ContractInfo.swift' exit 1 fi @@ -121,6 +121,26 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_KEY_PASSWORD }} run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache + # ContractInfo.swift ships the version, and on main it reads "-dev" because the + # number is not real until this workflow runs. Regenerate it with the real one and + # commit, so the tag -- which IS the Swift release -- carries a released version. + # + # The commit is never pushed to main. Only the tag is pushed, so main keeps -dev and + # this workflow never needs write access to a protected branch. The tagged commit is + # reachable from the tag alone, which is exactly what SPM resolves. + - name: Stamp the released version into ContractInfo.swift + if: ${{ !inputs.dry_run }} + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + scripts/generate-swift.sh + scripts/check-contract-info.sh + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Sources/OCPClientProtocol/ContractInfo.swift + git commit -m "chore(release): stamp ${{ inputs.version }} into ContractInfo.swift" + # Last, because the tag is the Swift release: once it exists, SPM consumers resolve # it immediately. Tagging before the JAR is up would ship half a version. - name: Tag the release diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da7296..42e3c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ called out explicitly even when nothing else did. release notes, so a version with no entry here does not release. Write the entry in the same PR that syncs the contract, while the diff is still in front of you. +## 0.5.0 + +No contract change. Still synced to +[`ocp-protobuf-api@82202912`](https://github.com/code-payments/ocp-protobuf-api/commit/82202912574e122bba90025fe8b292d5a3f04c05). + +### Added + +- `OcpContractInfo` (Kotlin) and `OCPContractInfo` (Swift), carrying `VERSION` / + `version` and `PROTO_COMMIT` / `protoCommit` for the upstream commit this package was + generated from, plus `shortProtoCommit` and `isLocal`. A package built from + `sync-protos.sh --local` reports `LOCAL` as its commit, so a consumer can tell a local + contract build from a pinned one at runtime. + ## 0.4.0 Synced to [`ocp-protobuf-api@82202912`](https://github.com/code-payments/ocp-protobuf-api/commit/82202912574e122bba90025fe8b292d5a3f04c05). diff --git a/README.md b/README.md index e48ff62..b9ccc67 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,28 @@ Four services — `Account`, `Currency`, `Messaging`, `Transaction` — plus the `common/v1/model.proto`. The contract is owned upstream; `ocp.lock` records which commit of it this package was generated from. +## Knowing what you built against + +Both generated clients carry the package version and the upstream commit they were +generated from: + +```kotlin +OcpContractInfo.VERSION // "0.4.0" +OcpContractInfo.shortProtoCommit // "82202912", or "LOCAL" +``` + +```swift +OCPContractInfo.version // "0.4.0" +OCPContractInfo.shortProtoCommit // "82202912", or "LOCAL" +``` + +A build on a local proto sync reports `LOCAL` for the commit, because that is what +`sync-protos.sh --local` writes to `ocp.lock` and both generators read it from there. +`isLocal` is the flag to branch on. + +The Swift file's version reads `-dev` on `main`. `publish.yml` stamps the real +number into the commit it tags, so a resolved SPM tag always carries a released version. + ## Layout ``` diff --git a/Sources/OCPClientProtocol/ContractInfo.swift b/Sources/OCPClientProtocol/ContractInfo.swift new file mode 100644 index 0000000..2c9023b --- /dev/null +++ b/Sources/OCPClientProtocol/ContractInfo.swift @@ -0,0 +1,15 @@ +// Generated by scripts/generate-swift.sh. Do not edit. + +public enum OCPContractInfo { + public static let version = "0.5.0-dev" + public static let protoCommit = "82202912574e122bba90025fe8b292d5a3f04c05" + + public static var isLocal: Bool { protoCommit == localSentinel } + + public static var shortProtoCommit: String { + isLocal ? protoCommit : String(protoCommit.prefix(shortLength)) + } + + private static let localSentinel = "LOCAL" + private static let shortLength = 8 +} diff --git a/build.gradle.kts b/build.gradle.kts index 1bb3ecb..3b7f78a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -125,3 +125,66 @@ mavenPublishing { } } } + +// The published artifact is entirely generated protobuf, so there is nowhere for a +// consumer to read which contract it holds. This writes that in: the version Gradle is +// building under, and the upstream commit ocp.lock is pinned at. `sync-protos.sh --local` +// writes `commit: LOCAL`, so a composite-build consumer sees LOCAL with no extra handling. +abstract class GenerateContractInfo : DefaultTask() { + @get:InputFile + abstract val lockFile: RegularFileProperty + + @get:Input + abstract val packageVersion: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val lock = lockFile.get().asFile + val commit = lock.readLines() + .firstOrNull { it.startsWith("commit:") } + ?.removePrefix("commit:") + ?.trim() + .orEmpty() + require(commit.isNotEmpty()) { "${lock.name}: no 'commit:' line" } + + val dir = outputDir.get().asFile.resolve("com/codeinc/opencode/gen") + dir.mkdirs() + dir.resolve("OcpContractInfo.kt").writeText( + """ + // Generated by the generateContractInfo Gradle task. Do not edit. + package com.codeinc.opencode.gen + + public object OcpContractInfo { + public const val VERSION: String = "${packageVersion.get()}" + public const val PROTO_COMMIT: String = "$commit" + + public val isLocal: Boolean get() = PROTO_COMMIT == LOCAL_SENTINEL + + public val shortProtoCommit: String + get() = if (isLocal) PROTO_COMMIT else PROTO_COMMIT.take(SHORT_LENGTH) + + private const val LOCAL_SENTINEL: String = "LOCAL" + private const val SHORT_LENGTH: Int = 8 + } + + """.trimIndent() + ) + } +} + +// Captured eagerly rather than through a provider: `version` is resolved at configuration +// time from RELEASE_VERSION, and reading `project` from a task action is not permitted. +val contractInfoVersion = version.toString() + +val generateContractInfo = tasks.register("generateContractInfo") { + lockFile.set(layout.projectDirectory.file("ocp.lock")) + packageVersion.set(contractInfoVersion) + outputDir.set(layout.buildDirectory.dir("generated/sources/contractinfo/main/kotlin")) +} + +kotlin.sourceSets.named("main") { + kotlin.srcDir(generateContractInfo) +} diff --git a/scripts/check-contract-info.sh b/scripts/check-contract-info.sh new file mode 100755 index 0000000..c6d2ad2 --- /dev/null +++ b/scripts/check-contract-info.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# Asserts that the generated contract metadata agrees with ocp.lock. +# +# The Swift file is committed and excluded from the `git diff -- Sources` codegen check +# (its version line moves at release time, which that check would read as drift), so this +# is what guards it instead. The Kotlin file is generated into build/ on every build and +# is checked here for the same reason: one script, both languages, run by CI and by hand. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCK="$ROOT/ocp.lock" +SWIFT="$ROOT/Sources/OCPClientProtocol/ContractInfo.swift" +KOTLIN="$ROOT/build/generated/sources/contractinfo/main/kotlin/com/codeinc/opencode/gen/OcpContractInfo.kt" + +fail() { echo "ERROR: $*" >&2; exit 1; } + +expected="$(awk '/^commit:/ {print $2}' "$LOCK")" +[ -n "$expected" ] || fail "$LOCK has no 'commit:' line" + +[ -f "$SWIFT" ] || fail "missing $SWIFT -- run scripts/generate-swift.sh" +swift_commit="$(sed -n 's/.*protoCommit = "\([^"]*\)".*/\1/p' "$SWIFT")" +[ "$swift_commit" = "$expected" ] || \ + fail "ContractInfo.swift protoCommit is '$swift_commit', ocp.lock says '$expected'" + +[ -f "$KOTLIN" ] || fail "missing $KOTLIN -- run ./gradlew generateContractInfo" +kotlin_commit="$(sed -n 's/.*PROTO_COMMIT: String = "\([^"]*\)".*/\1/p' "$KOTLIN")" +[ "$kotlin_commit" = "$expected" ] || \ + fail "OcpContractInfo.kt PROTO_COMMIT is '$kotlin_commit', ocp.lock says '$expected'" + +echo "contract metadata agrees with ocp.lock: $expected" diff --git a/scripts/generate-swift.sh b/scripts/generate-swift.sh index 309d575..0b404d6 100755 --- a/scripts/generate-swift.sh +++ b/scripts/generate-swift.sh @@ -46,4 +46,37 @@ while IFS= read -r f; do fi done < <(cd "$PROTO_DIR" && find . -name '*.proto' -type f | sed "s#^\./#$PROTO_DIR/#" | sort) +# ContractInfo is the one hand-written file in this directory, and it has to be written +# here rather than by sync-protos.sh: OUT is wiped above, so anything sync writes would +# not survive a regenerate. On main the version is the next CHANGELOG entry plus -dev; +# publish.yml re-runs this with RELEASE_VERSION set and commits the result onto the tag. +commit="$(awk '/^commit:/ {print $2}' "$ROOT/ocp.lock")" +[ -n "$commit" ] || { echo "ocp.lock: no commit: line" >&2; exit 1; } + +version="${RELEASE_VERSION:-}" +if [ -z "$version" ]; then + changelog_version="$(awk '/^## / {print $2; exit}' "$ROOT/CHANGELOG.md")" + [ -n "$changelog_version" ] || { echo "CHANGELOG.md: no '## ' heading" >&2; exit 1; } + version="${changelog_version}-dev" +fi + +cat > "$OUT/ContractInfo.swift" < generated $(find "$OUT" -name '*.swift' | wc -l | tr -d ' ') Swift file(s) into Sources/OCPClientProtocol/" +echo "==> ContractInfo.swift: $version / $commit"