Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/scripts/changelog-entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Add a bullet to the "<subheading>" list of a CHANGELOG.md version section.

Usage: changelog-entry.py <changelog-path> <version> <subheading> <bullet> [replace-prefix]

If the top "## <version>" section already exists (a release is staged), the
bullet joins it, replacing a bullet that starts with replace-prefix if given
(so a second native bump in one release updates "Updates Android SDK to ..."
instead of listing both). Otherwise a new section is inserted above the newest.
"""
import re
import sys

VERSION_HEADING = re.compile(r"^##\s+\d+\.\d+\.\d+")


def main():
path, version, subheading, bullet = sys.argv[1:5]
replace_prefix = sys.argv[5] if len(sys.argv) > 5 else None
with open(path, encoding="utf-8") as f:
lines = f.read().split("\n")
bullet_line = bullet if bullet.startswith("- ") else f"- {bullet}"

top = next((i for i, l in enumerate(lines) if VERSION_HEADING.match(l)), len(lines))
if top < len(lines) and lines[top].split()[1] == version:
end = next(
(i for i in range(top + 1, len(lines)) if VERSION_HEADING.match(lines[i])),
len(lines),
)
if replace_prefix:
stale = next(
(i for i in range(top + 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(top + 1, end) if lines[i].strip() == subheading), None)
if sub is None:
lines[top + 1:top + 1] = ["", subheading, bullet_line]
else:
# Append after the last bullet of that sub-list.
j = sub + 1
while j < end and (lines[j].startswith("- ") or lines[j].startswith(" ")):
j += 1
lines.insert(j, bullet_line)
else:
lines[top:top] = [f"## {version}", "", subheading, bullet_line, ""]

write(path, lines)


def write(path, lines):
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))


if __name__ == "__main__":
main()
40 changes: 40 additions & 0 deletions .github/scripts/native-changelog.py
Original file line number Diff line number Diff line change
@@ -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 <changelog-path> <old-version> <new-version>

Prints every "## <version>" 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 "## <semver>" 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()
295 changes: 295 additions & 0 deletions .github/workflows/native-sdk-bump.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
# 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, pubspec version, CHANGELOG — committed
# and opened as a PR against develop.
# 2. Pi agent (shaftoe/pi-coding-agent-action) reads the native release notes
# for the whole skipped range and, if the public API changed, implements
# the plugin side and pushes it onto the same PR.
#
# 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:
# PI_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. Locally, local-ci (https://agent-ci.dev) can run it through a caller
# workflow that uses this one via workflow_call with dry_run: true.
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
# Same inputs for callers. Mainly so the workflow can be run locally with
# local-ci (https://agent-ci.dev), which cannot simulate dispatch events but
# does pass workflow_call inputs.
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: develop

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:
# Values flow between steps through $GITHUB_ENV rather than step
# outputs: same behaviour on GitHub, and it also works under local-ci,
# which resolves steps.*.outputs.* to empty strings.
- 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' android/build.gradle)"
else
CURRENT="$(sed -nE "s/.*s\.dependency 'SuperwallKit', '([^']+)'.*/\1/p" ios/superwallkit_flutter.podspec)"
fi
[ -n "$CURRENT" ] || { echo "::error::Could not read the current $PLATFORM pin"; exit 1; }
echo "CURRENT=$CURRENT" >> "$GITHUB_ENV"

SKIP=false
if [ "$(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
# Through the API rather than `git ls-remote`, which local-ci stubs to
# always succeed.
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
python3 .github/scripts/native-changelog.py .native-sdk/CHANGELOG.md "$CURRENT" "$VERSION" \
> "$RUNNER_TEMP/native-changelog.md"
[ -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\"/" android/build.gradle
else
# The podspec and the SPM manifest must pin the same version.
sed -i -E \
-e "s/('SuperwallKit', ')[^']+'/\1$VERSION'/" \
-e "s#(Superwall-iOS\.git\", exact: \")[^\"]+\"#\1$VERSION\"#" \
ios/superwallkit_flutter.podspec ios/superwallkit_flutter/Package.swift
fi

# Bump the patch version unless the pubspec version is not on pub.dev
# yet (a release is already staged on develop) — then this joins it.
# pub.dev rather than git tags: not every release gets tagged.
PLUGIN_VERSION="$(sed -nE 's/^version: *(.*)$/\1/p' pubspec.yaml)"
if curl -fsS https://pub.dev/api/packages/superwallkit_flutter \
| python3 -c 'import json,sys; v=sys.argv[1]; sys.exit(0 if any(x["version"]==v for x in json.load(sys.stdin)["versions"]) else 1)' "$PLUGIN_VERSION"; then
IFS=. read -r MA MI PA <<< "$PLUGIN_VERSION"
PLUGIN_VERSION="$MA.$MI.$((PA + 1))"
sed -i -E "s/^version: .*/version: $PLUGIN_VERSION/" pubspec.yaml
fi
echo "PLUGIN_VERSION=$PLUGIN_VERSION" >> "$GITHUB_ENV"

python3 .github/scripts/changelog-entry.py CHANGELOG.md "$PLUGIN_VERSION" "### Enhancements" \
"Updates $LABEL SDK to $VERSION [View $LABEL SDK release notes](https://github.com/$REPO/releases/tag/$VERSION)." \
"Updates $LABEL SDK to"
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\` and superwallkit_flutter to \`$PLUGIN_VERSION\`."
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 "<details><summary>$LABEL release notes ($CURRENT → $VERSION)</summary>"
echo
cat "$RUNNER_TEMP/native-changelog.md"
echo
echo "</details>"
} > "$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 the agent to validate its changes with.
- uses: actions/setup-node@v4
if: env.SKIP == 'false' && env.DRY_RUN == 'false'
with:
node-version: 24
- uses: actions/setup-java@v4
if: env.SKIP == 'false' && env.DRY_RUN == 'false'
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
if: env.SKIP == 'false' && env.DRY_RUN == 'false'
with:
channel: stable
cache: true
- name: flutter pub get
if: env.SKIP == 'false' && env.DRY_RUN == 'false'
run: flutter pub get

- name: Build agent prompt
if: env.SKIP == 'false'
run: |
if [ "$PLATFORM" = android ]; then
PLATFORM_NOTES="The Android host lives in android/src/main/kotlin. Besides flutter analyze and flutter test, check the Android side compiles with: (cd example && flutter build apk --debug)."
else
PLATFORM_NOTES="The iOS host lives in ios/superwallkit_flutter/Sources/superwallkit_flutter. This runner is Linux: Swift cannot be compiled here, so be careful and precise with Swift and say in the PR description that iOS was not compiled locally."
fi
DELIM="PROMPT_$(openssl rand -hex 8)"
{
echo "AGENT_PROMPT<<$DELIM"
cat <<EOF
You are integrating a native Superwall SDK release into Superwall-Flutter (the superwallkit_flutter plugin), which wraps Superwall-Android and Superwall-iOS.

$REPO was bumped from $CURRENT to $VERSION. That mechanical bump (native pin, pubspec version = $PLUGIN_VERSION, CHANGELOG entry) is already committed on PR #$PR (branch $BRANCH), which is checked out. Do not undo it.

The native SDK source is at .native-sdk/, checked out at tag $VERSION with full history. Use \`git -C .native-sdk diff $CURRENT $VERSION -- <paths>\` to see exactly how its public API changed. Do not modify anything under .native-sdk/ or .context/.

Native release notes for every version in the range:

$(cat "$RUNNER_TEMP/native-changelog.md")

Your job:
1. Read CLAUDE.md first and follow its "Adding New SDK Methods" workflow: models and APIs are defined in pigeons/configure.dart (P-prefixed), regenerated with \`dart run pigeon --input pigeons/configure.dart\` (never hand-edit generated files), implemented in the native hosts, then exposed in lib/src/public and exported from lib/superwallkit_flutter.dart.
2. Decide whether the plugin needs changes beyond the version bump. Work is needed for: new public methods, properties or options; changed signatures; new or renamed delegate callbacks, events or enum cases that the plugin maps; deprecated or removed APIs the plugin calls; behaviour changes the plugin or its docs describe. Internal fixes and performance work need nothing.
3. If work is needed, implement it end to end in Dart and the $LABEL host, with sensible behaviour on the other platform when the feature is $LABEL-only (document it as such). Add or update tests next to the existing ones. Validate with flutter analyze and flutter test. $PLATFORM_NOTES
4. Update docs (README.md, CLAUDE.md) that state the pinned $LABEL version $CURRENT so they say $VERSION — only where they describe the current pin, not historical changelog entries.
5. Add customer-facing bullets for anything you add to the "## $PLUGIN_VERSION" section of CHANGELOG.md. If you add public API, bump the minor version instead (pubspec.yaml and that CHANGELOG heading) unless the section is already a minor/major release.
6. Never commit code that does not compile. If part of the work cannot be done safely, leave it out and list it in the PR description as follow-up.
7. Push with the update_pull_request tool for PR #$PR — never create_pull_request, never push to $BASE_BRANCH, never edit .github/. Update the PR description: 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"

- name: Integrate with Pi
if: env.SKIP == 'false' && env.DRY_RUN == 'false'
id: pi
continue-on-error: true
uses: shaftoe/pi-coding-agent-action@v2.29.1
with:
github_token: ${{ secrets.SDK_BOT_TOKEN || secrets.GITHUB_TOKEN }}
provider: anthropic
model: ${{ vars.PI_MODEL || 'claude-opus-5-5' }}
token: ${{ secrets.ANTHROPIC_API_KEY }}
thinking_level: high
auto_compaction: true
pr_number: ${{ env.PR }}
prompt: ${{ env.AGENT_PROMPT }}

# 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.pi.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."