From 0c9927f30dfb20e09e716d6a8c204f0e6a16afee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:55:28 -0400 Subject: [PATCH 1/6] test(contract-info): assert generated metadata matches the lock file --- scripts/check-contract-info.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 scripts/check-contract-info.sh 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" From 6897afba2246f916e9db46f60a8d79fee72640c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:59:12 -0400 Subject: [PATCH 2/6] feat(contract-info): generate the Kotlin metadata object from ocp.lock The published artifact is all generated protobuf, so a consumer has no way to read which contract it holds. generateContractInfo writes an OcpContractInfo object into the main source set carrying the Gradle version and the commit ocp.lock is pinned at. A local sync writes 'commit: LOCAL', so a composite-build consumer gets isLocal for free rather than needing a second signal. --- build.gradle.kts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) 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) +} From c5cc290ff88de86d9e07fdce5e3c55ef20333d45 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 15:00:45 -0400 Subject: [PATCH 3/6] feat(contract-info): emit ContractInfo.swift from the Swift generator SPM ships source, so the Swift side needs the same metadata committed rather than computed at build time. generate-swift.sh owns it because that script wipes Sources/OCPClientProtocol before generating -- anything sync-protos.sh wrote there would not survive a regenerate. Off a release the version is the next CHANGELOG heading plus -dev. publish.yml re-runs this with RELEASE_VERSION set and commits the result onto the tag, so main keeps -dev. --- Sources/OCPClientProtocol/ContractInfo.swift | 15 +++++++++ scripts/generate-swift.sh | 33 ++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 Sources/OCPClientProtocol/ContractInfo.swift diff --git a/Sources/OCPClientProtocol/ContractInfo.swift b/Sources/OCPClientProtocol/ContractInfo.swift new file mode 100644 index 0000000..da435c3 --- /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.4.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/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" From b4dcb1523f91fe14c201d1e1c2263d73a10dcb65 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 15:01:53 -0400 Subject: [PATCH 4/6] ci(contract-info): check the generated metadata against the lock file The Kotlin job regenerates the metadata object and asserts both sides carry the commit ocp.lock names. Sources/OCPClientProtocol/ContractInfo.swift is committed, so this runs on ubuntu without the Swift toolchain. The Swift codegen equality check excludes ContractInfo.swift. publish.yml stamps its version line just before tagging, so on a released tag it will not match what codegen produces from CHANGELOG.md; the commit field is the part that matters and the new check guards it. --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 From 43fd7595ee04dd0d596e9e6b383f8cf29c013355 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 15:02:22 -0400 Subject: [PATCH 5/6] ci(release): stamp the released version into ContractInfo.swift before tagging On main ContractInfo.swift reads '-dev', because the version is not real until this workflow runs. Between the Central upload and the tag, regenerate it with the real number and commit, so the tag -- which is the Swift release -- carries a released version. The commit is not pushed to a branch. Only the tag is, so main keeps -dev and the workflow needs no write access to a protected branch; the stamped commit stays reachable from the tag, which is what SPM resolves. The cost is that git log main does not show release commits, and the tags are the record. --- .github/workflows/publish.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 From 2b3e86344c9ad21c0d64c4cc178ea7f549a5240e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 15:02:59 -0400 Subject: [PATCH 6/6] docs(contract-info): document the metadata constants and note 0.5.0 ContractInfo.swift moves to 0.5.0-dev because its version is derived from the top CHANGELOG heading, which this commit adds. --- CHANGELOG.md | 13 ++++++++++++ README.md | 22 ++++++++++++++++++++ Sources/OCPClientProtocol/ContractInfo.swift | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) 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 index da435c3..2c9023b 100644 --- a/Sources/OCPClientProtocol/ContractInfo.swift +++ b/Sources/OCPClientProtocol/ContractInfo.swift @@ -1,7 +1,7 @@ // Generated by scripts/generate-swift.sh. Do not edit. public enum OCPContractInfo { - public static let version = "0.4.0-dev" + public static let version = "0.5.0-dev" public static let protoCommit = "82202912574e122bba90025fe8b292d5a3f04c05" public static var isLocal: Bool { protoCommit == localSentinel }