diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bcccb1..0c080aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,25 @@ concurrency: cancel-in-progress: true jobs: + contract: + name: Contract pin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # `scripts/sync-protos.sh --local` writes `commit: LOCAL` so the contract can be + # iterated on without pushing it. proto/ then reflects somebody's working tree, which + # no other checkout can reproduce, so it must not reach main. + - name: Reject a local contract sync + run: | + set -euo pipefail + if grep -q '^commit: LOCAL' ocp.lock; then + echo "::error::ocp.lock records a local sync. Push the contract change to ocp-protobuf-api, then re-run scripts/sync-protos.sh ." + cat ocp.lock + exit 1 + fi + echo "Pinned at $(awk '/^commit:/ {print $2}' ocp.lock)." + kotlin: name: Kotlin codegen runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 088f6ed..76ab466 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,6 +39,17 @@ jobs: exit 1 fi + # A local sync is for iterating, not releasing: proto/ came from a working tree and + # the published artifact would have no upstream commit behind it. + - name: Reject a local contract sync + run: | + set -euo pipefail + if grep -q '^commit: LOCAL' ocp.lock; then + echo "::error::ocp.lock records a local sync — refusing to publish it." + cat ocp.lock + exit 1 + fi + - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 diff --git a/README.md b/README.md index 342ead1..80b1d72 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,26 @@ scripts/generate-swift.sh # refresh committed Swift Commit the resulting `proto/`, `ocp.lock`, and `Sources/` together. CI re-runs both generators and fails if `Sources/` does not match the protos. +## Local development + +Trying a contract change does not need a release. `sync-protos.sh --local` reads a checkout of +[`ocp-protobuf-api`](https://github.com/code-payments/ocp-protobuf-api) directly, uncommitted +edits included: + +```bash +scripts/sync-protos.sh --local ../ocp-protobuf-api # or set OCP_UPSTREAM_PATH +scripts/generate-swift.sh # only if you need the Swift side +``` + +That writes `commit: LOCAL` into `ocp.lock`. CI fails on it and the publish workflow refuses to +release it, so a local sync cannot reach `main` or Maven Central. Push the contract change and +re-run `scripts/sync-protos.sh ` to get back to a reproducible pin. + +Both apps can consume a checkout of this repo without a publish as well: `protoLocalRoot` in +Android's `local.properties`, `FLIPCASH_PROTO_LOCAL` for iOS. The whole loop, including what +stops a local state from shipping, is in `docs/proto-local-development.md` in the cross-platform +orchestrator directory. + ## Releasing `.github/workflows/publish.yml`, run from the Actions tab with a version like `0.1.0`. One diff --git a/scripts/sync-protos.sh b/scripts/sync-protos.sh index 985c9fc..03a6a3b 100755 --- a/scripts/sync-protos.sh +++ b/scripts/sync-protos.sh @@ -1,11 +1,21 @@ #!/usr/bin/env bash # -# Sync the OCP contract protos from ocp-protobuf-api at a pinned commit. +# Sync the OCP contract protos from ocp-protobuf-api. # # scripts/sync-protos.sh # sync at the SHA in ocp.lock # scripts/sync-protos.sh # re-pin to , then sync +# scripts/sync-protos.sh --local [path] # sync from a local checkout, uncommitted edits included # -# Set OCP_UPSTREAM_URL to a local path to sync from a mirror instead of the network. +# --local is the contract-authoring loop: edit a .proto in an ocp-protobuf-api checkout, +# sync, regenerate, and build the apps against the result without pushing anything. The +# path defaults to $OCP_UPSTREAM_PATH, then to ../ocp-protobuf-api. +# +# A local sync writes `commit: LOCAL` to ocp.lock. CI and the publish workflow both reject +# that, so proto/ can never reach main or Maven Central without a real upstream commit +# behind it. Re-run with a sha once the contract change is pushed. +# +# Set OCP_UPSTREAM_URL to a local path to sync from a mirror instead of the network. That +# still clones, so it sees committed state only; --local reads the working tree. # set -euo pipefail @@ -21,30 +31,70 @@ DEST="$ROOT/proto" UPSTREAM_PKG='com.codeinc.gen.' SDK_PKG='com.codeinc.opencode.gen.' -requested="${1:-}" -if [ -z "$requested" ]; then - [ -f "$LOCK" ] || { echo "no ocp.lock and no ref given; pass a sha to pin" >&2; exit 1; } - requested="$(awk '/^commit:/ {print $2}' "$LOCK")" -fi +mode="pinned" +requested="" +local_path="" + +case "${1:-}" in + --local) + mode="local" + local_path="${2:-${OCP_UPSTREAM_PATH:-$ROOT/../ocp-protobuf-api}}" + ;; + -h|--help) + sed -n '2,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + requested="${1:-}" + ;; +esac tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -echo "==> cloning $UPSTREAM_URL" -git clone --quiet "$UPSTREAM_URL" "$tmp/upstream" -git -C "$tmp/upstream" checkout --quiet "$requested" -sha="$(git -C "$tmp/upstream" rev-parse HEAD)" -subject="$(git -C "$tmp/upstream" log -1 --format='%s')" -echo "==> pinned at $sha ($subject)" +if [ "$mode" = "local" ]; then + SRC="$(cd "$local_path" 2>/dev/null && pwd)" || { + echo "no such directory: $local_path" >&2 + echo "pass the path to an ocp-protobuf-api checkout, or set OCP_UPSTREAM_PATH." >&2 + exit 1 + } + echo "==> reading the working tree at $SRC" + if head="$(git -C "$SRC" rev-parse HEAD 2>/dev/null)"; then + worktree_state="clean" + git -C "$SRC" diff --quiet HEAD 2>/dev/null || worktree_state="uncommitted changes" + else + head="not a git checkout" + worktree_state="unversioned" + fi + echo "==> local HEAD $head ($worktree_state)" +else + if [ -z "$requested" ]; then + [ -f "$LOCK" ] || { echo "no ocp.lock and no ref given; pass a sha to pin" >&2; exit 1; } + requested="$(awk '/^commit:/ {print $2}' "$LOCK")" + if [ "$requested" = "LOCAL" ]; then + echo "ocp.lock records a local sync, so there is no upstream commit to re-sync from." >&2 + echo "Pass a sha to re-pin, or re-run: scripts/sync-protos.sh --local [path]" >&2 + exit 1 + fi + fi + + SRC="$tmp/upstream" + echo "==> cloning $UPSTREAM_URL" + git clone --quiet "$UPSTREAM_URL" "$SRC" + git -C "$SRC" checkout --quiet "$requested" + sha="$(git -C "$SRC" rev-parse HEAD)" + subject="$(git -C "$SRC" log -1 --format='%s')" + echo "==> pinned at $sha ($subject)" +fi -[ -d "$tmp/upstream/proto" ] || { echo "upstream has no proto/ directory" >&2; exit 1; } +[ -d "$SRC/proto" ] || { echo "upstream has no proto/ directory" >&2; exit 1; } # Contract protos only. buf.yaml / buf.lock / buf.gen.yaml describe how the *contract* # repo builds Go; they are not part of what this SDK ships. rm -rf "$DEST" mkdir -p "$DEST" -( cd "$tmp/upstream/proto" && find . -name '*.proto' -type f -print0 ) \ - | ( cd "$tmp/upstream/proto" && xargs -0 -I{} sh -c 'mkdir -p "$1/$(dirname "{}")" && cp "{}" "$1/{}"' _ "$DEST" ) +( cd "$SRC/proto" && find . -name '*.proto' -type f -print0 ) \ + | ( cd "$SRC/proto" && xargs -0 -I{} sh -c 'mkdir -p "$1/$(dirname "{}")" && cp "{}" "$1/{}"' _ "$DEST" ) # Re-namespace for the Kotlin artifact. Swift is unaffected: java_package does not # influence swift-protobuf naming. @@ -63,12 +113,28 @@ if grep -rq "\"${UPSTREAM_PKG}" "$DEST"; then exit 1 fi -cat > "$LOCK" < "$LOCK" <. +upstream: $SRC (local working tree) +commit: LOCAL +head: $head ($worktree_state) +LOCK_EOF +else + cat > "$LOCK" <. upstream: code-payments/ocp-protobuf-api commit: $sha subject: $subject LOCK_EOF +fi echo "==> synced $(find "$DEST" -name '*.proto' | wc -l | tr -d ' ') proto file(s) into proto/" echo "==> wrote $LOCK" + +if [ "$mode" = "local" ]; then + echo + echo " ocp.lock now says LOCAL. This tree is for iterating, not for merging." +fi