diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 89d8ec8b7..ebfc90a95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/publish-cocoapods-specs.yml b/.github/workflows/publish-cocoapods-specs.yml new file mode 100644 index 000000000..8541edca5 --- /dev/null +++ b/.github/workflows/publish-cocoapods-specs.yml @@ -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" diff --git a/.github/workflows/test-cocoapods-specs.yml b/.github/workflows/test-cocoapods-specs.yml new file mode 100644 index 000000000..0f1141540 --- /dev/null +++ b/.github/workflows/test-cocoapods-specs.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 66b5741f8..7272cef59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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] ``` diff --git a/scripts/README.md b/scripts/README.md index 895587f2c..4731ddd1a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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//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`. diff --git a/scripts/cocoapods-specs/classify.rb b/scripts/cocoapods-specs/classify.rb new file mode 100755 index 000000000..87daba43d --- /dev/null +++ b/scripts/cocoapods-specs/classify.rb @@ -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 diff --git a/scripts/cocoapods-specs/pods.rb b/scripts/cocoapods-specs/pods.rb new file mode 100644 index 000000000..8697994df --- /dev/null +++ b/scripts/cocoapods-specs/pods.rb @@ -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__ diff --git a/scripts/cocoapods-specs/publish.sh b/scripts/cocoapods-specs/publish.sh new file mode 100755 index 000000000..36a90a662 --- /dev/null +++ b/scripts/cocoapods-specs/publish.sh @@ -0,0 +1,235 @@ +#!/bin/bash +# Publishes the Adapty pods of this AdaptySDK-iOS checkout to the AdaptySDK-CocoaPods-Specs repo. +# stage --ref REF --out DIR lint the new pods of REF into DIR; pushes nothing +# verify --from DIR check a staged DIR, no network +# push --from DIR verify, then add DIR's new specs to the spec repo in one commit and one push +# --ref REF manual run: stage into a temp dir, then push (needs push access) +# Options: --specs-url URL, --ios-url URL (where release tags are checked), --skip-import-validation (stage). +# A release tag publishes with the tag as source; any other ref pins the source to its commit. +set -euo pipefail +export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SPECS_URL='https://github.com/adaptyteam/AdaptySDK-CocoaPods-Specs.git' +IOS_URL='https://github.com/adaptyteam/AdaptySDK-iOS.git' +COMMAND='' +FROM='' +NEW_PODS=() +IOS_REPO='' +STAGE_REPO='adapty-specs-stage' +REF='' +OUT='' +PUSH_ARGS=() +PODS=() + +die() { echo "error: $*" >&2; exit 1; } +need() { [[ $# -ge 2 && -n "$2" && "$2" != -* ]] || die "$1 needs a value"; } +json_field() { ruby -rjson -e 'puts JSON.parse($stdin.read).fetch(ARGV[0])' "$1"; } +spec_rel() { echo "Specs/$1/$2/$1.podspec.json"; } + +# Commit a release tag points to on a remote (peeled for annotated tags); empty if absent. +remote_tag_sha() { + local refs peeled + refs=$(git ls-remote "$1" "refs/tags/$2" "refs/tags/$2^{}") || die "cannot list tags of $1" + peeled=$(printf '%s\n' "$refs" | awk -v r="refs/tags/$2^{}" '$2 == r { print $1 }') + if [[ -n "$peeled" ]]; then + echo "$peeled" + else + printf '%s\n' "$refs" | awk -v r="refs/tags/$2" '$2 == r { print $1 }' + fi +} + +# Fills NEW_PODS with the pods not yet in the spec repo checkout; dies on a foreign source. +classify_new() { + local statuses pod status + local args=(--repo "$1" --version "$2" --source "$3") + [[ -z "${4:-}" ]] || args+=(--pods "$4") + statuses=$(ruby "$SCRIPT_DIR/classify.rb" "${args[@]}") + NEW_PODS=() + while read -r pod status; do + [[ -n "$pod" ]] || continue + case "$status" in + new) NEW_PODS+=("$pod") ;; + skip) echo "skip: $pod $2 is already published from the same source" ;; + *) die "$pod $2 is already published from another source; published versions are immutable" ;; + esac + done <<< "$statuses" +} + +# CocoaPods in the throwaway repos dir; the local commits it makes there are never pushed. +stage_pod() { + CP_REPOS_DIR="$WORK/repos" \ + GIT_AUTHOR_NAME=publish.sh GIT_AUTHOR_EMAIL=publish.sh@localhost \ + GIT_COMMITTER_NAME=publish.sh GIT_COMMITTER_EMAIL=publish.sh@localhost \ + pod "$@" +} + +stage_phase() { + [[ -n "$REF" && -n "$OUT" ]] || die "stage needs --ref and --out" + [[ ! -e "$OUT" ]] || die "$OUT already exists" + local sha version='' v mode source clone pod spec rel pods_out + IOS_REPO=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) || die "$SCRIPT_DIR is not inside a git checkout" + pods_out=$(ruby "$SCRIPT_DIR/pods.rb") || die "cannot read the pod list" + PODS=() + while IFS= read -r pod; do [[ -z "$pod" ]] || PODS+=("$pod"); done <<< "$pods_out" + [[ ${#PODS[@]} -gt 0 ]] || die "empty pod list" + sha=$(git -C "$IOS_REPO" rev-parse --verify --quiet "$REF^{commit}") || die "unknown ref $REF" + + # Podspecs are read at the ref, not from the working tree, and must share one version. + for pod in "${PODS[@]}"; do + git -C "$IOS_REPO" show "$sha:$pod.podspec" > "$WORK/$pod.podspec" 2>/dev/null \ + || die "missing $pod.podspec at $REF" + pod ipc spec "$WORK/$pod.podspec" > "$WORK/$pod.orig.json" + v=$(json_field version < "$WORK/$pod.orig.json") + if [[ -z "$version" ]]; then + version=$v + elif [[ "$v" != "$version" ]]; then + die "$pod is $v, expected $version (podspec versions drifted at $REF)" + fi + done + + # Lint clones the source from GitHub, so it must be reachable there before minutes of linting. + if git -C "$IOS_REPO" show-ref --verify --quiet "refs/tags/$REF"; then + mode=tag + [[ "$REF" == "$version" ]] || die "tag $REF does not match podspec version $version" + [[ "$(remote_tag_sha "$IOS_URL" "$REF")" == "$sha" ]] || die "tag $REF on $IOS_URL does not point to $sha" + else + mode=commit + # Uses the locally known remote branches; run `git fetch` if this looks stale. + [[ -n "$(git -C "$IOS_REPO" branch -r --contains "$sha")" ]] \ + || die "commit $sha is not on any remote branch; push it first" + fi + source=$(ruby -r"$SCRIPT_DIR/pods" -rjson -e 'puts JSON.generate(AdaptyPods.expected_source(*ARGV))' \ + "$mode" "$REF" "$sha") + + for pod in "${PODS[@]}"; do + spec="$WORK/$pod.podspec.json" + if [[ "$mode" == commit ]]; then + ruby "$SCRIPT_DIR/rewrite_source.rb" --commit "$sha" < "$WORK/$pod.orig.json" > "$spec" + else + cp "$WORK/$pod.orig.json" "$spec" + fi + ruby -rjson -e 'exit(JSON.parse(File.read(ARGV[0]))["source"] == JSON.parse(ARGV[1]))' "$spec" "$source" \ + || die "$pod.podspec source is not $source" + done + + stage_pod repo add "$STAGE_REPO" "$SPECS_URL" main > /dev/null + clone="$WORK/repos/$STAGE_REPO" + # `pod repo push` pulls before every pod; rebase keeps the local commits when main moves. + git -C "$clone" config pull.rebase true + + classify_new "$clone" "$version" "$source" + if [[ ${#NEW_PODS[@]} -eq 0 ]]; then + echo "Nothing to publish: $version is already published from $mode $REF" + return 0 + fi + + echo "Staging $version from $REF ($mode source, $sha): ${NEW_PODS[*]}" + for pod in "${NEW_PODS[@]}"; do + echo "lint: $pod $version" + # Only the spec repo is a source: a missing Adapty dependency must fail, not resolve from trunk. + stage_pod repo push "$STAGE_REPO" "$WORK/$pod.podspec.json" \ + --sources="$STAGE_REPO" \ + --local-only --no-overwrite --allow-warnings --skip-tests \ + --commit-message="[Add] $pod ($version)" \ + ${PUSH_ARGS[@]+"${PUSH_ARGS[@]}"} + done + + for pod in "${NEW_PODS[@]}"; do + rel=$(spec_rel "$pod" "$version") + mkdir -p "$OUT/$(dirname "$rel")" + cp "$clone/$rel" "$OUT/$rel" + done + ruby -rjson -e 'puts JSON.pretty_generate("version" => ARGV[0], "mode" => ARGV[1], "ref" => ARGV[2], + "sha" => ARGV[3], "pods" => ARGV[4..])' \ + "$version" "$mode" "$REF" "$sha" "${NEW_PODS[@]}" > "$OUT/manifest.json" + ruby "$SCRIPT_DIR/verify_artifact.rb" "$OUT" > /dev/null + echo "Staged $version: ${NEW_PODS[*]} -> $OUT" +} + +verify_phase() { + [[ -n "$FROM" ]] || die "verify needs --from DIR" + ruby "$SCRIPT_DIR/verify_artifact.rb" "$FROM" +} + +push_phase() { + [[ -n "$FROM" ]] || die "push needs --from DIR" + local manifest version mode ref sha source pods_csv clone attempt=1 pod rel body + manifest=$(ruby "$SCRIPT_DIR/verify_artifact.rb" "$FROM") || exit 1 + version=$(json_field version <<< "$manifest") + mode=$(json_field mode <<< "$manifest") + ref=$(json_field ref <<< "$manifest") + sha=$(json_field sha <<< "$manifest") + pods_csv=$(ruby -rjson -e 'puts JSON.parse($stdin.read).fetch("pods").join(",")' <<< "$manifest") + source=$(ruby -r"$SCRIPT_DIR/pods" -rjson -e 'puts JSON.generate(AdaptyPods.expected_source(*ARGV))' \ + "$mode" "$ref" "$sha") + + if [[ "$mode" == tag ]]; then + [[ "$(remote_tag_sha "$IOS_URL" "$ref")" == "$sha" ]] || die "tag $ref on $IOS_URL does not point to $sha" + fi + + body="Source: $mode $ref ($sha)" + if [[ -n "${GITHUB_RUN_ID:-}" ]]; then + body="$body"$'\n'"Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + fi + + clone="$WORK/specs" + git clone --quiet --branch main "$SPECS_URL" "$clone" || die "cannot clone $SPECS_URL" + while :; do + git -C "$clone" fetch --quiet origin main + git -C "$clone" reset --quiet --hard origin/main + classify_new "$clone" "$version" "$source" "$pods_csv" + if [[ ${#NEW_PODS[@]} -eq 0 ]]; then + echo "Nothing to publish: $version is already published from $mode $ref" + return 0 + fi + for pod in "${NEW_PODS[@]}"; do + rel=$(spec_rel "$pod" "$version") + mkdir -p "$clone/$(dirname "$rel")" + cp "$FROM/$rel" "$clone/$rel" + done + git -C "$clone" add Specs + git -C "$clone" commit --quiet -m "[Add] Adapty pods ($version)" -m "$body" + if git -C "$clone" push --quiet origin HEAD:refs/heads/main; then + break + fi + [[ $attempt -lt 3 ]] || die "cannot push to $SPECS_URL after 3 attempts" + attempt=$((attempt + 1)) + echo "push rejected, retrying ($attempt/3)" >&2 + done + + [[ "$(git -C "$clone" ls-remote origin refs/heads/main | cut -f1)" == "$(git -C "$clone" rev-parse HEAD)" ]] \ + || die "$SPECS_URL main does not match the pushed commit" + echo "Published $version ($mode $ref, $sha): ${NEW_PODS[*]}" +} + +while [[ $# -gt 0 ]]; do + case $1 in + stage|verify|push) [[ -z "$COMMAND" ]] || die "unexpected $1"; COMMAND=$1; shift ;; + --ref) need "$@"; REF=$2; shift 2 ;; + --out) need "$@"; OUT=$2; shift 2 ;; + --from) need "$@"; FROM=$2; shift 2 ;; + --specs-url) need "$@"; SPECS_URL=$2; shift 2 ;; + --ios-url) need "$@"; IOS_URL=$2; shift 2 ;; + --skip-import-validation) PUSH_ARGS+=(--skip-import-validation); shift ;; + -h|--help) sed -n '2,8p' "$0"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +case "$COMMAND" in + stage) stage_phase ;; + verify) verify_phase ;; + push) push_phase ;; + '') + [[ -n "$REF" ]] || die "a command or --ref is required (see --help)" + OUT="$WORK/staged" + stage_phase + [[ -f "$OUT/manifest.json" ]] || exit 0 + FROM="$OUT" + push_phase + ;; +esac diff --git a/scripts/cocoapods-specs/rewrite_source.rb b/scripts/cocoapods-specs/rewrite_source.rb new file mode 100755 index 000000000..f194f0153 --- /dev/null +++ b/scripts/cocoapods-specs/rewrite_source.rb @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# Reads a podspec JSON on stdin and prints it with `source` pinned to a commit, +# for versions that have no tag in AdaptySDK-iOS. Without --commit prints it unchanged. +require 'json' + +def rewrite_source(spec, commit) + return spec if commit.nil? + + source = spec['source'] + git = source['git'] if source.is_a?(Hash) + raise ArgumentError, "#{spec['name']}: source has no git URL" if git.nil? + + spec.merge('source' => { 'git' => git, 'commit' => commit }) +end + +if $PROGRAM_NAME == __FILE__ + commit = nil + args = ARGV.dup + until args.empty? + arg = args.shift + case arg + when '--commit' + commit = args.shift + abort('--commit needs a SHA') if commit.nil? || commit.start_with?('-') + else abort("unknown argument: #{arg}") + end + end + + begin + spec = JSON.parse($stdin.read) + abort('podspec JSON must be an object') unless spec.is_a?(Hash) + puts JSON.pretty_generate(rewrite_source(spec, commit)) + rescue ArgumentError, JSON::ParserError => e + abort(e.message) + end +end diff --git a/scripts/cocoapods-specs/test/classify_test.rb b/scripts/cocoapods-specs/test/classify_test.rb new file mode 100644 index 000000000..1489400ed --- /dev/null +++ b/scripts/cocoapods-specs/test/classify_test.rb @@ -0,0 +1,100 @@ +require 'minitest/autorun' +require 'json' +require 'open3' +require 'tmpdir' +require 'fileutils' +require_relative '../classify' + +class ClassifyTest < Minitest::Test + SHA = '28038b667097a259d2762ae1e37a8de0593ada3a' + SCRIPT = File.expand_path('../classify.rb', __dir__) + + def setup + @repo = Dir.mktmpdir + @tag = AdaptyPods.expected_source('tag', '4.3.0', SHA) + end + + def teardown + FileUtils.remove_entry(@repo) + end + + def publish(pod, version, source) + path = File.join(@repo, AdaptyPods.spec_path(pod, version)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.generate('name' => pod, 'version' => version, 'source' => source)) + end + + def test_all_new_in_empty_repo + result = Classify.call(@repo, '4.3.0', @tag) + assert_equal AdaptyPods::ORDER, result.keys + assert_equal ['new'], result.values.uniq + end + + def test_mixed_statuses + publish('AdaptyLogger', '4.3.0', @tag) + publish('AdaptyCSimdjson', '4.3.0', AdaptyPods.expected_source('commit', 'dev', SHA)) + result = Classify.call(@repo, '4.3.0', @tag) + assert_equal 'skip', result['AdaptyLogger'] + assert_equal 'conflict', result['AdaptyCSimdjson'] + assert_equal 'new', result['AdaptyCodable'] + end + + def test_directory_without_spec_is_conflict + FileUtils.mkdir_p(File.join(@repo, 'Specs', 'Adapty', '4.3.0')) + assert_equal 'conflict', Classify.call(@repo, '4.3.0', @tag)['Adapty'] + end + + def test_unparsable_spec_is_conflict + path = File.join(@repo, AdaptyPods.spec_path('Adapty', '4.3.0')) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, 'not json') + assert_equal 'conflict', Classify.call(@repo, '4.3.0', @tag)['Adapty'] + end + + def test_non_object_spec_is_conflict + path = File.join(@repo, AdaptyPods.spec_path('Adapty', '4.3.0')) + FileUtils.mkdir_p(File.dirname(path)) + ['null', '[]', '1', '"x"'].each do |content| + File.write(path, content) + assert_equal 'conflict', Classify.call(@repo, '4.3.0', @tag)['Adapty'], content + end + end + + def test_other_versions_do_not_matter + publish('Adapty', '4.2.2', @tag) + assert_equal 'new', Classify.call(@repo, '4.3.0', @tag)['Adapty'] + end + + def test_prerelease_version + source = AdaptyPods.expected_source('commit', 'dev', SHA) + publish('Adapty', '4.3.0-SNAPSHOT.1', source) + assert_equal 'skip', Classify.call(@repo, '4.3.0-SNAPSHOT.1', source)['Adapty'] + end + + def test_cli_prints_statuses_in_order + publish('AdaptyLogger', '4.3.0', @tag) + out, err, status = Open3.capture3('ruby', SCRIPT, '--repo', @repo, '--version', '4.3.0', + '--source', JSON.generate(@tag), '--pods', 'AdaptyCodable,AdaptyLogger') + assert status.success?, err + assert_equal "AdaptyCodable new\nAdaptyLogger skip\n", out + end + + def test_cli_rejects_unknown_pod + _out, err, status = Open3.capture3('ruby', SCRIPT, '--repo', @repo, '--version', '4.3.0', + '--source', JSON.generate(@tag), '--pods', 'Evil') + refute status.success? + assert_includes err, 'unknown pods: Evil' + end + + def test_cli_rejects_missing_arguments + _out, err, status = Open3.capture3('ruby', SCRIPT, '--repo', @repo) + refute status.success? + assert_includes err, 'required' + end + + def test_cli_rejects_invalid_source + _out, err, status = Open3.capture3('ruby', SCRIPT, '--repo', @repo, '--version', '4.3.0', '--source', '{') + refute status.success? + assert_includes err, '--source' + end +end diff --git a/scripts/cocoapods-specs/test/pods_test.rb b/scripts/cocoapods-specs/test/pods_test.rb new file mode 100644 index 000000000..490a22259 --- /dev/null +++ b/scripts/cocoapods-specs/test/pods_test.rb @@ -0,0 +1,35 @@ +require 'minitest/autorun' +require 'open3' +require_relative '../pods' + +class PodsTest < Minitest::Test + SHA = '28038b667097a259d2762ae1e37a8de0593ada3a' + + def test_order_is_dependency_order + assert_equal %w[AdaptyLogger AdaptyCSimdjson AdaptyCodable AdaptyUIBuilder Adapty AdaptyUI AdaptyPlugin], + AdaptyPods::ORDER + end + + def test_spec_path + assert_equal 'Specs/Adapty/4.3.0-SNAPSHOT.1/Adapty.podspec.json', AdaptyPods.spec_path('Adapty', '4.3.0-SNAPSHOT.1') + end + + def test_expected_source_for_tag + assert_equal({ 'git' => AdaptyPods::IOS_GIT, 'tag' => '4.3.0' }, AdaptyPods.expected_source('tag', '4.3.0', SHA)) + end + + def test_expected_source_for_commit + assert_equal({ 'git' => AdaptyPods::IOS_GIT, 'commit' => SHA }, + AdaptyPods.expected_source('commit', 'origin/dev', SHA)) + end + + def test_expected_source_rejects_unknown_mode + assert_raises(ArgumentError) { AdaptyPods.expected_source('branch', 'dev', SHA) } + end + + def test_cli_prints_order + out, status = Open3.capture2('ruby', File.expand_path('../pods.rb', __dir__)) + assert status.success? + assert_equal AdaptyPods::ORDER, out.split("\n") + end +end diff --git a/scripts/cocoapods-specs/test/push_test.rb b/scripts/cocoapods-specs/test/push_test.rb new file mode 100644 index 000000000..c51bfa7ad --- /dev/null +++ b/scripts/cocoapods-specs/test/push_test.rb @@ -0,0 +1,253 @@ +require 'minitest/autorun' +require 'json' +require 'open3' +require 'tmpdir' +require 'fileutils' +require_relative '../pods' + +class PushTest < Minitest::Test + SCRIPT = File.expand_path('../publish.sh', __dir__) + # nil unsets the variable for the child process. + ENV_VARS = { + 'GIT_AUTHOR_NAME' => 'Test', 'GIT_AUTHOR_EMAIL' => 'test@example.com', + 'GIT_COMMITTER_NAME' => 'Test', 'GIT_COMMITTER_EMAIL' => 'test@example.com', + 'EXPECTED_SHA' => nil, 'EXPECTED_MODE' => nil, 'EXPECTED_REF' => nil, 'GITHUB_RUN_ID' => nil + }.freeze + + def setup + @tmp = Dir.mktmpdir + @specs = File.join(@tmp, 'specs.git') + git('init', '--quiet', '--bare', '--initial-branch=main', @specs) + seed = File.join(@tmp, 'seed') + git('clone', '--quiet', @specs, seed) + FileUtils.mkdir_p(File.join(seed, 'Specs')) + File.write(File.join(seed, 'Specs', '.gitkeep'), '') + git('-C', seed, 'add', '.') + git('-C', seed, 'commit', '--quiet', '-m', 'init') + git('-C', seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + + ios = File.join(@tmp, 'ios') + git('init', '--quiet', ios) + git('-C', ios, 'commit', '--quiet', '--allow-empty', '-m', 'release') + @sha = git('-C', ios, 'rev-parse', 'HEAD').strip + git('-C', ios, 'tag', '-a', '9.9.9', '-m', '9.9.9') + git('-C', ios, 'tag', '8.8.8') + git('-C', ios, 'commit', '--quiet', '--allow-empty', '-m', 'later') + @other_sha = git('-C', ios, 'rev-parse', 'HEAD').strip + @ios_url = "file://#{ios}" + @count = 0 + end + + def teardown + FileUtils.remove_entry(@tmp) + end + + def git(*args) + out, err, status = Open3.capture3(ENV_VARS, 'git', *args) + raise "git #{args.join(' ')}: #{err}" unless status.success? + + out + end + + def stage(version: '9.9.9', mode: 'tag', ref: '9.9.9', sha: @sha, pods: AdaptyPods::ORDER) + @count += 1 + dir = File.join(@tmp, "staged-#{@count}") + FileUtils.mkdir_p(dir) + source = AdaptyPods.expected_source(mode, ref, sha) + pods.each do |pod| + path = File.join(dir, AdaptyPods.spec_path(pod, version)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate('name' => pod, 'version' => version, 'source' => source)) + end + File.write(File.join(dir, 'manifest.json'), + JSON.generate('version' => version, 'mode' => mode, 'ref' => ref, 'sha' => sha, 'pods' => pods)) + dir + end + + def run_script(*args, env: {}) + Open3.capture3(ENV_VARS.merge(env), '/bin/bash', SCRIPT, *args) + end + + def push(dir, env: {}) + run_script('push', '--from', dir, '--specs-url', "file://#{@specs}", '--ios-url', @ios_url, env: env) + end + + def log + git("--git-dir=#{@specs}", 'log', '--format=%s', 'main').lines.map(&:strip) + end + + def published?(pod, version) + !git("--git-dir=#{@specs}", 'ls-tree', '--name-only', 'main', '--', AdaptyPods.spec_path(pod, version)).strip.empty? + end + + def test_annotated_tag + _out, err, status = push(stage) + assert status.success?, err + assert_equal ['[Add] Adapty pods (9.9.9)', 'init'], log + assert(AdaptyPods::ORDER.all? { |pod| published?(pod, '9.9.9') }) + end + + def test_lightweight_tag + _out, err, status = push(stage(version: '8.8.8', ref: '8.8.8')) + assert status.success?, err + assert_equal ['[Add] Adapty pods (8.8.8)', 'init'], log + end + + def test_commit_mode + _out, err, status = push(stage(version: '9.9.10-SNAPSHOT.1', mode: 'commit', ref: 'origin/dev', sha: @other_sha)) + assert status.success?, err + assert published?('AdaptyPlugin', '9.9.10-SNAPSHOT.1') + end + + def test_second_run_is_a_noop + push(stage) + out, err, status = push(stage) + assert status.success?, err + assert_includes out, 'Nothing to publish' + assert_equal 2, log.size + end + + def test_adds_only_missing_pods + push(stage(pods: AdaptyPods::ORDER.first(3))) + out, err, status = push(stage) + assert status.success?, err + assert_includes out, 'skip: AdaptyLogger 9.9.9' + assert_equal 3, log.size + assert published?('AdaptyPlugin', '9.9.9') + end + + def test_refuses_foreign_source_without_pushing + push(stage(mode: 'commit', ref: 'origin/dev')) + _out, err, status = push(stage) + refute status.success? + assert_includes err, 'another source' + assert_equal 2, log.size + end + + def test_refuses_tag_on_other_commit + _out, err, status = push(stage(sha: @other_sha)) + refute status.success? + assert_includes err, 'does not point to' + assert_equal 1, log.size + end + + def test_refuses_missing_tag + _out, err, status = push(stage(version: '7.7.7', ref: '7.7.7')) + refute status.success? + assert_includes err, 'does not point to' + end + + def test_rejects_bad_artifact_before_network + dir = stage + File.write(File.join(dir, 'extra.txt'), 'x') + _out, err, status = run_script('push', '--from', dir, '--specs-url', 'file:///nonexistent.git', '--ios-url', @ios_url) + refute status.success? + assert_includes err, 'unexpected files' + end + + def test_rejects_artifact_of_another_run + _out, err, status = push(stage, env: { 'EXPECTED_SHA' => @other_sha, 'EXPECTED_MODE' => 'tag', 'EXPECTED_REF' => '9.9.9' }) + refute status.success? + assert_includes err, 'is not the run' + assert_equal 1, log.size + end + + def test_retries_rejected_push + marker = File.join(@tmp, 'rejected-once') + hook = File.join(@specs, 'hooks', 'pre-receive') + File.write(hook, "#!/bin/sh\nif [ ! -f '#{marker}' ]; then touch '#{marker}'; echo 'simulated rejection' >&2; exit 1; fi\n") + File.chmod(0o755, hook) + _out, err, status = push(stage) + assert status.success?, err + assert_includes err, 'retrying' + assert_equal ['[Add] Adapty pods (9.9.9)', 'init'], log + end + + # A commit on refs/competing/main that adds the given specs on top of the current main. + def competing_commit(specs) + clone = File.join(@tmp, 'competitor') + git('clone', '--quiet', '--branch', 'main', @specs, clone) + specs.each do |pod, version, source| + path = File.join(clone, AdaptyPods.spec_path(pod, version)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate('name' => pod, 'version' => version, 'source' => source)) + end + git('-C', clone, 'add', 'Specs') + git('-C', clone, 'commit', '--quiet', '-m', 'competing') + git('-C', clone, 'push', '--quiet', 'origin', 'HEAD:refs/competing/main') + git('-C', clone, 'rev-parse', 'HEAD').strip + end + + # On the first push only, another publisher's commit lands on main, then ours is rejected. + # Ref updates are forbidden inside receive-pack's quarantine unless GIT_QUARANTINE_PATH is unset. + def race_on_first_push(competing_sha) + calls = File.join(@tmp, 'pre-receive-calls') + hook = File.join(@specs, 'hooks', 'pre-receive') + File.write(hook, <<~SH) + #!/bin/sh + echo call >> '#{calls}' + if [ "$(wc -l < '#{calls}')" -eq 1 ]; then + (unset GIT_QUARANTINE_PATH; git update-ref refs/heads/main #{competing_sha}) || exit 2 + echo 'simulated concurrent push' >&2 + exit 1 + fi + SH + File.chmod(0o755, hook) + calls + end + + def test_retry_publishes_on_top_of_a_concurrent_commit + competing = competing_commit([['AdaptyLogger', '1.0.0', AdaptyPods.expected_source('tag', '1.0.0', @sha)]]) + calls = race_on_first_push(competing) + out, err, status = push(stage) + assert status.success?, err + assert_includes err, 'retrying (2/3)' + assert_equal 2, File.readlines(calls).size + assert_equal ['[Add] Adapty pods (9.9.9)', 'competing', 'init'], log + assert_equal competing, git("--git-dir=#{@specs}", 'rev-parse', 'main^').strip + assert published?('AdaptyLogger', '1.0.0') + assert(AdaptyPods::ORDER.all? { |pod| published?(pod, '9.9.9') }) + assert_includes out, "Published 9.9.9 (tag 9.9.9, #{@sha}): #{AdaptyPods::ORDER.join(' ')}" + end + + def test_retry_skips_pods_a_concurrent_commit_already_published + source = AdaptyPods.expected_source('tag', '9.9.9', @sha) + competing = competing_commit([['AdaptyLogger', '9.9.9', source]]) + calls = race_on_first_push(competing) + out, err, status = push(stage) + assert status.success?, err + assert_includes err, 'retrying (2/3)' + assert_equal 2, File.readlines(calls).size + assert_includes out, 'skip: AdaptyLogger 9.9.9' + assert_equal ['[Add] Adapty pods (9.9.9)', 'competing', 'init'], log + added = git("--git-dir=#{@specs}", 'diff', '--name-only', 'main^', 'main').lines.map(&:strip) + assert_equal(AdaptyPods::ORDER.drop(1).map { |pod| AdaptyPods.spec_path(pod, '9.9.9') }.sort, added.sort) + assert_includes out, "Published 9.9.9 (tag 9.9.9, #{@sha}): #{AdaptyPods::ORDER.drop(1).join(' ')}" + end + + def test_commit_body_has_run_url + env = { 'GITHUB_SERVER_URL' => 'https://github.com', 'GITHUB_REPOSITORY' => 'adaptyteam/AdaptySDK-iOS', 'GITHUB_RUN_ID' => '42' } + push(stage, env: env) + body = git("--git-dir=#{@specs}", 'log', '-1', '--format=%b', 'main') + assert_includes body, "tag 9.9.9 (#{@sha})" + assert_includes body, 'https://github.com/adaptyteam/AdaptySDK-iOS/actions/runs/42' + end + + def test_verify_prints_manifest + out, err, status = run_script('verify', '--from', stage) + assert status.success?, err + assert_equal '9.9.9', JSON.parse(out)['version'] + end + + def test_unknown_command + _out, err, status = run_script('frobnicate') + refute status.success? + assert_includes err, 'unknown argument: frobnicate' + end + + def test_push_needs_from + _out, err, status = run_script('push') + refute status.success? + assert_includes err, '--from' + end +end diff --git a/scripts/cocoapods-specs/test/rewrite_source_test.rb b/scripts/cocoapods-specs/test/rewrite_source_test.rb new file mode 100644 index 000000000..1185aed9b --- /dev/null +++ b/scripts/cocoapods-specs/test/rewrite_source_test.rb @@ -0,0 +1,62 @@ +require 'minitest/autorun' +require 'json' +require 'open3' +require_relative '../rewrite_source' + +class RewriteSourceTest < Minitest::Test + SHA = '28038b667097a259d2762ae1e37a8de0593ada3a' + GIT = 'https://github.com/adaptyteam/AdaptySDK-iOS.git' + SPEC = { + 'name' => 'AdaptyLogger', + 'version' => '4.2.0-SNAPSHOT', + 'source' => { 'git' => GIT, 'tag' => '4.2.0-SNAPSHOT' }, + 'source_files' => 'Sources.Logger/**/*.swift', + }.freeze + SCRIPT = File.expand_path('../rewrite_source.rb', __dir__) + + def test_pins_source_to_commit_and_drops_tag + assert_equal({ 'git' => GIT, 'commit' => SHA }, rewrite_source(SPEC, SHA)['source']) + end + + def test_keeps_other_fields + without_source = ->(spec) { spec.reject { |key, _| key == 'source' } } + assert_equal without_source.(SPEC), without_source.(rewrite_source(SPEC, SHA)) + end + + def test_without_commit_returns_spec_unchanged + assert_equal SPEC, rewrite_source(SPEC, nil) + end + + def test_rejects_source_without_git + http_spec = SPEC.merge('source' => { 'http' => 'https://example.com/sdk.zip' }) + assert_raises(ArgumentError) { rewrite_source(http_spec, SHA) } + end + + def test_rejects_string_source + assert_raises(ArgumentError) { rewrite_source(SPEC.merge('source' => GIT), SHA) } + end + + def test_cli_pins_commit + out, status = Open3.capture2('ruby', SCRIPT, '--commit', SHA, stdin_data: JSON.generate(SPEC)) + assert status.success? + assert_equal SHA, JSON.parse(out).dig('source', 'commit') + end + + def test_cli_fails_on_invalid_json + _out, err, status = Open3.capture3('ruby', SCRIPT, '--commit', SHA, stdin_data: 'not json') + refute status.success? + refute_empty err + end + + def test_cli_rejects_flag_as_commit + _out, err, status = Open3.capture3('ruby', SCRIPT, '--commit', '--foo', stdin_data: JSON.generate(SPEC)) + refute status.success? + assert_includes err, '--commit needs a SHA' + end + + def test_cli_rejects_non_object_json + _out, err, status = Open3.capture3('ruby', SCRIPT, '--commit', SHA, stdin_data: '[]') + refute status.success? + assert_includes err, 'must be an object' + end +end diff --git a/scripts/cocoapods-specs/test/run.sh b/scripts/cocoapods-specs/test/run.sh new file mode 100755 index 000000000..3089ce73f --- /dev/null +++ b/scripts/cocoapods-specs/test/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Runs the cocoapods-specs script tests. +set -euo pipefail +cd "$(dirname "$0")" +for test in *_test.rb; do + echo "== $test" + ruby "$test" +done diff --git a/scripts/cocoapods-specs/test/stage_cli_test.rb b/scripts/cocoapods-specs/test/stage_cli_test.rb new file mode 100644 index 000000000..3ccd00e90 --- /dev/null +++ b/scripts/cocoapods-specs/test/stage_cli_test.rb @@ -0,0 +1,72 @@ +require 'minitest/autorun' +require 'open3' +require 'tmpdir' +require 'fileutils' + +class StageCliTest < Minitest::Test + SCRIPT = File.expand_path('../publish.sh', __dir__) + + def run_script(*args) + Open3.capture3('/bin/bash', SCRIPT, *args) + end + + def test_stage_needs_ref_and_out + _out, err, status = run_script('stage', '--ref', 'HEAD') + refute status.success? + assert_includes err, 'stage needs --ref and --out' + end + + def test_stage_refuses_existing_out + Dir.mktmpdir do |dir| + _out, err, status = run_script('stage', '--ref', 'HEAD', '--out', dir) + refute status.success? + assert_includes err, 'already exists' + end + end + + def test_stage_rejects_unknown_ref + Dir.mktmpdir do |dir| + _out, err, status = run_script('stage', '--ref', 'no-such-ref-xyz', '--out', File.join(dir, 'out')) + refute status.success? + assert_includes err, 'unknown ref no-such-ref-xyz' + end + end + + def test_stage_flags_need_values + _out, err, status = run_script('stage', '--ref') + refute status.success? + assert_includes err, '--ref needs a value' + end + + def test_help_lists_stage + out, _err, status = run_script('--help') + assert status.success? + assert_includes out, 'stage --ref REF --out DIR' + end + + # Only stage needs the iOS checkout and the pod list. + def test_non_stage_commands_work_outside_a_git_checkout + Dir.mktmpdir do |dir| + FileUtils.cp(Dir[File.join(File.dirname(SCRIPT), '*.{sh,rb}')], dir) + script = File.join(dir, 'publish.sh') + out, _err, status = Open3.capture3('/bin/bash', script, '--help', chdir: dir) + assert status.success? + assert_includes out, 'stage --ref REF --out DIR' + + _out, err, status = Open3.capture3('/bin/bash', script, 'verify', chdir: dir) + refute status.success? + assert_includes err, 'verify needs --from DIR' + + _out, err, status = Open3.capture3('/bin/bash', script, 'stage', '--ref', 'HEAD', '--out', + File.join(dir, 'out'), chdir: dir) + refute status.success? + assert_includes err, 'not inside a git checkout' + end + end + + def test_manual_mode_needs_ref + _out, err, status = run_script('--specs-url', 'file:///nonexistent.git') + refute status.success? + assert_includes err, 'a command or --ref is required' + end +end diff --git a/scripts/cocoapods-specs/test/stage_test.rb b/scripts/cocoapods-specs/test/stage_test.rb new file mode 100644 index 000000000..584ec1205 --- /dev/null +++ b/scripts/cocoapods-specs/test/stage_test.rb @@ -0,0 +1,273 @@ +require 'minitest/autorun' +require 'json' +require 'open3' +require 'tmpdir' +require 'fileutils' +require_relative '../pods' + +# Runs `publish.sh stage` against local repos with a fake `pod` first on PATH. +class StageTest < Minitest::Test + SCRIPTS = File.expand_path('..', __dir__) + STAGE_REPO = 'adapty-specs-stage'.freeze + + # Implements only what stage calls; logs every invocation as a JSON array. + FAKE_POD = <<~'RUBY'.freeze + #!/usr/bin/env ruby + require 'json' + require 'fileutils' + + File.open(ENV.fetch('FAKE_POD_LOG'), 'a') { |f| f.puts(JSON.generate(ARGV)) } + + module Pod + class Spec + attr_accessor :name, :version, :source + + def initialize + yield self + end + end + end + + def run!(*cmd) + system(*cmd) || abort("fake pod: #{cmd.join(' ')} failed") + end + + case ARGV[0, 2] + when %w[ipc spec] + spec = eval(File.read(ARGV[2]), binding, ARGV[2]) + puts JSON.pretty_generate('name' => spec.name, 'version' => spec.version, 'source' => spec.source) + when %w[repo add] + name, url, branch = ARGV[2, 3] + dir = File.join(ENV.fetch('CP_REPOS_DIR'), name) + FileUtils.mkdir_p(File.dirname(dir)) + run!('git', 'clone', '--quiet', *(branch ? ['--branch', branch] : []), url, dir) + when %w[repo push] + name, file = ARGV[2, 2] + abort('fake pod: only --local-only is supported') unless ARGV.include?('--local-only') + spec = JSON.parse(File.read(file)) + dir = File.join(ENV.fetch('CP_REPOS_DIR'), name) + rel = File.join('Specs', spec['name'], spec['version'], "#{spec['name']}.podspec.json") + abort("fake pod: #{rel} exists") if File.exist?(File.join(dir, rel)) + FileUtils.mkdir_p(File.dirname(File.join(dir, rel))) + FileUtils.cp(file, File.join(dir, rel)) + message = ARGV.grep(/\A--commit-message=/).first.to_s.sub('--commit-message=', '') + run!('git', '-C', dir, 'add', rel) + run!('git', '-C', dir, 'commit', '--quiet', '-m', message) + else + abort("fake pod: unsupported #{ARGV.join(' ')}") + end + RUBY + + def setup + @tmp = Dir.mktmpdir + bin = File.join(@tmp, 'bin') + FileUtils.mkdir_p(bin) + File.write(File.join(bin, 'pod'), FAKE_POD) + File.chmod(0o755, File.join(bin, 'pod')) + @pod_log = File.join(@tmp, 'pod.log') + @env = { + 'PATH' => "#{bin}:#{ENV.fetch('PATH')}", 'FAKE_POD_LOG' => @pod_log, + 'GIT_AUTHOR_NAME' => 'Test', 'GIT_AUTHOR_EMAIL' => 'test@example.com', + 'GIT_COMMITTER_NAME' => 'Test', 'GIT_COMMITTER_EMAIL' => 'test@example.com', + 'EXPECTED_SHA' => nil, 'EXPECTED_MODE' => nil, 'EXPECTED_REF' => nil + } + @out = File.join(@tmp, 'staged') + setup_specs_repo + setup_ios_repo + end + + def teardown + FileUtils.remove_entry(@tmp) + end + + # The default branch is not main, so a clone that ignores the branch sees no specs. + def setup_specs_repo + @specs = File.join(@tmp, 'specs.git') + git('init', '--quiet', '--bare', '--initial-branch=main', @specs) + seed = File.join(@tmp, 'seed') + git('clone', '--quiet', @specs, seed) + FileUtils.mkdir_p(File.join(seed, 'Specs')) + File.write(File.join(seed, 'Specs', '.gitkeep'), '') + git('-C', seed, 'add', '.') + git('-C', seed, 'commit', '--quiet', '-m', 'init') + git('-C', seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + git('-C', seed, 'checkout', '--quiet', '--orphan', 'legacy') + git('-C', seed, 'rm', '--quiet', '-rf', '.') + File.write(File.join(seed, 'README'), 'legacy') + git('-C', seed, 'add', 'README') + git('-C', seed, 'commit', '--quiet', '-m', 'legacy') + git('-C', seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/legacy') + git("--git-dir=#{@specs}", 'symbolic-ref', 'HEAD', 'refs/heads/legacy') + end + + # The scripts live inside the fake iOS checkout, so publish.sh resolves it as IOS_REPO. + def setup_ios_repo + @ios = File.join(@tmp, 'ios') + @ios_origin = File.join(@tmp, 'ios.git') + git('init', '--quiet', '--bare', '--initial-branch=main', @ios_origin) + git('init', '--quiet', '--initial-branch=main', @ios) + git('-C', @ios, 'remote', 'add', 'origin', "file://#{@ios_origin}") + @script = File.join(@ios, 'scripts', 'cocoapods-specs', 'publish.sh') + FileUtils.mkdir_p(File.dirname(@script)) + FileUtils.cp(Dir[File.join(SCRIPTS, '*.{sh,rb}')], File.dirname(@script)) + end + + def git(*args) + out, err, status = Open3.capture3(@env, 'git', *args) + raise "git #{args.join(' ')}: #{err}" unless status.success? + + out + end + + def podspec(pod, version) + <<~RUBY + Pod::Spec.new do |s| + s.name = '#{pod}' + s.version = '#{version}' + s.source = { :git => '#{AdaptyPods::IOS_GIT}', :tag => s.version.to_s } + end + RUBY + end + + # Commits the seven podspecs at `version` (or per-pod `versions`) and optionally pushes main. + def ios_commit(version, versions: {}, push: true) + AdaptyPods::ORDER.each do |pod| + File.write(File.join(@ios, "#{pod}.podspec"), podspec(pod, versions.fetch(pod, version))) + end + git('-C', @ios, 'add', '--', '*.podspec') + git('-C', @ios, 'commit', '--quiet', '--allow-empty', '-m', "podspecs #{version}") + git('-C', @ios, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') if push + git('-C', @ios, 'fetch', '--quiet', 'origin') + git('-C', @ios, 'rev-parse', 'HEAD').strip + end + + def tag(name) + git('-C', @ios, 'tag', '-a', name, '-m', name) + git('-C', @ios, 'push', '--quiet', 'origin', "refs/tags/#{name}") + end + + # Adds specs to the spec repo's main as if they were published earlier. + def publish_existing(pods, version, source) + clone = File.join(@tmp, 'publisher') + git('clone', '--quiet', '--branch', 'main', @specs, clone) + pods.each do |pod| + path = File.join(clone, AdaptyPods.spec_path(pod, version)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate('name' => pod, 'version' => version, 'source' => source)) + end + git('-C', clone, 'add', 'Specs') + git('-C', clone, 'commit', '--quiet', '-m', 'published earlier') + git('-C', clone, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + end + + def stage(ref) + Open3.capture3(@env, '/bin/bash', @script, 'stage', '--ref', ref, '--out', @out, + '--ios-url', "file://#{@ios_origin}", '--specs-url', "file://#{@specs}") + end + + def pod_calls(command = nil) + calls = File.exist?(@pod_log) ? File.readlines(@pod_log).map { |line| JSON.parse(line) } : [] + command ? calls.select { |args| args[0, 2] == command } : calls + end + + def staged_spec(pod, version) + JSON.parse(File.read(File.join(@out, AdaptyPods.spec_path(pod, version)))) + end + + def specs_main_log + git("--git-dir=#{@specs}", 'log', '--format=%s', 'main').lines.map(&:strip) + end + + def assert_staged_nothing + assert_empty pod_calls(%w[repo push]) + refute File.exist?(@out), "#{@out} must not be created" + end + + def test_tag_mode + sha = ios_commit('9.9.9') + tag('9.9.9') + out, err, status = stage('9.9.9') + assert status.success?, err + assert_includes out, "Staged 9.9.9: #{AdaptyPods::ORDER.join(' ')}" + + manifest = JSON.parse(File.read(File.join(@out, 'manifest.json'))) + assert_equal({ 'version' => '9.9.9', 'mode' => 'tag', 'ref' => '9.9.9', 'sha' => sha, 'pods' => AdaptyPods::ORDER }, + manifest) + AdaptyPods::ORDER.each do |pod| + spec = staged_spec(pod, '9.9.9') + assert_equal [pod, '9.9.9'], [spec['name'], spec['version']] + assert_equal AdaptyPods.expected_source('tag', '9.9.9', sha), spec['source'] + end + + assert_equal [['repo', 'add', STAGE_REPO, "file://#{@specs}", 'main']], pod_calls(%w[repo add]) + pushes = pod_calls(%w[repo push]) + assert_equal(AdaptyPods::ORDER, pushes.map { |args| File.basename(args[3], '.podspec.json') }) + pushes.each do |args| + assert_equal STAGE_REPO, args[2] + assert_includes args, "--sources=#{STAGE_REPO}" + assert_includes args, '--local-only' + assert_includes args, '--no-overwrite' + end + assert_equal ['init'], specs_main_log + end + + def test_commit_mode + sha = ios_commit('9.9.10-SNAPSHOT.1') + _out, err, status = stage(sha) + assert status.success?, err + + manifest = JSON.parse(File.read(File.join(@out, 'manifest.json'))) + assert_equal(%w[commit] + [sha, sha], manifest.values_at('mode', 'ref', 'sha')) + assert_equal AdaptyPods::ORDER, manifest['pods'] + AdaptyPods::ORDER.each do |pod| + assert_equal({ 'git' => AdaptyPods::IOS_GIT, 'commit' => sha }, staged_spec(pod, '9.9.10-SNAPSHOT.1')['source']) + end + assert_equal AdaptyPods::ORDER.size, pod_calls(%w[repo push]).size + end + + def test_nothing_new + sha = ios_commit('9.9.9') + tag('9.9.9') + publish_existing(AdaptyPods::ORDER, '9.9.9', AdaptyPods.expected_source('tag', '9.9.9', sha)) + out, err, status = stage('9.9.9') + assert status.success?, err + assert_includes out, 'Nothing to publish: 9.9.9 is already published from tag 9.9.9' + assert_staged_nothing + end + + def test_refuses_pod_published_from_another_source + sha = ios_commit('9.9.9') + tag('9.9.9') + publish_existing(['AdaptyUI'], '9.9.9', AdaptyPods.expected_source('commit', sha, sha)) + _out, err, status = stage('9.9.9') + refute status.success? + assert_includes err, 'AdaptyUI 9.9.9 is already published from another source' + assert_staged_nothing + end + + def test_refuses_version_drift + sha = ios_commit('9.9.9', versions: { 'AdaptyUI' => '9.9.8' }) + _out, err, status = stage(sha) + refute status.success? + assert_includes err, 'AdaptyUI is 9.9.8, expected 9.9.9 (podspec versions drifted' + assert_staged_nothing + end + + def test_refuses_tag_not_matching_version + ios_commit('9.9.9') + tag('9.9.8') + _out, err, status = stage('9.9.8') + refute status.success? + assert_includes err, 'tag 9.9.8 does not match podspec version 9.9.9' + assert_staged_nothing + end + + def test_refuses_commit_not_on_a_remote_branch + ios_commit('9.9.9') + sha = ios_commit('9.9.10-SNAPSHOT.1', push: false) + _out, err, status = stage(sha) + refute status.success? + assert_includes err, "commit #{sha} is not on any remote branch" + assert_staged_nothing + end +end diff --git a/scripts/cocoapods-specs/test/verify_artifact_test.rb b/scripts/cocoapods-specs/test/verify_artifact_test.rb new file mode 100644 index 000000000..bf5361a05 --- /dev/null +++ b/scripts/cocoapods-specs/test/verify_artifact_test.rb @@ -0,0 +1,170 @@ +require 'minitest/autorun' +require 'json' +require 'open3' +require 'tmpdir' +require 'fileutils' +require_relative '../verify_artifact' + +class VerifyArtifactTest < Minitest::Test + SHA = '28038b667097a259d2762ae1e37a8de0593ada3a' + OTHER_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + SCRIPT = File.expand_path('../verify_artifact.rb', __dir__) + + def setup + @tmp = Dir.mktmpdir + end + + def teardown + FileUtils.remove_entry(@tmp) + end + + def stage(version: '4.3.0', mode: 'tag', ref: '4.3.0', sha: SHA, pods: AdaptyPods::ORDER, manifest: {}) + dir = File.join(@tmp, 'staged') + FileUtils.mkdir_p(dir) + source = AdaptyPods.expected_source(mode, ref, sha) + pods.each do |pod| + path = File.join(dir, AdaptyPods.spec_path(pod, version)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate('name' => pod, 'version' => version, 'source' => source)) + end + data = { 'version' => version, 'mode' => mode, 'ref' => ref, 'sha' => sha, 'pods' => pods }.merge(manifest) + File.write(File.join(dir, 'manifest.json'), JSON.generate(data)) + dir + end + + def spec_file(dir, pod, version = '4.3.0') + File.join(dir, AdaptyPods.spec_path(pod, version)) + end + + def assert_rejected(message, dir, expected = {}) + error = assert_raises(VerifyError) { VerifyArtifact.call(dir, expected) } + assert_includes error.message, message + end + + def test_accepts_tag_artifact + assert_equal '4.3.0', VerifyArtifact.call(stage)['version'] + end + + def test_accepts_commit_artifact + dir = stage(version: '4.3.0-SNAPSHOT.1', mode: 'commit', ref: 'origin/dev') + assert_equal 'commit', VerifyArtifact.call(dir)['mode'] + end + + def test_prerelease_version + dir = stage(version: '4.3.0-SNAPSHOT.1', mode: 'commit', ref: SHA, pods: %w[AdaptyLogger]) + assert_equal %w[AdaptyLogger], VerifyArtifact.call(dir)['pods'] + end + + def test_accepts_subset_of_pods + assert_equal %w[Adapty AdaptyUI], VerifyArtifact.call(stage(pods: %w[Adapty AdaptyUI]))['pods'] + end + + def test_rejects_missing_manifest + dir = stage + File.delete(File.join(dir, 'manifest.json')) + assert_rejected 'missing manifest.json', dir + end + + def test_rejects_extra_file + dir = stage + File.write(File.join(dir, 'Specs', 'evil.sh'), 'echo') + assert_rejected 'unexpected files: Specs/evil.sh', dir + end + + def test_rejects_spec_not_in_manifest + dir = stage(manifest: { 'pods' => %w[Adapty] }) + assert_rejected 'unexpected files', dir + end + + def test_rejects_missing_spec + dir = stage + File.delete(spec_file(dir, 'AdaptyUI')) + assert_rejected 'missing files', dir + end + + def test_rejects_symlink + dir = stage + File.delete(spec_file(dir, 'AdaptyUI')) + File.symlink(spec_file(dir, 'Adapty'), spec_file(dir, 'AdaptyUI')) + assert_rejected 'not a regular file', dir + end + + def test_rejects_unknown_pod + assert_rejected 'unknown pods: Evil', stage(manifest: { 'pods' => %w[Evil] }) + end + + def test_rejects_traversal_version + assert_rejected 'invalid version', stage(manifest: { 'version' => '../4.3.0' }) + end + + def test_rejects_bad_sha + assert_rejected 'sha must be', stage(manifest: { 'sha' => 'main' }) + end + + def test_rejects_tag_not_matching_version + assert_rejected 'does not match version', stage(manifest: { 'ref' => '4.3.1' }) + end + + def test_rejects_wrong_name + dir = stage + File.write(spec_file(dir, 'Adapty'), JSON.generate('name' => 'Evil', 'version' => '4.3.0', + 'source' => AdaptyPods.expected_source('tag', '4.3.0', SHA))) + assert_rejected 'Adapty: name', dir + end + + def test_rejects_wrong_version_in_spec + dir = stage + File.write(spec_file(dir, 'Adapty'), JSON.generate('name' => 'Adapty', 'version' => '4.2.0', + 'source' => AdaptyPods.expected_source('tag', '4.3.0', SHA))) + assert_rejected 'Adapty: version', dir + end + + def test_rejects_wrong_source + dir = stage + File.write(spec_file(dir, 'Adapty'), JSON.generate('name' => 'Adapty', 'version' => '4.3.0', + 'source' => { 'git' => 'https://example.com/fork.git', 'tag' => '4.3.0' })) + assert_rejected 'Adapty: source', dir + end + + def test_rejects_commit_source_in_tag_mode + dir = stage + File.write(spec_file(dir, 'Adapty'), JSON.generate('name' => 'Adapty', 'version' => '4.3.0', + 'source' => AdaptyPods.expected_source('commit', '4.3.0', SHA))) + assert_rejected 'Adapty: source', dir + end + + def test_expected_run_matches + assert VerifyArtifact.call(stage, sha: SHA, mode: 'tag', ref: '4.3.0') + end + + def test_rejects_other_sha_than_run + assert_rejected 'is not the run', stage, sha: OTHER_SHA, mode: 'tag', ref: '4.3.0' + end + + def test_rejects_other_mode_than_run + assert_rejected 'is not the run', stage, sha: SHA, mode: 'commit', ref: '4.3.0' + end + + def test_rejects_other_tag_than_run + assert_rejected 'is not the run', stage, sha: SHA, mode: 'tag', ref: '4.2.0' + end + + def test_rejects_partial_expectation + assert_rejected 'not set', stage, sha: SHA, mode: nil, ref: nil + end + + def test_cli_prints_manifest_and_reads_env + dir = stage + out, err, status = Open3.capture3({ 'EXPECTED_SHA' => SHA, 'EXPECTED_MODE' => 'tag', 'EXPECTED_REF' => '4.3.0' }, + 'ruby', SCRIPT, dir) + assert status.success?, err + assert_equal '4.3.0', JSON.parse(out)['version'] + end + + def test_cli_fails_with_reason + _out, err, status = Open3.capture3({ 'EXPECTED_SHA' => OTHER_SHA, 'EXPECTED_MODE' => 'tag', 'EXPECTED_REF' => '4.3.0' }, + 'ruby', SCRIPT, stage) + refute status.success? + assert_includes err, 'verify: ' + end +end diff --git a/scripts/cocoapods-specs/verify_artifact.rb b/scripts/cocoapods-specs/verify_artifact.rb new file mode 100755 index 000000000..813ebe323 --- /dev/null +++ b/scripts/cocoapods-specs/verify_artifact.rb @@ -0,0 +1,103 @@ +#!/usr/bin/env ruby +# Verifies a staged artifact before it is pushed: regular files only, exactly the manifest and the +# manifest's specs, each spec's name/version/source matching the manifest, and the manifest matching +# the CI run when EXPECTED_* are set. Prints the manifest as JSON on success. +require 'json' +require 'find' +require_relative 'pods' + +class VerifyError < StandardError; end + +module VerifyArtifact + SHA = /\A[0-9a-f]{40}\z/.freeze + VERSION = /\A\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?\z/.freeze + + def self.call(dir, expected = {}) + dir = File.expand_path(dir) + manifest = read_json(dir, 'manifest.json') + check_manifest(manifest) + check_expected(manifest, expected) + version = manifest['version'] + check_files(dir, ['manifest.json'] + manifest['pods'].map { |pod| AdaptyPods.spec_path(pod, version) }) + source = AdaptyPods.expected_source(manifest['mode'], manifest['ref'], manifest['sha']) + manifest['pods'].each { |pod| check_spec(read_json(dir, AdaptyPods.spec_path(pod, version)), pod, version, source) } + manifest + end + + def self.check_manifest(manifest) + reject('manifest must be an object') unless manifest.is_a?(Hash) + version = manifest['version'] + reject("manifest: invalid version #{version.inspect}") unless version.is_a?(String) && version =~ VERSION && !version.include?('..') + reject('manifest: mode must be tag or commit') unless %w[tag commit].include?(manifest['mode']) + reject('manifest: ref must be a non-empty string') unless manifest['ref'].is_a?(String) && !manifest['ref'].empty? + reject('manifest: sha must be a 40-character hex SHA') unless manifest['sha'].is_a?(String) && manifest['sha'] =~ SHA + if manifest['mode'] == 'tag' && manifest['ref'] != version + reject("manifest: tag #{manifest['ref']} does not match version #{version}") + end + pods = manifest['pods'] + reject('manifest: pods must be a non-empty array') unless pods.is_a?(Array) && !pods.empty? + unknown = pods - AdaptyPods::ORDER + reject("manifest: unknown pods: #{unknown.join(', ')}") unless unknown.empty? + reject('manifest: duplicate pods') unless pods.uniq.size == pods.size + end + + def self.check_expected(manifest, expected) + return if expected.values.all?(&:nil?) + + missing = %i[sha mode ref].select { |key| expected[key].nil? || expected[key].empty? } + reject("expected #{missing.join(', ')} not set") unless missing.empty? + reject("manifest sha #{manifest['sha']} is not the run's #{expected[:sha]}") unless manifest['sha'] == expected[:sha] + reject("manifest mode #{manifest['mode']} is not the run's #{expected[:mode]}") unless manifest['mode'] == expected[:mode] + if manifest['mode'] == 'tag' && manifest['ref'] != expected[:ref] + reject("manifest tag #{manifest['ref']} is not the run's #{expected[:ref]}") + end + end + + def self.check_files(dir, allowed) + found = [] + Find.find(dir) do |path| + next if path == dir + + rel = path.delete_prefix("#{dir}/") + stat = File.lstat(path) + if stat.symlink? || !(stat.file? || stat.directory?) + reject("#{rel}: not a regular file") + elsif stat.file? + found << rel + end + end + extra = found - allowed + missing = allowed - found + reject("unexpected files: #{extra.sort.join(', ')}") unless extra.empty? + reject("missing files: #{missing.sort.join(', ')}") unless missing.empty? + end + + def self.check_spec(spec, pod, version, source) + reject("#{pod}: spec must be an object") unless spec.is_a?(Hash) + reject("#{pod}: name is #{spec['name'].inspect}") unless spec['name'] == pod + reject("#{pod}: version is #{spec['version'].inspect}, expected #{version}") unless spec['version'] == version + reject("#{pod}: source #{JSON.generate(spec['source'])} is not #{JSON.generate(source)}") unless spec['source'] == source + end + + def self.read_json(dir, rel) + JSON.parse(File.read(File.join(dir, rel))) + rescue Errno::ENOENT + reject("missing #{rel}") + rescue JSON::ParserError => e + reject("#{rel}: #{e.message}") + end + + def self.reject(message) + raise VerifyError, message + end +end + +if $PROGRAM_NAME == __FILE__ + abort('usage: verify_artifact.rb DIR') unless ARGV.size == 1 + expected = { sha: ENV['EXPECTED_SHA'], mode: ENV['EXPECTED_MODE'], ref: ENV['EXPECTED_REF'] } + begin + puts JSON.generate(VerifyArtifact.call(ARGV[0], expected)) + rescue VerifyError => e + abort("verify: #{e.message}") + end +end