From 6de298abbd89e237ca2b14d754e4d16799d0eeb1 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 9 Oct 2026 17:56:21 +0200 Subject: [PATCH] Open a PR when a native SDK releases. Android releases never reached this package, and iOS was still on a floating pod requirement, so a native API change had nowhere to land. Co-authored-by: Cursor --- .github/scripts/native-changelog.py | 40 ++++ .github/scripts/unreleased-entry.py | 66 ++++++ .github/workflows/native-sdk-bump.yml | 307 ++++++++++++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 .github/scripts/native-changelog.py create mode 100644 .github/scripts/unreleased-entry.py create mode 100644 .github/workflows/native-sdk-bump.yml diff --git a/.github/scripts/native-changelog.py b/.github/scripts/native-changelog.py new file mode 100644 index 0000000..e893bf9 --- /dev/null +++ b/.github/scripts/native-changelog.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Print the CHANGELOG.md sections of a native Superwall SDK between two versions. + +Usage: native-changelog.py + +Prints every "## " section with old < version <= new, newest first, +headings included. A wrapper can lag several native releases behind, so the +whole range matters, not just the newest entry. + +Only "## " lines count as section boundaries: Superwall-Android also +uses "## Fixes" / "## Enhancements" as sub-headings inside a version. +""" +import re +import sys + +HEADING = re.compile(r"^##\s+v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s*$") + + +def key(version): + core, _, pre = version.partition("-") + # A release sorts after its own prereleases (2.9.0-beta.1 < 2.9.0). + return tuple(int(p) for p in core.split(".")), pre == "", pre + + +def main(): + path, old, new = sys.argv[1:4] + lo, hi = key(old), key(new) + out, keep = [], False + with open(path, encoding="utf-8") as f: + for line in f: + m = HEADING.match(line.rstrip("\n")) + if m: + keep = lo < key(m.group(1)) <= hi + if keep: + out.append(line) + sys.stdout.write("".join(out).strip() + "\n" if out else "") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/unreleased-entry.py b/.github/scripts/unreleased-entry.py new file mode 100644 index 0000000..2adcfc4 --- /dev/null +++ b/.github/scripts/unreleased-entry.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Add a bullet to a subheading of the CHANGELOG.md "## [Unreleased]" section. + +Usage: unreleased-entry.py [replace-prefix] + +Bullets use the "* " marker this changelog already uses. A second bump of the +same platform replaces the bullet that starts with replace-prefix instead of +adding another one. package.json is left alone: cutting a version out of +[Unreleased] is a separate release. +""" +import sys + +UNRELEASED = "## [Unreleased]" + + +def main(): + path, subheading, bullet = sys.argv[1:4] + replace_prefix = sys.argv[4] if len(sys.argv) > 4 else None + with open(path, encoding="utf-8") as f: + lines = f.read().split("\n") + bullet_line = bullet if bullet.startswith("* ") else f"* {bullet}" + + start = next((i for i, line in enumerate(lines) if line.strip() == UNRELEASED), None) + if start is None: + insert_at = next((i for i, line in enumerate(lines) if line.startswith("## ")), len(lines)) + lines[insert_at:insert_at] = [UNRELEASED, "", subheading, bullet_line, ""] + return write(path, lines) + + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) + + if replace_prefix: + stale = next( + (i for i in range(start + 1, end) if lines[i].startswith(f"* {replace_prefix}")), + None, + ) + if stale is not None: + lines[stale] = bullet_line + return write(path, lines) + + sub = next((i for i in range(start + 1, end) if lines[i].strip() == subheading), None) + if sub is None: + # Sit above the next version heading, after whatever is already unreleased. + block = [subheading, bullet_line, ""] + at = end + if at > start + 1 and lines[at - 1] != "": + block.insert(0, "") + lines[at:at] = block + else: + j = sub + 1 + while j < end and (lines[j].startswith("* ") or lines[j].startswith(" ")): + j += 1 + lines.insert(j, bullet_line) + + write(path, lines) + + +def write(path, lines): + text = "\n".join(lines) + if not text.endswith("\n"): + text += "\n" + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/native-sdk-bump.yml b/.github/workflows/native-sdk-bump.yml new file mode 100644 index 0000000..4677b57 --- /dev/null +++ b/.github/workflows/native-sdk-bump.yml @@ -0,0 +1,307 @@ +# Bumps the wrapped native SDK when Superwall-Android or Superwall-iOS releases. +# +# Fired by the native repos' notify-wrappers.yml (repository_dispatch +# `native-sdk-release`, payload {platform, version}), or by hand. +# +# Two stages, so a PR always exists even if the agent fails or finds nothing: +# 1. Deterministic bump: native pin plus a CHANGELOG note under [Unreleased] +# — committed and opened as a PR. package.json is not bumped; cutting a +# version out of [Unreleased] is a separate release. +# 2. Claude Code (anthropics/claude-code-action) reads the native release +# notes for the whole skipped range and, if the public API changed, +# implements the Unity side and pushes it onto the same PR. +# +# Android is pinned exactly in Plugins/Android/.../build.gradle. iOS is pinned +# in two places that this workflow keeps identical: the Podfile lines written +# by Editor/SuperwallPostBuildProcessor.cs, and the SPM requirement in +# ci~/ios-harness/Package.swift. A floating requirement (`~> 4.0`, `from:`) is +# replaced with an exact version on the first bump. +# +# Secrets: +# ANTHROPIC_API_KEY — for the agent. +# SDK_BOT_TOKEN — optional but recommended: PAT/App token with contents + +# pull-requests write here. Pushes and PRs made with +# GITHUB_TOKEN do not trigger other workflows. If unset, +# GITHUB_TOKEN is used and "Allow GitHub Actions to create +# and approve pull requests" must be enabled in the repo +# settings. +# Variables: +# AGENT_MODEL — optional model override. +# +# Testing: run it by hand with dry_run: true — it applies the bump and prints +# the diff and the agent prompt, but pushes nothing, opens no PR and skips the +# agent. +name: Native SDK bump + +on: + repository_dispatch: + types: [native-sdk-release] + workflow_dispatch: + inputs: + platform: + description: Native SDK that released + required: true + type: choice + options: [android, ios] + version: + description: Native version (blank = latest release) + required: false + type: string + dry_run: + description: Apply the bump and print the diff and agent prompt only — no push, PR or agent + required: false + type: boolean + default: false + workflow_call: + inputs: + platform: + required: true + type: string + version: + required: false + type: string + dry_run: + required: false + type: boolean + default: false + +# One bump per platform at a time; an Android and an iOS bump may run together. +concurrency: + group: native-sdk-bump-${{ github.event.client_payload.platform || inputs.platform }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + issues: write + +env: + BASE_BRANCH: main + +jobs: + bump: + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + GH_TOKEN: ${{ secrets.SDK_BOT_TOKEN || secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + steps: + - name: Resolve target + env: + PLATFORM: ${{ github.event.client_payload.platform || inputs.platform }} + VERSION: ${{ github.event.client_payload.version || inputs.version }} + run: | + case "$PLATFORM" in + android) REPO=superwall/Superwall-Android; LABEL=Android ;; + ios) REPO=superwall/Superwall-iOS; LABEL=iOS ;; + *) echo "::error::Unknown platform '$PLATFORM'"; exit 1 ;; + esac + if [ -z "$VERSION" ]; then + VERSION="$(gh release view --repo "$REPO" --json tagName -q .tagName)" + fi + VERSION="${VERSION#v}" + # The payload is interpolated into commands and the agent prompt, so + # accept nothing but a stable x.y.z. + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$VERSION' is not a stable x.y.z version"; exit 1 + fi + { + echo "PLATFORM=$PLATFORM" + echo "REPO=$REPO" + echo "LABEL=$LABEL" + echo "VERSION=$VERSION" + echo "BRANCH=native-sdk/$PLATFORM-$VERSION" + } >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_BRANCH }} + token: ${{ secrets.SDK_BOT_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Read current pin + run: | + if [ "$PLATFORM" = android ]; then + CURRENT="$(sed -nE "s/.*com\.superwall\.sdk:superwall-android:([^']+)'.*/\1/p" Plugins/Android/SuperwallSDK.androidlib/build.gradle)" + else + # Exact SPM pin once one exists; otherwise the Podfile requirement, + # which starts out as a floating range (`~> 4.0`). + CURRENT="$(sed -nE 's/.*Superwall-iOS", exact: "([^"]+)".*/\1/p' ci~/ios-harness/Package.swift)" + if [ -z "$CURRENT" ]; then + CURRENT="$(sed -nE "s/.*pod 'SuperwallKit', '([^']+)'.*/\1/p" Editor/SuperwallPostBuildProcessor.cs | head -1)" + fi + fi + [ -n "$CURRENT" ] || { echo "::error::Could not read the current $PLATFORM pin"; exit 1; } + echo "CURRENT=$CURRENT" >> "$GITHUB_ENV" + + SKIP=false + if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + && [ "$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -1)" = "$CURRENT" ]; then + echo "::notice::$PLATFORM is already at $CURRENT (>= $VERSION), nothing to do." + SKIP=true + elif gh api --silent "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" 2>/dev/null; then + echo "::notice::Branch $BRANCH already exists, a bump PR was already opened." + SKIP=true + fi + echo "SKIP=$SKIP" >> "$GITHUB_ENV" + + - name: Fetch native source and release notes + if: env.SKIP == 'false' + run: | + # Inside the workspace so the agent can read it, but excluded from git + # so the agent's commits never pick it up. + git clone --quiet --filter=blob:none "https://github.com/$REPO" .native-sdk + git -C .native-sdk checkout --quiet "$VERSION" 2>/dev/null \ + || git -C .native-sdk checkout --quiet "v$VERSION" + echo ".native-sdk/" >> .git/info/exclude + if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + python3 .github/scripts/native-changelog.py .native-sdk/CHANGELOG.md "$CURRENT" "$VERSION" \ + > "$RUNNER_TEMP/native-changelog.md" + else + # A floating requirement has no previous tag. Keep the released + # version's notes; the full history back to 0.0.0 does not belong + # in the prompt. + python3 .github/scripts/native-changelog.py .native-sdk/CHANGELOG.md 0.0.0 "$VERSION" \ + | awk 'BEGIN{n=0} /^## /{n++; if(n>1) exit} {print}' \ + > "$RUNNER_TEMP/native-changelog.md" + fi + [ -s "$RUNNER_TEMP/native-changelog.md" ] \ + || echo "_No CHANGELOG.md entries found between $CURRENT and $VERSION._" > "$RUNNER_TEMP/native-changelog.md" + cat "$RUNNER_TEMP/native-changelog.md" + + - name: Apply version bump + if: env.SKIP == 'false' + run: | + if [ "$PLATFORM" = android ]; then + sed -i -E "s/(com\.superwall\.sdk:superwall-android:)[^']+/\1$VERSION/" \ + Plugins/Android/SuperwallSDK.androidlib/build.gradle + # The comment above the dependency states the pin too. + sed -i -E "s/superwall-android $CURRENT/superwall-android $VERSION/" \ + Plugins/Android/SuperwallSDK.androidlib/build.gradle + else + # Podfile text (three copies) and the SPM harness must name the + # same version. `from:` becomes `exact:` the first time. + sed -i -E "s/(pod 'SuperwallKit', ')[^']+'/\1$VERSION'/" \ + Editor/SuperwallPostBuildProcessor.cs + sed -i -E "s#(Superwall-iOS\", )(from|exact): \"[^\"]+\"#\1exact: \"$VERSION\"#" \ + ci~/ios-harness/Package.swift + fi + + python3 .github/scripts/unreleased-entry.py CHANGELOG.md "### Dependencies" \ + "$LABEL: pins the native SDK to $VERSION (was \`$CURRENT\`). [View $LABEL SDK release notes](https://github.com/$REPO/releases/tag/$VERSION)." \ + "$LABEL: pins the native SDK" + git -c core.fileMode=false --no-pager diff + + - name: Open PR + if: env.SKIP == 'false' && env.DRY_RUN == 'false' + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b "$BRANCH" + git commit -am "Bump $LABEL SDK to $VERSION" + git push -u origin "$BRANCH" + + { + echo "Bumps the $LABEL SDK from \`$CURRENT\` to \`$VERSION\`. The package version is unchanged; the note is under \`[Unreleased]\`." + echo + echo "Opened automatically by \`native-sdk-bump.yml\` after [$REPO $VERSION](https://github.com/$REPO/releases/tag/$VERSION) was released. An agent then checks the release notes below for public API changes and pushes any integration work onto this PR." + echo + echo "
$LABEL release notes ($CURRENT → $VERSION)" + echo + cat "$RUNNER_TEMP/native-changelog.md" + echo + echo "
" + } > "$RUNNER_TEMP/pr-body.md" + gh pr create --base "$BASE_BRANCH" --head "$BRANCH" \ + --title "Bump $LABEL SDK to $VERSION" --body-file "$RUNNER_TEMP/pr-body.md" + echo "PR=$(gh pr view "$BRANCH" --json number -q .number)" >> "$GITHUB_ENV" + + # Toolchain for an Android bump: the agent can compile the androidlib. + # iOS and C# cannot be compiled on this runner. + - uses: actions/setup-java@v4 + if: env.SKIP == 'false' && env.DRY_RUN == 'false' && env.PLATFORM == 'android' + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + if: env.SKIP == 'false' && env.DRY_RUN == 'false' && env.PLATFORM == 'android' + with: + packages: 'platforms;android-35 build-tools;35.0.0' + - uses: gradle/actions/setup-gradle@v3 + if: env.SKIP == 'false' && env.DRY_RUN == 'false' && env.PLATFORM == 'android' + with: + gradle-version: '8.7' + + - name: Build agent prompt + if: env.SKIP == 'false' + run: | + if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + DIFF_HINT="Use \`git -C .native-sdk diff $CURRENT $VERSION -- \` to see exactly how its public API changed." + else + DIFF_HINT="The previous pin was a floating range (\`$CURRENT\`), not a tag, so there is no previous tag to diff against. Read the public API at tag $VERSION." + fi + if [ "$PLATFORM" = android ]; then + PLATFORM_NOTES="The Android bridge is Plugins/Android/SuperwallSDK.androidlib (Kotlin). Compile it with \`(cd ci~/android-harness && gradle :SuperwallSDK:assembleRelease -PagpVersion=8.5.2 --no-daemon)\`. C# cannot be compiled on this runner (no Unity); say so in the PR description." + else + PLATFORM_NOTES="The iOS bridge is Plugins/iOS/SuperwallUnityBridge.swift. The Podfile lines live in Editor/SuperwallPostBuildProcessor.cs and the SPM pin in ci~/ios-harness/Package.swift; keep them on the same version. This runner is Linux: Swift cannot be compiled here, and neither can C# (no Unity). Be careful and precise, and say in the PR description that iOS was not compiled locally." + fi + DELIM="PROMPT_$(openssl rand -hex 8)" + { + echo "AGENT_PROMPT<<$DELIM" + cat <\`: start from \`gh pr view $PR --json body -q .body\`, keep the existing text and release notes, and add an "Integration changes" section listing what you changed and why. If nothing beyond the bump is needed, change no files and only add that section explaining why no integration work is needed. + EOF + echo "$DELIM" + } >> "$GITHUB_ENV" + + - name: Dry-run summary + if: env.SKIP == 'false' && env.DRY_RUN == 'true' + run: | + echo "::notice::Dry run: nothing was pushed, no PR was opened, the agent did not run." + git -c core.fileMode=false --no-pager diff HEAD + echo "----- agent prompt -----" + printf '%s\n' "$AGENT_PROMPT" + + # Bash is unrestricted: the agent needs gradle, git and gh, and already + # has GH_TOKEN in its environment, so a command allowlist would not + # narrow what it can reach. + - name: Integrate with Claude Code + if: env.SKIP == 'false' && env.DRY_RUN == 'false' + id: agent + continue-on-error: true + uses: anthropics/claude-code-action@v1 + env: + CLAUDE_BRANCH: ${{ env.BRANCH }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.SDK_BOT_TOKEN || secrets.GITHUB_TOKEN }} + base_branch: ${{ env.BASE_BRANCH }} + prompt: ${{ env.AGENT_PROMPT }} + claude_args: | + --model ${{ vars.AGENT_MODEL || 'claude-opus-5-5' }} + --allowedTools "Read,Edit,Write,Glob,Grep,Bash,TodoWrite" + + # The bump PR stands on its own; make it obvious that nobody reviewed the + # release notes for API changes. + - name: Flag failed integration + if: env.SKIP == 'false' && env.DRY_RUN == 'false' && steps.agent.outcome == 'failure' + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh pr comment "$PR" --body "The integration agent failed ([run]($RUN_URL)). This PR only contains the version bump — check the release notes above for public API changes by hand before merging."