Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Global code owners - all files will be assigned to these users for review
* @aleksei-valiano @x401om

# Publishing to adaptyteam/AdaptySDK-CocoaPods-Specs: these files decide what the spec repo receives.
/.github/workflows/publish-cocoapods-specs.yml @aleksei-valiano @x401om @StanislavMayorov
/.github/workflows/test-cocoapods-specs.yml @aleksei-valiano @x401om @StanislavMayorov
/scripts/cocoapods-specs/ @aleksei-valiano @x401om @StanislavMayorov
131 changes: 131 additions & 0 deletions .github/workflows/publish-cocoapods-specs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Publish CocoaPods specs

# Publishes the Adapty pods to adaptyteam/AdaptySDK-CocoaPods-Specs. A release tag publishes with the
# tag as source; a manual run from a branch publishes its head pinned by commit (use a version that
# will never be a release, e.g. 4.3.0-SNAPSHOT.1). `stage` lints without secrets; `publish` waits for
# approval in the cocoapods-specs environment and pushes with a GitHub App token.
on:
push:
tags: ['[0-9]*.[0-9]*.[0-9]*']
workflow_dispatch: {}

permissions:
contents: read

jobs:
stage:
name: Lint and stage
runs-on: macos-26
timeout-minutes: 90
outputs:
has_new: ${{ steps.stage.outputs.has_new }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: Select Xcode
run: |
[ -d /Applications/Xcode_26.6.app ] || { echo "Xcode 26.6 is not on this runner image" >&2; exit 1; }
sudo xcode-select -s /Applications/Xcode_26.6.app

- name: Install CocoaPods
run: gem install cocoapods -v 1.17.0 --no-document

- name: Stage
id: stage
run: |
if [ "$GITHUB_REF_TYPE" = tag ]; then ref="$GITHUB_REF_NAME"; else ref="$GITHUB_SHA"; fi
scripts/cocoapods-specs/publish.sh stage --ref "$ref" --out "$RUNNER_TEMP/staged"
if [ -f "$RUNNER_TEMP/staged/manifest.json" ]; then
echo "has_new=true" >> "$GITHUB_OUTPUT"
else
echo "has_new=false" >> "$GITHUB_OUTPUT"
fi

- name: Summary
run: |
{
echo "### CocoaPods specs: $GITHUB_REF_TYPE $GITHUB_REF_NAME"
echo
echo "Commit: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"
echo
if [ -f "$RUNNER_TEMP/staged/manifest.json" ]; then
echo "Staged (check ref and SHA against the run header before approving):"
echo
echo '```json'
cat "$RUNNER_TEMP/staged/manifest.json"
echo '```'
else
echo "Nothing to publish."
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload staged specs
if: steps.stage.outputs.has_new == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: staged
path: ${{ runner.temp }}/staged
if-no-files-found: error
retention-days: 30

publish:
name: Push to the spec repo
needs: stage
if: needs.stage.outputs.has_new == 'true'
runs-on: macos-26
timeout-minutes: 15
environment: cocoapods-specs
env:
EXPECTED_SHA: ${{ github.sha }}
EXPECTED_REF: ${{ github.ref_name }}
EXPECTED_MODE: ${{ github.ref_type == 'tag' && 'tag' || 'commit' }}
# Job-level only: a run waiting for approval must not hold or cancel other runs.
concurrency:
group: cocoapods-specs-publish
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# Outside the workspace, so the artifact cannot overwrite the script.
- name: Download staged specs
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: staged
path: ${{ runner.temp }}/staged

- name: Verify staged specs
run: scripts/cocoapods-specs/publish.sh verify --from "$RUNNER_TEMP/staged"

- name: Create spec repo token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.SPECS_APP_CLIENT_ID }}
private-key: ${{ secrets.SPECS_APP_PRIVATE_KEY }}
owner: adaptyteam
repositories: AdaptySDK-CocoaPods-Specs
permission-contents: write

- name: Push
env:
TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
bot_id=$(GH_TOKEN="$TOKEN" gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
export GIT_AUTHOR_NAME="${APP_SLUG}[bot]" GIT_COMMITTER_NAME="${APP_SLUG}[bot]"
export GIT_AUTHOR_EMAIL="${bot_id}+${APP_SLUG}[bot]@users.noreply.github.com"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
echo "::add-mask::$auth"
# Scoped to the spec repo URL; never in a URL, argv or .git/config.
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="http.https://github.com/adaptyteam/AdaptySDK-CocoaPods-Specs.git.extraheader"
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth"
scripts/cocoapods-specs/publish.sh push --from "$RUNNER_TEMP/staged"
28 changes: 28 additions & 0 deletions .github/workflows/test-cocoapods-specs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Test CocoaPods specs scripts

on:
pull_request:
paths:
- 'scripts/cocoapods-specs/**'
- '.github/workflows/publish-cocoapods-specs.yml'
- '.github/workflows/test-cocoapods-specs.yml'

permissions:
contents: read

jobs:
test:
name: Script tests (bash 3.2)
runs-on: macos-26
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Syntax check
run: /bin/bash -n scripts/cocoapods-specs/publish.sh

- name: Tests
run: scripts/cocoapods-specs/test/run.sh
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ For update version read scripts/README.md

## Publishing to CocoaPods

Spec repo (4.x, the only channel after the trunk freeze on 2026-12-02): pushing a release tag runs the
**Publish CocoaPods specs** workflow, which needs an approval in the `cocoapods-specs` environment.
Manual and snapshot use: `scripts/cocoapods-specs/publish.sh` (see scripts/README.md).

Trunk (until 2026-12-02):

```bash
./scripts/publish_podspecs.sh [--skip-lint] [--skip-tests] [--max-retries N]
```
Expand Down
54 changes: 54 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,57 @@ This directory contains utility scripts for the Adapty iOS SDK project.
- You must be registered with CocoaPods trunk (`pod trunk register`, check with `pod trunk me`)

**Note**: Run `update_version.sh` first, so that every podspec carries the version being released; the podspecs point at the git tag of that version.

## Publishing to AdaptySDK-CocoaPods-Specs

CocoaPods trunk becomes read-only on 2026-12-02; the 4.x pods are published to
[adaptyteam/AdaptySDK-CocoaPods-Specs](https://github.com/adaptyteam/AdaptySDK-CocoaPods-Specs).

### Release (CI)

1. Push the release tag (`x.y.z`, equal to the podspec version). The tag must contain all seven podspecs.
2. The **Publish CocoaPods specs** workflow runs `stage`: it lints the pods that are not published yet
(about 20–40 minutes). Nothing is pushed in this job. Its summary shows the version, ref, SHA and pods.
3. The `publish` job waits for approval in the `cocoapods-specs` environment. A reviewer other than the
person who started the run checks the ref and SHA in the run header against the summary and approves.
4. `publish` verifies the staged specs and pushes them to the spec repo in one commit.
5. Check the result in the spec repo directly:
`git ls-remote https://github.com/adaptyteam/AdaptySDK-CocoaPods-Specs.git refs/heads/main` shows the
new head, and `Specs/<Pod>/x.y.z` is there on GitHub. Only if the spec repo is added locally:
`pod repo update && pod spec cat AdaptyPlugin --version=x.y.z`.
6. Tell the wrapper maintainers (React Native, Capacitor, Flutter, Unity) to bump their pod version.

A version that is already published from the same source is skipped, so re-running is safe. A version
published from another source is refused: published versions are immutable, publish a new version.

### Snapshot (CI, from a branch)

Run the workflow manually ("Run workflow") on `dev`, `master`, `release/*` or `hotfix/*` (`master` works
only once it contains the podspecs). The source is pinned to the branch head commit. A manual run from any
other branch only runs `stage`: it is a dry run, the environment refuses `publish`. **Commit a version that
will never be a release first** (for example `./scripts/update_version.sh 4.3.0-SNAPSHOT.1`): a snapshot
published as `4.3.0` blocks the real `4.3.0` release forever.

### Failures

| What happened | What to do |
|---|---|
| A pod fails lint in `stage` | Fix it, then a new tag (or a new snapshot version) |
| `stage` says the version is published from another source | Publish a new version |
| `publish` failed (network, token) | "Re-run failed jobs" within 30 days; another reviewer approves |
| `publish` was cancelled or stays pending (another run took the concurrency slot) | Reject stale waiting runs, then re-run the cancelled run's failed jobs within 30 days |
| `publish` is red after pushing (`main does not match the pushed commit`) | Another push landed right after ours; re-run — it reports Nothing to publish if the specs are in |
| Bug in the workflow or script on a release tag | Tags cannot be moved; an org admin publishes manually with the fixed script |

### Manual use

```bash
# Dry run for anyone: lint into a directory, push nothing
scripts/cocoapods-specs/publish.sh stage --ref 4.3.0 --out /tmp/staged
scripts/cocoapods-specs/publish.sh verify --from /tmp/staged

# Break-glass for org admins (the spec repo's main accepts pushes only from the CI app and admins)
scripts/cocoapods-specs/publish.sh --ref 4.3.0
```

Tests: `scripts/cocoapods-specs/test/run.sh`.
54 changes: 54 additions & 0 deletions scripts/cocoapods-specs/classify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env ruby
# Classifies pods of a version against a spec repo checkout: new, skip (published from the same
# source) or conflict (published from another source, or not a readable spec).
require 'json'
require_relative 'pods'

module Classify
def self.call(repo_dir, version, source, pods = AdaptyPods::ORDER)
pods.to_h { |pod| [pod, status(repo_dir, pod, version, source)] }
end

def self.status(repo_dir, pod, version, source)
return 'new' unless File.exist?(File.join(repo_dir, 'Specs', pod, version))

file = File.join(repo_dir, AdaptyPods.spec_path(pod, version))
return 'conflict' unless File.file?(file)

spec = JSON.parse(File.read(file))
return 'conflict' unless spec.is_a?(Hash)

spec['source'] == source ? 'skip' : 'conflict'
rescue JSON::ParserError, SystemCallError
'conflict'
end
end

if $PROGRAM_NAME == __FILE__
opts = {}
args = ARGV.dup
until args.empty?
arg = args.shift
value = args.shift
abort("#{arg} needs a value") if value.nil? || value.start_with?('--')
case arg
when '--repo' then opts[:repo] = value
when '--version' then opts[:version] = value
when '--source' then opts[:source] = value
when '--pods' then opts[:pods] = value.split(',')
else abort("unknown argument: #{arg}")
end
end
abort('--repo, --version and --source are required') unless opts[:repo] && opts[:version] && opts[:source]

pods = opts[:pods] || AdaptyPods::ORDER
unknown = pods - AdaptyPods::ORDER
abort("unknown pods: #{unknown.join(', ')}") unless unknown.empty?

begin
source = JSON.parse(opts[:source])
rescue JSON::ParserError => e
abort("--source: #{e.message}")
end
Classify.call(opts[:repo], opts[:version], source, pods).each { |pod, status| puts "#{pod} #{status}" }
end
19 changes: 19 additions & 0 deletions scripts/cocoapods-specs/pods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# The Adapty pods published to AdaptySDK-CocoaPods-Specs, in dependency order.
module AdaptyPods
ORDER = %w[AdaptyLogger AdaptyCSimdjson AdaptyCodable AdaptyUIBuilder Adapty AdaptyUI AdaptyPlugin].freeze
IOS_GIT = 'https://github.com/adaptyteam/AdaptySDK-iOS.git'.freeze

def self.spec_path(pod, version)
File.join('Specs', pod, version, "#{pod}.podspec.json")
end

def self.expected_source(mode, ref, sha)
case mode
when 'tag' then { 'git' => IOS_GIT, 'tag' => ref }
when 'commit' then { 'git' => IOS_GIT, 'commit' => sha }
else raise ArgumentError, "unknown mode: #{mode}"
end
end
end

puts AdaptyPods::ORDER if $PROGRAM_NAME == __FILE__
Loading
Loading