├── .ruby-version ├── .tool-versions ├── .standard.yml ├── .gitignore ├── bin ├── dev └── prod ├── Justfile ├── Guardfile ├── initializers ├── env.rb ├── config.rb ├── jwt.rb └── sentry_config.rb ├── .env.template ├── .editorconfig ├── config └── puma.rb ├── render.yaml ├── Gemfile ├── .github └── workflows │ └── ci.yml ├── lib ├── internal.rb └── app_store │ ├── errors.rb │ └── connect.rb ├── rack ├── app_store_auth_handler.rb ├── internal_error_handler.rb ├── applelink_util.rb ├── app_store_connect_headers.rb └── rack_ougai_logger.rb ├── dev.Dockerfile ├── SECURITY.md ├── test ├── one_off │ └── play_util.rb └── requests ├── spaceship ├── wrapper_token.rb └── wrapper_error.rb ├── config.ru ├── CODE_OF_CONDUCT.md ├── Gemfile.lock ├── LICENSE └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.0 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "test/**/*" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.p8 2 | .idea/ 3 | bundle/ 4 | .env 5 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | bundle exec guard --no-bundler-warning --no-interactions 2 | -------------------------------------------------------------------------------- /bin/prod: -------------------------------------------------------------------------------- 1 | RACK_ENV=production bundle exec rackup -s puma config.ru -p 4000 2 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | start: 2 | bundle exec guard 3 | 4 | lint: 5 | bundle exec standardrb --fix 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard "rack", port: 4000 do 2 | watch("Gemfile.lock") 3 | watch(".env") 4 | watch(%r{^config|initializers|lib|rack|spaceship|lib/.*}) 5 | end 6 | -------------------------------------------------------------------------------- /initializers/env.rb: -------------------------------------------------------------------------------- 1 | module Initializers 2 | module Env 3 | def development? 4 | ENV["RACK_ENV"].eql?("development") 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | AUTH_ISSUER=AUTH_ISSUER 2 | AUTH_SECRET=AUTH_SECRET 3 | AUTH_AUD=AUTH_AUD 4 | WEB_CONCURRENCY=WEB_CONCURRENCY 5 | MAX_THREADS=MAX_THREADS 6 | SENTRY_DSN=SENTRY_DSN 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | workers Integer(ENV.fetch("WEB_CONCURRENCY", 5)) 2 | threads_count = Integer(ENV.fetch("MAX_THREADS", 5)) 3 | threads threads_count, threads_count 4 | environment ENV.fetch("RACK_ENV", "development") 5 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: pserv 3 | name: applelink 4 | env: ruby 5 | region: frankfurt 6 | buildCommand: bundle install 7 | startCommand: ./bin/prod 8 | autoDeploy: true 9 | branch: main 10 | -------------------------------------------------------------------------------- /initializers/config.rb: -------------------------------------------------------------------------------- 1 | require "dotenv" 2 | 3 | module Initializers 4 | module Config 5 | puts "Loading config..." 6 | Dotenv.overload 7 | Dotenv.require_keys("AUTH_ISSUER", "AUTH_SECRET", "AUTH_AUD") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /initializers/jwt.rb: -------------------------------------------------------------------------------- 1 | module Initializers 2 | module JWT 3 | def self.options 4 | { 5 | secret: ENV["AUTH_SECRET"], 6 | options: { 7 | algorithm: "HS256", 8 | verify_expiration: true, 9 | iss: ENV["AUTH_ISSUER"], 10 | verify_iss: true, 11 | aud: ENV["AUTH_AUDIENCE"], 12 | verify_aud: true 13 | } 14 | } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby File.read(".ruby-version").strip 5 | 6 | gem "dotenv" 7 | gem "fastlane" 8 | gem "puma" 9 | gem "hanami-api" 10 | gem "rack-jwt" 11 | gem "sentry-ruby" 12 | gem "ougai" 13 | gem "retryable" 14 | 15 | group :development, :test do 16 | gem "standard" 17 | gem "guard-rack" 18 | gem "bundler-audit", "~> 0.9.1" 19 | end 20 | -------------------------------------------------------------------------------- /initializers/sentry_config.rb: -------------------------------------------------------------------------------- 1 | require "sentry-ruby" 2 | 3 | module Initializers 4 | module SentryConfig 5 | puts "Initializing Sentry..." 6 | Sentry.init do |config| 7 | config.dsn = ENV["SENTRY_DSN"] 8 | config.breadcrumbs_logger = [:sentry_logger, :http_logger] 9 | config.enabled_environments = %w[production] 10 | config.traces_sample_rate = (ENV["RACK_ENV"].eql?("staging") ? 0.0 : 0.2) 11 | config.logger.level = Logger::WARN 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Install Ruby and gems 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | - name: Security audit dependencies 20 | run: bundle exec bundler-audit --update 21 | - name: Lint Ruby files 22 | run: bundle exec standardrb 23 | -------------------------------------------------------------------------------- /lib/internal.rb: -------------------------------------------------------------------------------- 1 | require "spaceship" 2 | require "jwt" 3 | 4 | module Internal 5 | def self.keys(params) 6 | token = 7 | Spaceship::ConnectAPI::Token.create( 8 | key_id: params[:key_id], 9 | issuer_id: params[:issuer_id], 10 | filepath: File.absolute_path("key.p8") 11 | ) 12 | 13 | payload = { 14 | iat: Time.now.to_i, 15 | exp: Time.now.to_i + 10000, 16 | aud: ENV["AUTH_AUD"], 17 | iss: ENV["AUTH_ISSUER"] 18 | } 19 | 20 | { 21 | store_token: token.text, 22 | auth_token: JWT.encode(payload, ENV["AUTH_SECRET"], "HS256") 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /rack/app_store_auth_handler.rb: -------------------------------------------------------------------------------- 1 | require "rack/request" 2 | require_relative "../spaceship/wrapper_token" 3 | require_relative "./applelink_util" 4 | 5 | module Rack 6 | class AppStoreAuthHandler 7 | include Rack::AppleLinkUtil 8 | 9 | def initialize(app) 10 | @app = app 11 | end 12 | 13 | def call(env) 14 | @app.call env 15 | rescue Spaceship::WrapperToken::TokenExpiredError => e 16 | env[RACK_LOGGER].error e 17 | return_unauthorized_error( 18 | { 19 | message: "Invalid auth token for apple store connect API", 20 | code: "unauthorized", 21 | resource: "app_store_connect_api" 22 | } 23 | ) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /dev.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # docker build -t my-app . 4 | # docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY= my-app 5 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 6 | ARG RUBY_VERSION=3.2.0 7 | ARG DISTRO_NAME=bullseye 8 | 9 | FROM ruby:$RUBY_VERSION-slim-$DISTRO_NAME AS base 10 | 11 | ARG DISTRO_NAME 12 | 13 | # Rails app lives here 14 | WORKDIR /applelink 15 | 16 | # Install base packages 17 | RUN apt-get update -qq && \ 18 | apt-get install --no-install-recommends -y curl libjemalloc2 libvips gnupg2 less build-essential git pkg-config jq vim libnss3-tools 19 | 20 | # Copy application code 21 | COPY . . 22 | RUN bundle install 23 | -------------------------------------------------------------------------------- /rack/internal_error_handler.rb: -------------------------------------------------------------------------------- 1 | require "rack/request" 2 | require "sentry-ruby" 3 | require_relative "./applelink_util" 4 | require_relative "../lib/app_store/errors" 5 | 6 | module Rack 7 | class InternalErrorHandler 8 | include Rack::AppleLinkUtil 9 | 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | def call(env) 15 | @app.call env 16 | rescue *AppStore::ERRORS => e 17 | log(env, e) 18 | return_unprocessable_error e.as_json 19 | rescue *AppStore::NOT_FOUND_ERRORS => e 20 | log(env, e) 21 | return_not_found_error e.as_json 22 | rescue *AppStore::CONFLICT_ERRORS => e 23 | log(env, e) 24 | return_conflict_error e.as_json 25 | end 26 | 27 | private 28 | 29 | def log(env, e) 30 | env[RACK_LOGGER].error e 31 | Sentry.capture_exception(e) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We always recommend using the latest version of Applelink to ensure you get all security updates. 6 | 7 | Currently, security updates **are not backported** to previous versions. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you've found a security vulnerability in the Applelink codebase, you can disclose it responsibly by sending a summary to security@tramline.app. 12 | We will review the potential threat and get back to you within two (2) business days. We will then work on fixing it as fast as we can, followed by a public disclosure with attribution to you. 13 | 14 | While we do not have a bounty program in place yet, we are incredibly thankful for people who take the time to share their findings with us. Whether it's a tiny bug that you've found or a security vulnerability, all reports help us to continuously improve Tramline for everyone. Thank you for your support! 15 | -------------------------------------------------------------------------------- /rack/applelink_util.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module AppleLinkUtil 3 | def return_unauthorized_error(message) 4 | body = {error: message}.to_json 5 | headers = {"Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s} 6 | 7 | [401, headers, [body]] 8 | end 9 | 10 | def return_not_found_error(message) 11 | body = {error: message}.to_json 12 | headers = {"Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s} 13 | 14 | [404, headers, [body]] 15 | end 16 | 17 | def return_unprocessable_error(message) 18 | body = {error: message}.to_json 19 | headers = {"Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s} 20 | 21 | [422, headers, [body]] 22 | end 23 | 24 | def return_conflict_error(message) 25 | body = {error: message}.to_json 26 | headers = {"Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s} 27 | 28 | [409, headers, [body]] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /rack/app_store_connect_headers.rb: -------------------------------------------------------------------------------- 1 | require "rack/request" 2 | 3 | module Rack 4 | class AppStoreConnectHeaders 5 | HEADERS = { 6 | "HTTP_X_APPSTORECONNECT_KEY_ID" => :key_id, 7 | "HTTP_X_APPSTORECONNECT_ISSUER_ID" => :issuer_id, 8 | "HTTP_X_APPSTORECONNECT_TOKEN" => :token 9 | } 10 | 11 | def initialize(app) 12 | @app = app 13 | end 14 | 15 | def call(env) 16 | if any_missing_headers?(env) 17 | return [422, {"Content-Type" => "text/plain"}, ["Missing required custom headers to fulfill the request."]] 18 | end 19 | 20 | env[:app_store_connect_params] = {} 21 | HEADERS.keys.each do |header| 22 | env[:app_store_connect_params][HEADERS[header]] = env[header] 23 | end 24 | 25 | @app.call env 26 | end 27 | 28 | private 29 | 30 | def any_missing_headers?(env) 31 | HEADERS.keys.any? { |header| missing_header?(env, header) } 32 | end 33 | 34 | def missing_header?(env, header) 35 | env[header].nil? || env[header].strip.empty? 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/one_off/play_util.rb: -------------------------------------------------------------------------------- 1 | require "spaceship" 2 | require "json" 3 | 4 | BID = "com.tramline.ueno" 5 | 6 | def line 7 | pp "*" * 30 8 | puts "\n" 9 | end 10 | 11 | def version_data(version) 12 | version_data = { 13 | version_name: version.version_string, 14 | app_store_state: version.app_store_state, 15 | release_type: version.release_type, 16 | earliest_release_date: version.earliest_release_date, 17 | downloadable: version.downloadable, 18 | created_date: version.created_date, 19 | build_number: version.build&.version 20 | } 21 | puts JSON.pretty_generate(version_data) 22 | end 23 | 24 | def build_data(b) 25 | build_data = { 26 | build_number: b.version, 27 | version_string: b.app_version, 28 | details: b.get_build_beta_details 29 | } 30 | puts JSON.pretty_generate(build_data) 31 | end 32 | 33 | def set_auth_token 34 | token = Spaceship::ConnectAPI::Token.create( 35 | key_id: "KEY_ID", 36 | issuer_id: "ISSUER_ID", 37 | filepath: File.absolute_path("key.p8") 38 | ) 39 | 40 | token.text 41 | end 42 | 43 | puts set_auth_token 44 | -------------------------------------------------------------------------------- /rack/rack_ougai_logger.rb: -------------------------------------------------------------------------------- 1 | require "time" 2 | require "rubygems" 3 | require "ougai" 4 | 5 | module Rack 6 | module Ougai 7 | class Logger 8 | def initialize(app, level = ::Logger::INFO) 9 | @app, @level = app, level 10 | end 11 | 12 | def call(env) 13 | logger = ::Ougai::Logger.new(env[RACK_ERRORS]) 14 | logger.level = @level 15 | 16 | env[RACK_LOGGER] = logger 17 | @app.call(env) 18 | end 19 | end 20 | 21 | class RequestLogger 22 | def initialize(app, logger = nil) 23 | @app = app 24 | @logger = logger 25 | end 26 | 27 | def call(env) 28 | status, headers, _body = @app.call(env) 29 | ensure 30 | logger = @logger || env[RACK_LOGGER] 31 | logger.info(env[PATH_INFO], create_log(env, status, headers)) 32 | end 33 | 34 | private 35 | 36 | def create_log(env, status, headers) 37 | { 38 | time: Time.now, 39 | remote_addr: env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"], 40 | method: env[REQUEST_METHOD], 41 | path: env[PATH_INFO], 42 | query: env[QUERY_STRING], 43 | status: status.to_i, 44 | duration: headers&.dig("X-Runtime") 45 | } 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spaceship/wrapper_token.rb: -------------------------------------------------------------------------------- 1 | require "jwt" 2 | 3 | module Spaceship 4 | class WrapperToken 5 | class NotImplementedError < StandardError; end 6 | 7 | class TokenExpiredError < StandardError; end 8 | 9 | attr_reader :key_id 10 | attr_reader :issuer_id 11 | attr_reader :text 12 | attr_reader :duration 13 | attr_reader :expiration 14 | 15 | def self.from(_hash: nil, _filepath: nil) 16 | raise NotImplementedError 17 | end 18 | 19 | def self.from_json_file(_filepath) 20 | raise NotImplementedError 21 | end 22 | 23 | def self.create(_key_id: nil, _issuer_id: nil, _filepath: nil, _key: nil, _is_key_content_base64: false, _duration: nil, _in_house: nil, **) 24 | raise NotImplementedError 25 | end 26 | 27 | def initialize(key_id: nil, issuer_id: nil, text: nil) 28 | @key_id = key_id 29 | @issuer_id = issuer_id 30 | @text = text 31 | 32 | payload = JWT.decode(text, nil, false).first 33 | @duration = payload["exp"] - payload["iat"] 34 | @expiration = Time.at(payload["exp"]) 35 | end 36 | 37 | def expired? 38 | @expiration < Time.now 39 | end 40 | 41 | def refresh! 42 | raise TokenExpiredError 43 | end 44 | 45 | def write_key_to_file(_path) 46 | raise NotImplementedError 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spaceship/wrapper_error.rb: -------------------------------------------------------------------------------- 1 | module Spaceship 2 | class WrapperError 3 | MESSAGES = [ 4 | { 5 | message_matcher: /The build is not in a valid processing state for this operation/, 6 | decorated_exception: AppStore::BuildSubmissionForReviewNotAllowedError 7 | }, 8 | { 9 | message_matcher: %r{You cannot update when the value is already set. - /data/attributes/usesNonExemptEncryption}, 10 | decorated_exception: AppStore::ExportComplianceAlreadyUpdatedError 11 | }, 12 | { 13 | message_matcher: %r{The phased release already has this value - /data/attributes/phasedReleaseState}, 14 | decorated_exception: AppStore::PhasedReleaseAlreadyInStateError 15 | }, 16 | { 17 | message_matcher: /You cannot create a new version of the App in the current state/, 18 | decorated_exception: AppStore::VersionAlreadyAddedToSubmissionError 19 | }, 20 | { 21 | message_matcher: %r{The version number has been previously used. - /data/attributes/versionString}, 22 | decorated_exception: AppStore::VersionAlreadyExistsError 23 | }, 24 | { 25 | message_matcher: %r{An attribute value is not acceptable for the current resource state. - The attribute 'versionString' can not be modified. - /data/attributes/versionString}, 26 | decorated_exception: AppStore::VersionNotEditableError 27 | }, 28 | { 29 | message_matcher: %r{A relationship value is not acceptable for the current resource state. - The specified pre-release build could not be added. - /data/relationships/build}, 30 | decorated_exception: AppStore::VersionNotEditableError 31 | }, 32 | { 33 | message_matcher: /Another build is in review/, 34 | decorated_exception: AppStore::ReviewAlreadyInProgressError 35 | }, 36 | { 37 | message_matcher: /Version is not ready to be submitted yet/i, 38 | decorated_exception: AppStore::InvalidReviewStateError 39 | }, 40 | { 41 | message_matcher: /Attachment uploads still in progress/i, 42 | decorated_exception: AppStore::AttachmentUploadInProgress 43 | }, 44 | { 45 | message_matcher: /You cannot change the state of a phased release that is in a final state/i, 46 | decorated_exception: AppStore::PhasedReleaseAlreadyFinalError 47 | }, 48 | { 49 | message_matcher: /Resource reviewSubmissions with id .* cannot be found/i, 50 | decorated_exception: AppStore::ReviewSubmissionNotFound 51 | } 52 | ].freeze 53 | 54 | def self.handle(exception) 55 | new(exception).handle 56 | end 57 | 58 | def initialize(exception) 59 | @exception = exception 60 | end 61 | 62 | def handle 63 | return AppStore::UnexpectedAppstoreError.new(exception) if match.nil? 64 | match[:decorated_exception].new message 65 | end 66 | 67 | private 68 | 69 | attr_reader :exception 70 | 71 | def match 72 | @match ||= matched_message 73 | end 74 | 75 | def matched_message 76 | MESSAGES.find { |known_error_message| known_error_message[:message_matcher] =~ message } 77 | end 78 | 79 | def message 80 | exception.message 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/requests: -------------------------------------------------------------------------------- 1 | # -*- restclient -*- 2 | # RUN THIS IN EMACS IN restclient-mode 3 | 4 | :bundle-id = com.tramline.ueno 5 | :key-id = KEY_ID 6 | :issuer-id = ISSUER_ID 7 | :port = 4000 8 | :my-headers = << 9 | Authorization: Bearer :auth-token 10 | Content-Type: application/json 11 | X-AppStoreConnect-Key-Id: :key-id 12 | X-AppStoreConnect-Issuer-Id: :issuer-id 13 | X-AppStoreConnect-Token: :store-token 14 | # 15 | 16 | GET http://127.0.0.1::port/ping 17 | 18 | # 19 | 20 | GET http://127.0.0.1::port/internal/keys?key_id=:key-id&issuer_id=:issuer-id 21 | -> jq-set-var :store-token .store_token 22 | -> jq-set-var :auth-token .auth_token 23 | 24 | # Get an app metadata 25 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/ 26 | :my-headers 27 | # 28 | 29 | # Get an app live info 30 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/current_status 31 | :my-headers 32 | # 33 | # Get beta groups for an app 34 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/groups 35 | :my-headers 36 | # 37 | 38 | :build-number = 9018 39 | 40 | # Get build for an app 41 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/builds/:build-number 42 | :my-headers 43 | # 44 | 45 | # Update the build notes 46 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/builds/:build-number 47 | :my-headers 48 | { 49 | "notes": "ring a ring o roses" 50 | } 51 | # 52 | 53 | # Get latest build for an app 54 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/builds/latest 55 | :my-headers 56 | # 57 | 58 | 59 | # This is Opinion's ID 60 | :group-id = 3bc1ca3e-1d4f-4478-8f38-2dcae4dcbb69 61 | 62 | # Add build to group 63 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/groups/:group-id/add_build 64 | :my-headers 65 | { 66 | "build_number": :build-number 67 | } 68 | # 69 | 70 | # Prepare a release for submission 71 | POST http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/release/prepare 72 | :my-headers 73 | { 74 | "build_number": :build-number, 75 | "version": "1.6.2", 76 | "is_phased_release": true, 77 | "metadata": { "promotional_text": "this is the app store version promo text", 78 | "whats_new": "something new"} 79 | } 80 | # 81 | 82 | :version = "1.2.0" 83 | 84 | # Submit a release 85 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/release/submit 86 | :my-headers 87 | { 88 | "build_number": :build-number, 89 | "version": :version 90 | } 91 | # 92 | 93 | # Find a release that can be distributed 94 | GET http://127.0.0.1::port/apple/connect/v1/apps/:bundle-id/release?build_number=:build-number 95 | :my-headers 96 | # 97 | 98 | # Start a release 99 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/start 100 | :my-headers 101 | { 102 | "build_number": :build-number 103 | } 104 | # 105 | 106 | # 107 | GET http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/live 108 | :my-headers 109 | # 110 | 111 | # Pause phased rollout for the live release 112 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/live/rollout/pause 113 | :my-headers 114 | # 115 | 116 | # Resume phased rollout for the live release 117 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/live/rollout/resume 118 | :my-headers 119 | # 120 | 121 | # Complete phased rollout for the live release 122 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/live/rollout/complete 123 | :my-headers 124 | # 125 | 126 | # Halt rollout for the live release (remove it from app store) 127 | PATCH http://127.0.0.1::port/apple/connect/v1/apps/com.tramline.ueno/release/live/rollout/halt 128 | :my-headers 129 | # 130 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | #\ -q 2 | 3 | require "bundler/setup" 4 | require "hanami/api" 5 | require "hanami/middleware/body_parser" 6 | require "sentry-ruby" 7 | require "rack/jwt/auth" 8 | require "rack/jwt" 9 | require "rack/runtime" 10 | require "./initializers/config" 11 | require "./initializers/sentry_config" 12 | require "./initializers/jwt" 13 | require "./initializers/env" 14 | require "./rack/app_store_connect_headers" 15 | require "./rack/app_store_auth_handler" 16 | require "./rack/internal_error_handler" 17 | require "./rack/rack_ougai_logger" 18 | require "./lib/app_store/connect" 19 | require "./lib/internal" 20 | 21 | class InternalApp < Hanami::API 22 | get "keys" do 23 | json(Internal.keys(params)) 24 | end 25 | end 26 | 27 | class AppleAppV1 < Hanami::API 28 | use Rack::JWT::Auth, Initializers::JWT.options 29 | use Rack::AppStoreConnectHeaders 30 | use Rack::AppStoreAuthHandler 31 | use Rack::InternalErrorHandler 32 | 33 | DOMAIN = AppStore::Connect 34 | 35 | helpers do 36 | def not_found(message) 37 | halt(404, json({error: message})) 38 | end 39 | end 40 | 41 | scope "apps/:bundle_id" do 42 | get "/" do 43 | json(DOMAIN.metadata(**env[:app_store_connect_params].merge(params))) 44 | end 45 | 46 | get "current_status" do 47 | json(DOMAIN.current_app_info(**env[:app_store_connect_params].merge(params))) 48 | end 49 | 50 | scope "builds" do 51 | get ":build_number" do 52 | json(DOMAIN.build(**env[:app_store_connect_params].merge(params))) 53 | end 54 | 55 | get "latest" do 56 | json(DOMAIN.latest_build(**env[:app_store_connect_params].merge(params))) 57 | end 58 | 59 | patch ":build_number" do 60 | DOMAIN.update_build_notes(**env[:app_store_connect_params].merge(params)) 61 | status(204) 62 | end 63 | end 64 | 65 | scope "release" do 66 | post "prepare" do 67 | json(DOMAIN.prepare_release(**env[:app_store_connect_params].merge(params))) 68 | end 69 | 70 | patch "submit" do 71 | DOMAIN.create_review_submission(**env[:app_store_connect_params].merge(params)) 72 | status(204) 73 | end 74 | 75 | patch "cancel_submission" do 76 | json(DOMAIN.cancel_review_submission(**env[:app_store_connect_params].merge(params))) 77 | end 78 | 79 | patch "start" do 80 | DOMAIN.start_release(**env[:app_store_connect_params].merge(params)) 81 | status(204) 82 | end 83 | 84 | get "/" do 85 | json(DOMAIN.release(**env[:app_store_connect_params].merge(params))) 86 | end 87 | 88 | scope "live" do 89 | get "/" do 90 | json(DOMAIN.live_release(**env[:app_store_connect_params].merge(params))) 91 | end 92 | 93 | scope "rollout" do 94 | patch "pause" do 95 | json(DOMAIN.pause_phased_release(**env[:app_store_connect_params].merge(params))) 96 | end 97 | 98 | patch "resume" do 99 | json(DOMAIN.resume_phased_release(**env[:app_store_connect_params].merge(params))) 100 | end 101 | 102 | patch "complete" do 103 | json(DOMAIN.complete_phased_release(**env[:app_store_connect_params].merge(params))) 104 | end 105 | 106 | patch "halt" do 107 | DOMAIN.halt_release(**env[:app_store_connect_params].merge(params)) 108 | status(204) 109 | end 110 | end 111 | end 112 | end 113 | 114 | scope "groups" do 115 | get "/" do 116 | params[:internal] = params[:internal].nil? ? "nil" : params[:internal] 117 | json(DOMAIN.groups(**env[:app_store_connect_params].merge(params))) 118 | end 119 | 120 | patch ":group_id/add_build" do 121 | DOMAIN.send_to_group(**env[:app_store_connect_params].merge(params)) 122 | status(204) 123 | end 124 | end 125 | end 126 | end 127 | 128 | class App < Hanami::API 129 | include Initializers::Config 130 | extend Initializers::Env 131 | include Initializers::SentryConfig 132 | 133 | use Rack::Ougai::Logger 134 | use Rack::Ougai::RequestLogger 135 | use Rack::Runtime 136 | use Sentry::Rack::CaptureExceptions 137 | use Hanami::Middleware::BodyParser, :json 138 | 139 | get("ping") { "pong" } 140 | mount AppleAppV1.new, at: "apple/connect/v1" 141 | mount InternalApp.new, at: "internal" if development? 142 | end 143 | 144 | run App.new 145 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in the 6 | Tramline community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | codeofconduct@tramline.app. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /lib/app_store/errors.rb: -------------------------------------------------------------------------------- 1 | module AppStore 2 | def self.error_as_json(resource, code, message = "") 3 | { 4 | "resource" => resource.to_s, 5 | "code" => code.to_s, 6 | "message" => (message unless message.empty?) 7 | }.compact 8 | end 9 | 10 | class AppNotFoundError < StandardError 11 | def initialize(msg = "App not found") 12 | super 13 | end 14 | 15 | def as_json 16 | AppStore.error_as_json(:app, :not_found) 17 | end 18 | end 19 | 20 | class BuildNotFoundError < StandardError 21 | def as_json 22 | AppStore.error_as_json(:build, :not_found) 23 | end 24 | end 25 | 26 | class BetaGroupNotFoundError < StandardError 27 | def as_json 28 | AppStore.error_as_json(:beta_group, :not_found) 29 | end 30 | end 31 | 32 | class ExportComplianceNotFoundError < StandardError 33 | MSG = "Could not update missing export compliance attribute for the build." 34 | 35 | def initialize(msg = MSG) 36 | super 37 | end 38 | 39 | def as_json 40 | AppStore.error_as_json(:build, :export_compliance_not_updateable, MSG) 41 | end 42 | end 43 | 44 | class BuildSubmissionForReviewNotAllowedError < StandardError 45 | def as_json 46 | AppStore.error_as_json(:release, :review_submission_not_allowed) 47 | end 48 | end 49 | 50 | class ExportComplianceAlreadyUpdatedError < StandardError; end 51 | 52 | class VersionNotFoundError < StandardError 53 | MSG = "No app store version found to distribute" 54 | 55 | def initialize(msg = MSG) 56 | super 57 | end 58 | 59 | def as_json 60 | AppStore.error_as_json(:release, :not_found, MSG) 61 | end 62 | end 63 | 64 | class SubmissionNotFoundError < StandardError 65 | MSG = "No in progress review submission found" 66 | 67 | def initialize(msg = MSG) 68 | super 69 | end 70 | 71 | def as_json 72 | AppStore.error_as_json(:submission, :not_found, MSG) 73 | end 74 | end 75 | 76 | class BuildMismatchError < StandardError 77 | MSG = "The build on the release in app store does not match the build number" 78 | 79 | def initialize(msg = MSG) 80 | super 81 | end 82 | 83 | def as_json 84 | AppStore.error_as_json(:release, :build_mismatch, MSG) 85 | end 86 | end 87 | 88 | class ReviewAlreadyInProgressError < StandardError 89 | MSG = "There is a review already in progress, can not submit a new review to store" 90 | 91 | def initialize(msg = MSG) 92 | super 93 | end 94 | 95 | def as_json 96 | AppStore.error_as_json(:release, :review_in_progress) 97 | end 98 | end 99 | 100 | class InvalidReviewStateError < StandardError 101 | MSG = "The app store version is not in a valid state to submit for review" 102 | 103 | def initialize(msg = MSG) 104 | super 105 | end 106 | 107 | def as_json 108 | AppStore.error_as_json(:release, :invalid_review_state) 109 | end 110 | end 111 | 112 | class AttachmentUploadInProgress < StandardError 113 | MSG = "The app store version is not in a valid state to submit for review, attachment uploads still in progress" 114 | 115 | def initialize(msg = MSG) 116 | super 117 | end 118 | 119 | def as_json 120 | AppStore.error_as_json(:release, :attachment_upload_in_progress) 121 | end 122 | end 123 | 124 | class SubmissionWithItemsExistError < StandardError 125 | MSG = "Cannot submit for review - a review submission already exists with items" 126 | 127 | def initialize(msg = MSG) 128 | super 129 | end 130 | 131 | def as_json 132 | AppStore.error_as_json(:release, :review_already_created, MSG) 133 | end 134 | end 135 | 136 | class PhasedReleaseAlreadyInStateError < StandardError 137 | MSG = "The current live release is already in the state you are requesting" 138 | 139 | def as_json 140 | AppStore.error_as_json(:release, :release_already_in_state, MSG) 141 | end 142 | end 143 | 144 | class PhasedReleaseAlreadyFinalError < StandardError 145 | MSG = "The current phased release is already finished" 146 | 147 | def initialize(msg = MSG) 148 | super 149 | end 150 | 151 | def as_json 152 | AppStore.error_as_json(:release, :phased_release_already_final, MSG) 153 | end 154 | end 155 | 156 | class PhasedReleaseNotFoundError < StandardError 157 | MSG = "The current live release does not have a staged rollout" 158 | 159 | def initialize(msg = MSG) 160 | super 161 | end 162 | 163 | def as_json 164 | AppStore.error_as_json(:release, :phased_release_not_found, MSG) 165 | end 166 | end 167 | 168 | class ReleaseAlreadyHaltedError < StandardError 169 | MSG = "The release is already removed from sale for the latest version." 170 | 171 | def initialize(msg = MSG) 172 | super 173 | end 174 | 175 | def as_json 176 | AppStore.error_as_json(:release, :release_already_halted, MSG) 177 | end 178 | end 179 | 180 | class VersionAlreadyAddedToSubmissionError < StandardError 181 | MSG = "There is already an app store version in submission, can not start another release preparation" 182 | 183 | def initialize(msg = MSG) 184 | super 185 | end 186 | 187 | def as_json 188 | AppStore.error_as_json(:release, :release_already_prepared, MSG) 189 | end 190 | end 191 | 192 | class VersionAlreadyExistsError < StandardError 193 | MSG = "The build number has been previously used for a release" 194 | 195 | def initialize(msg = MSG) 196 | super 197 | end 198 | 199 | def as_json 200 | AppStore.error_as_json(:release, :version_already_exists, MSG) 201 | end 202 | end 203 | 204 | class ReleaseNotEditableError < StandardError 205 | MSG = "The release is now fully live and can not be updated" 206 | 207 | def initialize(msg = MSG) 208 | super 209 | end 210 | 211 | def as_json 212 | AppStore.error_as_json(:release, :release_fully_live, MSG) 213 | end 214 | end 215 | 216 | class UnexpectedAppstoreError < StandardError 217 | def as_json 218 | AppStore.error_as_json(:unknown, :unknown, message) 219 | end 220 | end 221 | 222 | class VersionNotEditableError < StandardError 223 | MSG = "The release is not editable in its current state" 224 | 225 | def initialize(msg = MSG) 226 | super 227 | end 228 | 229 | def as_json 230 | AppStore.error_as_json(:release, :release_not_editable, MSG) 231 | end 232 | end 233 | 234 | class LocalizationNotFoundError < StandardError 235 | MSG = "The localization for the app store version was not found" 236 | 237 | def initialize(msg = MSG) 238 | super 239 | end 240 | 241 | def as_json 242 | AppStore.error_as_json(:localization, :not_found, MSG) 243 | end 244 | end 245 | 246 | class ReviewSubmissionNotFound < StandardError 247 | MSG = "The review submission was not found" 248 | 249 | def initialize(msg = MSG) 250 | super 251 | end 252 | 253 | def as_json 254 | AppStore.error_as_json(:release, :review_submission_not_found, MSG) 255 | end 256 | end 257 | 258 | # 404 259 | NOT_FOUND_ERRORS = [ 260 | AppStore::AppNotFoundError, 261 | AppStore::BuildNotFoundError, 262 | AppStore::BetaGroupNotFoundError, 263 | AppStore::LocalizationNotFoundError, 264 | AppStore::ReviewSubmissionNotFound 265 | ] 266 | 267 | # 422 268 | ERRORS = [ 269 | AppStore::ExportComplianceNotFoundError, 270 | AppStore::BuildSubmissionForReviewNotAllowedError, 271 | AppStore::VersionNotFoundError, 272 | AppStore::ReviewAlreadyInProgressError, 273 | AppStore::SubmissionWithItemsExistError, 274 | AppStore::BuildMismatchError, 275 | AppStore::VersionAlreadyAddedToSubmissionError, 276 | AppStore::VersionAlreadyExistsError, 277 | AppStore::UnexpectedAppstoreError, 278 | AppStore::VersionNotEditableError 279 | ] 280 | 281 | # 409 282 | CONFLICT_ERRORS = [ 283 | AppStore::PhasedReleaseAlreadyInStateError, 284 | AppStore::PhasedReleaseAlreadyFinalError, 285 | AppStore::ReleaseNotEditableError, 286 | AppStore::ReleaseAlreadyHaltedError 287 | ] 288 | end 289 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.6) 5 | rexml 6 | addressable (2.8.4) 7 | public_suffix (>= 2.0.2, < 6.0) 8 | artifactory (3.0.15) 9 | ast (2.4.2) 10 | atomos (0.1.3) 11 | aws-eventstream (1.2.0) 12 | aws-partitions (1.792.0) 13 | aws-sdk-core (3.180.0) 14 | aws-eventstream (~> 1, >= 1.0.2) 15 | aws-partitions (~> 1, >= 1.651.0) 16 | aws-sigv4 (~> 1.5) 17 | jmespath (~> 1, >= 1.6.1) 18 | aws-sdk-kms (1.71.0) 19 | aws-sdk-core (~> 3, >= 3.177.0) 20 | aws-sigv4 (~> 1.1) 21 | aws-sdk-s3 (1.132.0) 22 | aws-sdk-core (~> 3, >= 3.179.0) 23 | aws-sdk-kms (~> 1) 24 | aws-sigv4 (~> 1.6) 25 | aws-sigv4 (1.6.0) 26 | aws-eventstream (~> 1, >= 1.0.2) 27 | babosa (1.0.4) 28 | bundler-audit (0.9.1) 29 | bundler (>= 1.2.0, < 3) 30 | thor (~> 1.0) 31 | claide (1.1.0) 32 | coderay (1.1.3) 33 | colored (1.2) 34 | colored2 (3.1.2) 35 | commander (4.6.0) 36 | highline (~> 2.0.0) 37 | concurrent-ruby (1.2.0) 38 | declarative (0.0.20) 39 | digest-crc (0.6.5) 40 | rake (>= 12.0.0, < 14.0.0) 41 | domain_name (0.5.20190701) 42 | unf (>= 0.0.5, < 1.0.0) 43 | dotenv (2.8.1) 44 | emoji_regex (3.2.3) 45 | excon (0.100.0) 46 | faraday (1.10.3) 47 | faraday-em_http (~> 1.0) 48 | faraday-em_synchrony (~> 1.0) 49 | faraday-excon (~> 1.1) 50 | faraday-httpclient (~> 1.0) 51 | faraday-multipart (~> 1.0) 52 | faraday-net_http (~> 1.0) 53 | faraday-net_http_persistent (~> 1.0) 54 | faraday-patron (~> 1.0) 55 | faraday-rack (~> 1.0) 56 | faraday-retry (~> 1.0) 57 | ruby2_keywords (>= 0.0.4) 58 | faraday-cookie_jar (0.0.7) 59 | faraday (>= 0.8.0) 60 | http-cookie (~> 1.0.0) 61 | faraday-em_http (1.0.0) 62 | faraday-em_synchrony (1.0.0) 63 | faraday-excon (1.1.0) 64 | faraday-httpclient (1.0.1) 65 | faraday-multipart (1.0.4) 66 | multipart-post (~> 2) 67 | faraday-net_http (1.0.1) 68 | faraday-net_http_persistent (1.2.0) 69 | faraday-patron (1.0.0) 70 | faraday-rack (1.0.0) 71 | faraday-retry (1.0.3) 72 | faraday_middleware (1.2.0) 73 | faraday (~> 1.0) 74 | fastimage (2.2.7) 75 | fastlane (2.214.0) 76 | CFPropertyList (>= 2.3, < 4.0.0) 77 | addressable (>= 2.8, < 3.0.0) 78 | artifactory (~> 3.0) 79 | aws-sdk-s3 (~> 1.0) 80 | babosa (>= 1.0.3, < 2.0.0) 81 | bundler (>= 1.12.0, < 3.0.0) 82 | colored 83 | commander (~> 4.6) 84 | dotenv (>= 2.1.1, < 3.0.0) 85 | emoji_regex (>= 0.1, < 4.0) 86 | excon (>= 0.71.0, < 1.0.0) 87 | faraday (~> 1.0) 88 | faraday-cookie_jar (~> 0.0.6) 89 | faraday_middleware (~> 1.0) 90 | fastimage (>= 2.1.0, < 3.0.0) 91 | gh_inspector (>= 1.1.2, < 2.0.0) 92 | google-apis-androidpublisher_v3 (~> 0.3) 93 | google-apis-playcustomapp_v1 (~> 0.1) 94 | google-cloud-storage (~> 1.31) 95 | highline (~> 2.0) 96 | json (< 3.0.0) 97 | jwt (>= 2.1.0, < 3) 98 | mini_magick (>= 4.9.4, < 5.0.0) 99 | multipart-post (>= 2.0.0, < 3.0.0) 100 | naturally (~> 2.2) 101 | optparse (~> 0.1.1) 102 | plist (>= 3.1.0, < 4.0.0) 103 | rubyzip (>= 2.0.0, < 3.0.0) 104 | security (= 0.1.3) 105 | simctl (~> 1.6.3) 106 | terminal-notifier (>= 2.0.0, < 3.0.0) 107 | terminal-table (>= 1.4.5, < 2.0.0) 108 | tty-screen (>= 0.6.3, < 1.0.0) 109 | tty-spinner (>= 0.8.0, < 1.0.0) 110 | word_wrap (~> 1.0.0) 111 | xcodeproj (>= 1.13.0, < 2.0.0) 112 | xcpretty (~> 0.3.0) 113 | xcpretty-travis-formatter (>= 0.0.3) 114 | ffi (1.15.5) 115 | formatador (1.1.0) 116 | gh_inspector (1.1.3) 117 | google-apis-androidpublisher_v3 (0.46.0) 118 | google-apis-core (>= 0.11.0, < 2.a) 119 | google-apis-core (0.11.1) 120 | addressable (~> 2.5, >= 2.5.1) 121 | googleauth (>= 0.16.2, < 2.a) 122 | httpclient (>= 2.8.1, < 3.a) 123 | mini_mime (~> 1.0) 124 | representable (~> 3.0) 125 | retriable (>= 2.0, < 4.a) 126 | rexml 127 | webrick 128 | google-apis-iamcredentials_v1 (0.17.0) 129 | google-apis-core (>= 0.11.0, < 2.a) 130 | google-apis-playcustomapp_v1 (0.13.0) 131 | google-apis-core (>= 0.11.0, < 2.a) 132 | google-apis-storage_v1 (0.19.0) 133 | google-apis-core (>= 0.9.0, < 2.a) 134 | google-cloud-core (1.6.0) 135 | google-cloud-env (~> 1.0) 136 | google-cloud-errors (~> 1.0) 137 | google-cloud-env (1.6.0) 138 | faraday (>= 0.17.3, < 3.0) 139 | google-cloud-errors (1.3.1) 140 | google-cloud-storage (1.44.0) 141 | addressable (~> 2.8) 142 | digest-crc (~> 0.4) 143 | google-apis-iamcredentials_v1 (~> 0.1) 144 | google-apis-storage_v1 (~> 0.19.0) 145 | google-cloud-core (~> 1.6) 146 | googleauth (>= 0.16.2, < 2.a) 147 | mini_mime (~> 1.0) 148 | googleauth (1.7.0) 149 | faraday (>= 0.17.3, < 3.a) 150 | jwt (>= 1.4, < 3.0) 151 | memoist (~> 0.16) 152 | multi_json (~> 1.11) 153 | os (>= 0.9, < 2.0) 154 | signet (>= 0.16, < 2.a) 155 | guard (2.18.0) 156 | formatador (>= 0.2.4) 157 | listen (>= 2.7, < 4.0) 158 | lumberjack (>= 1.0.12, < 2.0) 159 | nenv (~> 0.1) 160 | notiffany (~> 0.0) 161 | pry (>= 0.13.0) 162 | shellany (~> 0.0) 163 | thor (>= 0.18.1) 164 | guard-rack (2.2.1) 165 | ffi 166 | guard (~> 2.3) 167 | spoon 168 | hanami-api (0.3.0) 169 | hanami-router (~> 2.0) 170 | hanami-router (2.0.2) 171 | mustermann (~> 3.0) 172 | mustermann-contrib (~> 3.0) 173 | rack (~> 2.0) 174 | hansi (0.2.1) 175 | highline (2.0.3) 176 | http-cookie (1.0.5) 177 | domain_name (~> 0.5) 178 | httpclient (2.8.3) 179 | jmespath (1.6.2) 180 | json (2.6.3) 181 | jwt (2.7.1) 182 | language_server-protocol (3.17.0.2) 183 | listen (3.8.0) 184 | rb-fsevent (~> 0.10, >= 0.10.3) 185 | rb-inotify (~> 0.9, >= 0.9.10) 186 | lumberjack (1.2.8) 187 | memoist (0.16.2) 188 | method_source (1.0.0) 189 | mini_magick (4.12.0) 190 | mini_mime (1.1.2) 191 | multi_json (1.15.0) 192 | multipart-post (2.3.0) 193 | mustermann (3.0.0) 194 | ruby2_keywords (~> 0.0.1) 195 | mustermann-contrib (3.0.0) 196 | hansi (~> 0.2.0) 197 | mustermann (= 3.0.0) 198 | nanaimo (0.3.0) 199 | naturally (2.2.1) 200 | nenv (0.3.0) 201 | nio4r (2.7.3) 202 | notiffany (0.1.3) 203 | nenv (~> 0.1) 204 | shellany (~> 0.0) 205 | oj (3.14.2) 206 | optparse (0.1.1) 207 | os (1.1.4) 208 | ougai (2.0.0) 209 | oj (~> 3.10) 210 | parallel (1.22.1) 211 | parser (3.2.0.0) 212 | ast (~> 2.4.1) 213 | plist (3.7.0) 214 | pry (0.14.2) 215 | coderay (~> 1.1) 216 | method_source (~> 1.0) 217 | public_suffix (5.0.1) 218 | puma (6.4.3) 219 | nio4r (~> 2.0) 220 | rack (2.2.9) 221 | rack-jwt (0.4.0) 222 | jwt (~> 2.0) 223 | rack (>= 1.6.0) 224 | rainbow (3.1.1) 225 | rake (13.0.6) 226 | rb-fsevent (0.11.2) 227 | rb-inotify (0.10.1) 228 | ffi (~> 1.0) 229 | regexp_parser (2.6.2) 230 | representable (3.2.0) 231 | declarative (< 0.1.0) 232 | trailblazer-option (>= 0.1.1, < 0.2.0) 233 | uber (< 0.2.0) 234 | retriable (3.1.2) 235 | retryable (3.0.5) 236 | rexml (3.2.9) 237 | strscan 238 | rouge (2.0.7) 239 | rubocop (1.42.0) 240 | json (~> 2.3) 241 | parallel (~> 1.10) 242 | parser (>= 3.1.2.1) 243 | rainbow (>= 2.2.2, < 4.0) 244 | regexp_parser (>= 1.8, < 3.0) 245 | rexml (>= 3.2.5, < 4.0) 246 | rubocop-ast (>= 1.24.1, < 2.0) 247 | ruby-progressbar (~> 1.7) 248 | unicode-display_width (>= 1.4.0, < 3.0) 249 | rubocop-ast (1.24.1) 250 | parser (>= 3.1.1.0) 251 | rubocop-performance (1.15.2) 252 | rubocop (>= 1.7.0, < 2.0) 253 | rubocop-ast (>= 0.4.0) 254 | ruby-progressbar (1.11.0) 255 | ruby2_keywords (0.0.5) 256 | rubyzip (2.3.2) 257 | security (0.1.3) 258 | sentry-ruby (5.7.0) 259 | concurrent-ruby (~> 1.0, >= 1.0.2) 260 | shellany (0.0.1) 261 | signet (0.17.0) 262 | addressable (~> 2.8) 263 | faraday (>= 0.17.5, < 3.a) 264 | jwt (>= 1.5, < 3.0) 265 | multi_json (~> 1.10) 266 | simctl (1.6.10) 267 | CFPropertyList 268 | naturally 269 | spoon (0.0.6) 270 | ffi 271 | standard (1.22.1) 272 | language_server-protocol (~> 3.17.0.2) 273 | rubocop (= 1.42.0) 274 | rubocop-performance (= 1.15.2) 275 | strscan (3.1.0) 276 | terminal-notifier (2.0.0) 277 | terminal-table (1.8.0) 278 | unicode-display_width (~> 1.1, >= 1.1.1) 279 | thor (1.2.1) 280 | trailblazer-option (0.1.2) 281 | tty-cursor (0.7.1) 282 | tty-screen (0.8.1) 283 | tty-spinner (0.9.3) 284 | tty-cursor (~> 0.7) 285 | uber (0.1.0) 286 | unf (0.1.4) 287 | unf_ext 288 | unf_ext (0.0.8.2) 289 | unicode-display_width (1.8.0) 290 | webrick (1.8.2) 291 | word_wrap (1.0.0) 292 | xcodeproj (1.22.0) 293 | CFPropertyList (>= 2.3.3, < 4.0) 294 | atomos (~> 0.1.3) 295 | claide (>= 1.0.2, < 2.0) 296 | colored2 (~> 3.1) 297 | nanaimo (~> 0.3.0) 298 | rexml (~> 3.2.4) 299 | xcpretty (0.3.0) 300 | rouge (~> 2.0.7) 301 | xcpretty-travis-formatter (1.0.1) 302 | xcpretty (~> 0.2, >= 0.0.7) 303 | 304 | PLATFORMS 305 | aarch64-linux 306 | arm64-darwin-22 307 | x86_64-darwin-19 308 | x86_64-linux 309 | 310 | DEPENDENCIES 311 | bundler-audit (~> 0.9.1) 312 | dotenv 313 | fastlane 314 | guard-rack 315 | hanami-api 316 | ougai 317 | puma 318 | rack-jwt 319 | retryable 320 | sentry-ruby 321 | standard 322 | 323 | RUBY VERSION 324 | ruby 3.2.0p0 325 | 326 | BUNDLED WITH 327 | 2.4.2 328 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Tramline Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | applelink-banner-shado 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 |

14 | Practical recipes over the App Store Connect API via Fastlane 15 |

16 | 17 |

18 | Read more about the why in this blog post. 19 |

20 | 21 | ## Rationale 22 | 23 | Applelink is a small, self-contained, rack-based service using [Hanami::API](https://github.com/hanami/api), that wraps over [Spaceship](https://spaceship.airforce) and exposes some nice common recipes as RESTful endpoints in an entirely stateless fashion. 24 | 25 | These are based on the needs of the framework that [Tramline](https://tramline.app) implements over App Store. The API pulls its weight so Tramline has to do as little as possible. Currently, it exposes [13 API endpoints](#api). 26 | 27 | In Applelink, a complex recipe, such as [release/prepare](#prepare-a-release-for-the-app), will perform the following tasks all bunched up: 28 | - Ensure that there is an App Store version that we can use for the release, or create a new one 29 | - Update the release metadata for that release version 30 | - Enable phased releases, if necessary 31 | 32 | Similarly a simple fetch endpoint like [release/live](#fetch-the-status-of-current-live-release) will give you the current status of the latest release. 33 | 34 | Applelink is a separate service that is not reliant on Tramline’s internal state. It can be used in a standalone way, for e.g. from a CI workflow, or a Slack bot that spits out app release information. 35 | 36 | ## Development 37 | 38 | ### Running 39 | 40 | ```bash 41 | bundle install 42 | just start 43 | just lint # run lint 44 | ``` 45 | 46 | ### Auth token 47 | 48 | All APIs (except ping) are secured by JWT auth. Please use standard authorization header: 49 | ``` 50 | Authorization: Bearer 51 | ``` 52 | 53 | The `AUTH_TOKEN` can be generated using `HS256` algo and the secret for generating and verifying the token is shared between Tramline/any other client and applelink. 54 | 55 | These can be configured using the following env variables: 56 | 57 | ```text 58 | AUTH_ISSUER=tramline.dev 59 | AUTH_SECRET=password 60 | AUTH_AUD=applelink 61 | ``` 62 | These values can be set to whatever you want, as long as they are same between the caller and Applelink. 63 | 64 | Example code for generating the token can be taken from [this file](https://github.com/tramlinehq/tramline/blob/main/app/libs/installations/apple/app_store_connect/jwt.rb) in the Tramline repo. 65 | 66 | In addition to the auth token, you also need the App Store Connect JWT token which is documented [here](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests). 67 | 68 | #### Internal API 69 | 70 | For the development environment, you can generate the above tokens using the following helper API: 71 | 72 | ```shell 73 | curl -i -X GET http://127.0.0.1:4000/internal/keys?key_id=KEY_ID&issuer_id=ISSUER_ID 74 | 75 | { 76 | "store_token": "eyJraWQiOiJLRVlfSUQiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJJU1NVRVJfSUQiLCJpYXQiOjE2ODIwNjA1MzYsImV4cCI6MTY4MjA2MTAzNiwiYXVkIjoiYXBwc3RvcmVjb25uZWN0LXYxIn0.-pFtamhBjsNKLr5Z2Ft2tW9H2NojBF1d8RqQBr7nNZF43KUNGMQIPQyp9BCSrFXJop1k7hk7jJstXRJ-WMH_8Q", 77 | "auth_token": "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODIwNjA1MzYsImV4cCI6MTY4MjA2MTUzNiwiYXVkIjoiYXBwbGVsaW5rIiwiaXNzIjoidHJhbWxpbmUuZGV2In0.HDJJw6o6YK-Jmzpl0Xu4SmlTcGtNeEFI0VIg6fqitdw" 78 | } 79 | ``` 80 | This expects the correct env variables to be set for `AUTH_TOKEN` and the App Store Connect `key.p8` file to be present in the Applelink directory along with the relevant `KEY_ID` and `ISSUER_iD` being passed to the API. 81 | 82 | ## API 83 | 84 | One can also use [requests](test/requests) in [restclient-mode](https://github.com/pashky/restclient.el) to interactively play around with the entire API including fetching and refreshing tokens. 85 | 86 | ### Headers 87 | 88 | | Name | Description | 89 | |------|-------------| 90 | | `Authorization` | Bearer token signed by tramline | 91 | | `Content-Type` | Most endpoints expect `application/json` | 92 | | `X-AppStoreConnect-Key-Id` | App Store Connect key id acquired from the portal | 93 | | `X-AppStoreConnect-Issuer-Id` | App Store Connect issuer id acquired from the portal | 94 | | `X-AppStoreConnect-Token` | App Store Connect expirable JWT signed using the key-id and issuer-id | 95 | 96 | #### Fetch metadata for an App 97 | 98 |
99 | GET /apple/connect/v1/apps/:bundle-id 100 | 101 | ##### Path parameters 102 | 103 | > | name | type | data type | description | 104 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 105 | > | bundle-id | required | string | app's unique identifier | 106 | 107 | 108 | ##### Example cURL 109 | 110 | > ```bash 111 | > curl -X GET \ 112 | > -H "Authorization: Bearer token" \ 113 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 114 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 115 | > -H "X-AppStoreConnect-Token: token" \ 116 | > -H "Content-Type: application/json" \ 117 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app 118 | > ``` 119 | 120 | ##### Success response 121 | > ```json 122 | > { 123 | > "id": "1658845856", 124 | > "name": "Ueno", 125 | > "bundle_id": "com.tramline.ueno", 126 | > "sku": "com.tramline.ueno" 127 | > } 128 | > ``` 129 | 130 |
131 | 132 | #### Fetch live info for an app 133 | 134 |
135 | GET /apple/connect/v1/apps/:bundle-id/current_status 136 | 137 | ##### Path parameters 138 | 139 | > | name | type | data type | description | 140 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 141 | > | bundle-id | required | string | app's unique identifier | 142 | 143 | ##### Example cURL 144 | 145 | > ```bash 146 | > curl -X GET \ 147 | > -H "Authorization: Bearer token" \ 148 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 149 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 150 | > -H "X-AppStoreConnect-Token: token" \ 151 | > -H "Content-Type: application/json" \ 152 | > http://localhost:4000/apple/connect/v1/apps/:bundle-id/current_status 153 | > ``` 154 | 155 | ##### Success response 156 | > ```json 157 | > [ 158 | > { 159 | > "name": "Big External Group", 160 | > "builds": [ 161 | > { 162 | > "id": "da720570-cb6e-4b25-b82f-790045a6038e", 163 | > "build_number": "10001", 164 | > "status": "BETA_APPROVED", 165 | > "version_string": "1.46.0", 166 | > "release_date": "2023-04-17T07:03:01-07:00" 167 | > }, 168 | > { 169 | > "id": "1c4d0eb3-5cec-47f2-a843-949b12a69784", 170 | > "build_number": "9103", 171 | > "status": "BETA_APPROVED", 172 | > "version_string": "1.45.0", 173 | > "release_date": "2023-04-13T00:09:38-07:00" 174 | > } 175 | > ] 176 | > }, 177 | > { 178 | > "name": "Small External Group", 179 | > "builds": [ 180 | > { 181 | > "id": "e1aa4795-0df2-4d76-b899-8ee95fb8589e", 182 | > "build_number": "10002", 183 | > "status": "BETA_APPROVED", 184 | > "version_string": "1.47.0", 185 | > "release_date": "2023-04-17T10:00:19-07:00" 186 | > }, 187 | > { 188 | > "id": "da720570-cb6e-4b25-b82f-790045a6038e", 189 | > "build_number": "10001", 190 | > "status": "BETA_APPROVED", 191 | > "version_string": "1.46.0", 192 | > "release_date": "2023-04-17T07:03:01-07:00" 193 | > } 194 | > ] 195 | > }, 196 | > { 197 | > "name": "production", 198 | > "builds": [ 199 | > { 200 | > "id": "bf11d7a3-fe1c-4c71-acae-a9dc8af57907", 201 | > "version_string": "1.44.1", 202 | > "status": "READY_FOR_SALE", 203 | > "release_date": "2023-04-11T22:45:25-07:00", 204 | > "build_number": "9086" 205 | > } 206 | > ] 207 | > } 208 | > ] 209 | > ``` 210 | 211 |
212 | 213 | #### Fetch all beta groups for an app 214 | 215 |
216 | GET /apple/connect/v1/apps/:bundle-id/groups 217 | 218 | ##### Path parameters 219 | 220 | > | name | type | data type | description | 221 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 222 | > | bundle-id | required | string | app's unique identifier | 223 | 224 | ##### Example cURL 225 | 226 | > ```bash 227 | > curl -X GET \ 228 | > -H "Authorization: Bearer token" \ 229 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 230 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 231 | > -H "X-AppStoreConnect-Token: token" \ 232 | > -H "Content-Type: application/json" \ 233 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/groups 234 | > ``` 235 | 236 | ##### Success response 237 | > ```json 238 | > [{ 239 | > "name": "The Pledge", 240 | > "id": "fcacfdf7-db62-44af-a0cb-0676e17c251b", 241 | > "internal": true 242 | > }, 243 | > { 244 | > "name": "The Prestige", 245 | > "id": "2cd6be09-d959-4ed3-a4e7-db8cabbe44d0", 246 | > "internal": true 247 | > }, 248 | > { 249 | > "name": "The Trick", 250 | > "id": "dab66de0-7af2-48ae-97af-cc8dfdbde51d", 251 | > "internal": true 252 | > }, 253 | > { 254 | > "name": "Big External Group", 255 | > "id": "3bc1ca3e-1d4f-4478-8f38-2dcae4dcbb69", 256 | > "internal": false 257 | > }, 258 | > { 259 | > "name": "Small External Group", 260 | > "id": "dc64b810-1157-4228-825b-eb9e95cc8fba", 261 | > "internal": false 262 | > }] 263 | > ``` 264 | 265 |
266 | 267 | #### Fetch a single build for an app 268 | 269 |
270 | GET /apple/connect/v1/apps/:bundle-id/builds/:build-number 271 | 272 | ##### Path parameters 273 | 274 | > | name | type | data type | description | 275 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 276 | > | bundle-id | required | string | app's unique identifier | 277 | > | build-number | required | integer | build number | 278 | 279 | ##### Example cURL 280 | 281 | > ```bash 282 | > curl -X GET \ 283 | > -H "Authorization: Bearer token" \ 284 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 285 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 286 | > -H "X-AppStoreConnect-Token: token" \ 287 | > -H "Content-Type: application/json" \ 288 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/builds/9018 289 | > ``` 290 | 291 | ##### Success response 292 | > ```json 293 | > { 294 | > "id": "bc90d402-ed0c-4d05-887f-d300abc104e9", 295 | > "build_number": "9018", 296 | > "beta_internal_state": "IN_BETA_TESTING", 297 | > "beta_external_state": "BETA_APPROVED", 298 | > "uploaded_date": "2023-02-22T22:27:48-08:00", 299 | > "expired": false, 300 | > "processing_state": "VALID", 301 | > "version_string": "1.5.0" 302 | > } 303 | > ``` 304 | 305 | 306 |
307 | 308 | #### Update the test notes for a build in TestFlight 309 | 310 |
311 | PATCH /apple/connect/v1/apps/:bundle-id/builds/:build-number 312 | 313 | ##### Path parameters 314 | 315 | > | name | type | data type | description | 316 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 317 | > | bundle-id | required | string | app's unique identifier | 318 | 319 | ##### JSON Parameters 320 | 321 | > | name | type | data type | description | 322 | > |------|-----------|--------------------------------------------------|-------------------------| 323 | > | notes | required | string | "test the feature to add release notes to builds" | 324 | 325 | ##### Example cURL 326 | 327 | > ```bash 328 | > curl -X PATCH \ 329 | > -H "Authorization: Bearer token" \ 330 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 331 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 332 | > -H "X-AppStoreConnect-Token: token" \ 333 | > -H "Content-Type: application/json" \ 334 | > -d '{"notes": "test the feature to add release notes to builds"}' \ 335 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/builds/latest 336 | > ``` 337 | 338 |
339 | 340 | #### Fetch the latest build for an app 341 | 342 |
343 | GET /apple/connect/v1/apps/:bundle-id/builds/latest 344 | 345 | ##### Path parameters 346 | 347 | > | name | type | data type | description | 348 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 349 | > | bundle-id | required | string | app's unique identifier | 350 | 351 | ##### Example cURL 352 | 353 | > ```bash 354 | > curl -X GET \ 355 | > -H "Authorization: Bearer token" \ 356 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 357 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 358 | > -H "X-AppStoreConnect-Token: token" \ 359 | > -H "Content-Type: application/json" \ 360 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/builds/latest 361 | > ``` 362 | 363 | ##### Success response 364 | > ```json 365 | > { 366 | > "id": "bc90d402-ed0c-4d05-887f-d300abc104e9", 367 | > "build_number": "9018", 368 | > "beta_internal_state": "IN_BETA_TESTING", 369 | > "beta_external_state": "BETA_APPROVED", 370 | > "uploaded_date": "2023-02-22T22:27:48-08:00", 371 | > "expired": false, 372 | > "processing_state": "VALID", 373 | > "version_string": "1.5.0" 374 | > } 375 | > ``` 376 | 377 | 378 |
379 | 380 | #### Assign a build to a beta group 381 | 382 |
383 | PATCH /apple/connect/v1/apps/:bundle_id/groups/:group-id/add_build 384 | 385 | ##### Path parameters 386 | 387 | > | name | type | data type | description | 388 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 389 | > | bundle-id | required | string | app's unique identifier | 390 | > | build-number | required | integer | build number | 391 | > | group-id | required | string | beta group id (uuid) | 392 | 393 | ##### Example cURL 394 | 395 | > ```bash 396 | > curl -X PATCH \ 397 | > -H "Authorization: Bearer token" \ 398 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 399 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 400 | > -H "X-AppStoreConnect-Token: token" \ 401 | > -H "Content-Type: application/json" \ 402 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/groups/3bc1ca3e-1d4f-4478-8f38-2dcae4dcbb69/add_build 403 | > ``` 404 | 405 |
406 | 407 | #### Prepare a release for the app 408 | 409 |
410 | POST /apple/connect/v1/apps/:bundle_id/release/prepare 411 | 412 | ##### Path parameters 413 | 414 | > | name | type | data type | description | 415 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 416 | > | bundle-id | required | string | app's unique identifier | 417 | 418 | ##### JSON Parameters 419 | 420 | > | name | type | data type | description | 421 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 422 | > | build-number | required | integer | build number | 423 | > | version | required | string | version name | 424 | > | is_phased_release | optional | boolean | flag to enable or disable phased release, defaults to false | 425 | > | metadata | required | hash | { "promotional_text": "this is the app store version promo text", "whats_new": "release notes"} | 426 | 427 | ##### Example cURL 428 | 429 | > ```bash 430 | > curl -X POST \ 431 | > -H "Authorization: Bearer token" \ 432 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 433 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 434 | > -H "X-AppStoreConnect-Token: token" \ 435 | > -H "Content-Type: application/json" \ 436 | > -d '{"build_number": 9018, "version": "1.6.2", "is_phased_release": true, "metadata": {"promotional_text": "app store version promo text", "whats_new": "release notes"} }' \ 437 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/prepare 438 | > ``` 439 | 440 |
441 | 442 | #### Submit a release for review 443 | 444 |
445 | PATCH /apple/connect/v1/apps/:bundle_id/release/submit 446 | 447 | ##### Path parameters 448 | 449 | > | name | type | data type | description | 450 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 451 | > | bundle-id | required | string | app's unique identifier | 452 | 453 | ##### JSON Parameters 454 | 455 | > | name | type | data type | description | 456 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 457 | > | build-number | required | integer | build number | 458 | 459 | ##### Example cURL 460 | 461 | > ```bash 462 | > curl -X PATCH \ 463 | > -H "Authorization: Bearer token" \ 464 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 465 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 466 | > -H "X-AppStoreConnect-Token: token" \ 467 | > -H "Content-Type: application/json" \ 468 | > -d '{"build_number": 9018}' \ 469 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/submit 470 | > ``` 471 | 472 |
473 | 474 | #### Fetch the status of current inflight release 475 | 476 |
477 | GET /apple/connect/v1/apps/:bundle-id/release?build_number=:build-number 478 | 479 | ##### Path parameters 480 | 481 | > | name | type | data type | description | 482 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 483 | > | bundle-id | required | string | app's unique identifier | 484 | 485 | ##### Query parameters 486 | 487 | > | name | type | data type | description | 488 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 489 | > | build-number | required | integer | build number | 490 | 491 | ##### Example cURL 492 | 493 | > ```bash 494 | > curl -X GET \ 495 | > -H "Authorization: Bearer token" \ 496 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 497 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 498 | > -H "X-AppStoreConnect-Token: token" \ 499 | > -H "Content-Type: application/json" \ 500 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release?build_number=500 501 | > ``` 502 | 503 | ##### Success response 504 | > ```json 505 | > { 506 | > "id": "bd31faa6-6a9a-4958-82de-d271ddc639a8", 507 | > "version_name": "1.8.0", 508 | > "app_store_state": "PENDING_DEVELOPER_RELEASE", 509 | > "release_type": "MANUAL", 510 | > "earliest_release_date": null, 511 | > "downloadable": true, 512 | > "created_date": "2023-02-25T03:02:46-08:00", 513 | > "build_number": "33417", 514 | > "build_id": "31aafef2-d5fb-45d4-9b02-f0ab5911c1b2", 515 | > "phased_release": { 516 | > "id": "bd31faa6-6a9a-4958-82de-d271ddc639a8", 517 | > "phased_release_state": "INACTIVE", 518 | > "start_date": "2023-02-28T06:38:39Z", 519 | > "total_pause_duration": 0, 520 | > "current_day_number": 0 521 | > }, 522 | > "details": { 523 | > "id": "ef59d099-6154-4ccb-826b-3ffe6005ed59", 524 | > "description": "The true Yamanote line aural aesthetic.", 525 | > "locale": "en-US", 526 | > "keywords": "japanese, aural, subway", 527 | > "marketing_url": null, 528 | > "promotional_text": null, 529 | > "support_url": "http://tramline.app", 530 | > "whats_new": "We now have the total distance covered by each station across the line!" 531 | > } 532 | > } 533 | > ``` 534 | 535 |
536 | 537 | 538 | #### Start a release after review is approved 539 | 540 |
541 | PATCH /apple/connect/v1/apps/:bundle_id/release/start 542 | 543 | ##### Path parameters 544 | 545 | > | name | type | data type | description | 546 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 547 | > | bundle-id | required | string | app's unique identifier | 548 | 549 | ##### JSON Parameters 550 | 551 | > | name | type | data type | description | 552 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 553 | > | build-number | required | integer | build number | 554 | 555 | ##### Example cURL 556 | 557 | > ```bash 558 | > curl -X PATCH \ 559 | > -H "Authorization: Bearer token" \ 560 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 561 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 562 | > -H "X-AppStoreConnect-Token: token" \ 563 | > -H "Content-Type: application/json" \ 564 | > -d '{"build_number": 9018}' \ 565 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/start 566 | > ``` 567 | 568 |
569 | 570 | #### Fetch the status of current live release 571 | 572 |
573 | GET /apple/connect/v1/apps/:bundle-id/release/live 574 | 575 | ##### Path parameters 576 | 577 | > | name | type | data type | description | 578 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 579 | > | bundle-id | required | string | app's unique identifier | 580 | 581 | ##### Example cURL 582 | 583 | > ```bash 584 | > curl -X GET \ 585 | > -H "Authorization: Bearer token" \ 586 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 587 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 588 | > -H "X-AppStoreConnect-Token: token" \ 589 | > -H "Content-Type: application/json" \ 590 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/live 591 | > ``` 592 | 593 | ##### Success response 594 | > ```json 595 | > { 596 | > "id": "bd31faa6-6a9a-4958-82de-d271ddc639a8", 597 | > "version_name": "1.8.0", 598 | > "app_store_state": "READY_FOR_SALE", 599 | > "release_type": "MANUAL", 600 | > "earliest_release_date": null, 601 | > "downloadable": true, 602 | > "created_date": "2023-02-25T03:02:46-08:00", 603 | > "build_number": "33417", 604 | > "build_id": "31aafef2-d5fb-45d4-9b02-f0ab5911c1b2", 605 | > "phased_release": { 606 | > "id": "bd31faa6-6a9a-4958-82de-d271ddc639a8", 607 | > "phased_release_state": "COMPLETE", 608 | > "start_date": "2023-02-28T06:38:39Z", 609 | > "total_pause_duration": 0, 610 | > "current_day_number": 4 611 | > }, 612 | > "details": { 613 | > "id": "ef59d099-6154-4ccb-826b-3ffe6005ed59", 614 | > "description": "The true Yamanote line aural aesthetic.", 615 | > "locale": "en-US", 616 | > "keywords": "japanese, aural, subway", 617 | > "marketing_url": null, 618 | > "promotional_text": null, 619 | > "support_url": "http://tramline.app", 620 | > "whats_new": "We now have the total distance covered by each station across the line!" 621 | > } 622 | > } 623 | > ``` 624 | 625 | 626 |
627 | 628 | #### Pause the rollout of current live release 629 | 630 |
631 | PATCH /apple/connect/v1/apps/:bundle_id/release/live/rollout/pause 632 | 633 | ##### Path parameters 634 | 635 | > | name | type | data type | description | 636 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 637 | > | bundle-id | required | string | app's unique identifier | 638 | 639 | ##### Example cURL 640 | 641 | > ```bash 642 | > curl -X PATCH \ 643 | > -H "Authorization: Bearer token" \ 644 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 645 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 646 | > -H "X-AppStoreConnect-Token: token" \ 647 | > -H "Content-Type: application/json" \ 648 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/live/rollout/pause 649 | > ``` 650 | 651 |
652 | 653 | #### Resume the rollout of current live release 654 | 655 |
656 | PATCH /apple/connect/v1/apps/:bundle_id/release/live/rollout/resume 657 | 658 | ##### Path parameters 659 | 660 | > | name | type | data type | description | 661 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 662 | > | bundle-id | required | string | app's unique identifier | 663 | 664 | ##### Example cURL 665 | 666 | > ```bash 667 | > curl -X PATCH \ 668 | > -H "Authorization: Bearer token" \ 669 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 670 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 671 | > -H "X-AppStoreConnect-Token: token" \ 672 | > -H "Content-Type: application/json" \ 673 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/live/rollout/resume 674 | > ``` 675 | 676 |
677 | 678 | #### Fully release the current live release to all users 679 | 680 |
681 | PATCH /apple/connect/v1/apps/:bundle_id/release/live/rollout/complete 682 | 683 | ##### Path parameters 684 | 685 | > | name | type | data type | description | 686 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 687 | > | bundle-id | required | string | app's unique identifier | 688 | 689 | ##### Example cURL 690 | 691 | > ```bash 692 | > curl -X PATCH \ 693 | > -H "Authorization: Bearer token" \ 694 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 695 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 696 | > -H "X-AppStoreConnect-Token: token" \ 697 | > -H "Content-Type: application/json" \ 698 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/live/rollout/complete 699 | > ``` 700 | 701 |
702 | 703 | #### Halt the current live release from distribution 704 | 705 |
706 | PATCH /apple/connect/v1/apps/:bundle_id/release/live/rollout/halt 707 | 708 | ##### Path parameters 709 | 710 | > | name | type | data type | description | 711 | > |-----------|-----------|-------------------------|-----------------------------------------------------------------------| 712 | > | bundle-id | required | string | app's unique identifier | 713 | 714 | ##### Example cURL 715 | 716 | > ```bash 717 | > curl -X PATCH \ 718 | > -H "Authorization: Bearer token" \ 719 | > -H "X-AppStoreConnect-Key-Id: key-id" \ 720 | > -H "X-AppStoreConnect-Issuer-Id: iss-id" \ 721 | > -H "X-AppStoreConnect-Token: token" \ 722 | > -H "Content-Type: application/json" \ 723 | > http://localhost:4000/apple/connect/v1/apps/com.tramline.app/release/live/rollout/halt 724 | > ``` 725 | 726 |
727 | -------------------------------------------------------------------------------- /lib/app_store/connect.rb: -------------------------------------------------------------------------------- 1 | require "spaceship" 2 | require "json" 3 | require "ougai" 4 | require "retryable" 5 | require_relative "../../spaceship/wrapper_token" 6 | require_relative "../../spaceship/wrapper_error" 7 | 8 | module AppStore 9 | class Connect 10 | def self.groups(**params) = new(**params).groups 11 | 12 | def self.build(**params) = new(**params).build(**params.slice(:build_number)) 13 | 14 | def self.update_build_notes(**params) = new(**params).update_build_notes(**params.slice(:build_number, :notes)) 15 | 16 | def self.latest_build(**params) = new(**params).latest_build 17 | 18 | def self.send_to_group(**params) = new(**params).send_to_group(**params.slice(:group_id, :build_number)) 19 | 20 | def self.metadata(**params) = new(**params).metadata 21 | 22 | def self.current_app_info(**params) = new(**params).current_app_info 23 | 24 | def self.prepare_release(**params) = new(**params).prepare_release(**params.slice(:build_number, :is_phased_release, :version, :is_force, :metadata)) 25 | 26 | def self.create_review_submission(**params) = new(**params).create_review_submission(**params.slice(:build_number, :version)) 27 | 28 | def self.cancel_review_submission(**params) = new(**params).cancel_review_submission(**params.slice(:build_number, :version)) 29 | 30 | def self.release(**params) = new(**params).release(**params.slice(:build_number)) 31 | 32 | def self.start_release(**params) = new(**params).start_release(**params.slice(:build_number)) 33 | 34 | def self.live_release(**params) = new(**params).live_release 35 | 36 | def self.pause_phased_release(**params) = new(**params).pause_phased_release 37 | 38 | def self.resume_phased_release(**params) = new(**params).resume_phased_release 39 | 40 | def self.complete_phased_release(**params) = new(**params).complete_phased_release 41 | 42 | def self.halt_release(**params) = new(**params).halt_release 43 | 44 | MAX_RETRIES = 3 45 | RETRY_BASE_SLEEP_SECONDS = 1 46 | IOS_PLATFORM = Spaceship::ConnectAPI::Platform::IOS 47 | VERSION_DATA_INCLUDES = %w[build appStoreVersionPhasedRelease appStoreVersionLocalizations appStoreVersionSubmission].join(",").freeze 48 | READY_FOR_REVIEW_STATE = "READY_FOR_REVIEW" 49 | INFLIGHT_RELEASE_FILTERS = { 50 | appStoreState: [ 51 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::PREPARE_FOR_SUBMISSION, 52 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::PROCESSING_FOR_APP_STORE, 53 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::DEVELOPER_REJECTED, 54 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::REJECTED, 55 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::METADATA_REJECTED, 56 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::WAITING_FOR_REVIEW, 57 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::INVALID_BINARY, 58 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::IN_REVIEW, 59 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::PENDING_DEVELOPER_RELEASE, 60 | Spaceship::ConnectAPI::AppStoreVersion::AppStoreState::PENDING_APPLE_RELEASE, 61 | READY_FOR_REVIEW_STATE 62 | ].join(","), 63 | platform: IOS_PLATFORM 64 | } 65 | 66 | def initialize(**params) 67 | token = Spaceship::WrapperToken.new(key_id: params[:key_id], issuer_id: params[:issuer_id], text: params[:token]) 68 | Spaceship::ConnectAPI.token = token 69 | @api = Spaceship::ConnectAPI 70 | @bundle_id = params[:bundle_id] 71 | @logger = ::Ougai::Logger.new($stdout) 72 | end 73 | 74 | attr_reader :api, :bundle_id 75 | 76 | def app 77 | @app ||= api::App.find(bundle_id) 78 | raise AppNotFoundError unless @app 79 | @app 80 | end 81 | 82 | # no of api calls: 3 + n (n = number of beta groups) 83 | def current_app_info 84 | beta_app_info.push({name: "production", builds: [live_app_info, inflight_release_info].compact}) 85 | end 86 | 87 | # no of api calls: 1 + n (n = number of beta groups) 88 | def beta_app_info 89 | app.get_beta_groups(filter: {isInternalGroup: false}).map do |group| 90 | builds = get_builds_for_group(group.id).map do |build| 91 | build_data(build) 92 | .slice(:id, :build_number, :beta_external_state, :version_string, :uploaded_date) 93 | .transform_keys({beta_external_state: :status, uploaded_date: :release_date}) 94 | end 95 | 96 | {name: group.name, builds: builds} 97 | end 98 | end 99 | 100 | # no of api calls: 2 101 | def groups 102 | app.get_beta_groups.map do |group| 103 | {name: group.name, id: group.id, internal: group.is_internal_group} 104 | end 105 | end 106 | 107 | # no of api calls: 2; +3 retries 108 | def build(build_number:) 109 | build_data(get_build(build_number)) 110 | end 111 | 112 | def update_build_notes(build_number:, notes: nil) 113 | return if notes.nil? || notes.empty? 114 | execute do 115 | build = get_build(build_number, %w[betaBuildLocalizations]) 116 | locale = build.get_beta_build_localizations.first 117 | locale_params = {whatsNew: notes} 118 | 119 | log "Updating locale for the build", {build: build.to_json, locale: locale, params: locale_params} 120 | 121 | if locale 122 | api.patch_beta_build_localizations(localization_id: locale.id, attributes: locale_params) 123 | else 124 | attributes[:locale] = "en-US" 125 | api.post_beta_build_localizations(build_id: build.id, attributes: locale_params) 126 | end 127 | end 128 | end 129 | 130 | # no of api calls: 2 131 | def latest_build 132 | execute do 133 | params = { 134 | sort: "-version", 135 | limit: 1, 136 | filter: {app: app.id} 137 | } 138 | build = api.test_flight_request_client.get("builds", params)&.first 139 | raise BuildNotFoundError.new("No build found for the app") unless build 140 | build_data(build) 141 | end 142 | end 143 | 144 | # no of api calls: 4-7 145 | def send_to_group(group_id:, build_number:) 146 | execute do 147 | # NOTE: have to get the build separately, can not be included in the app 148 | # That inclusion is not exposed by Spaceship, but it does exist in apple API, so it can be fixed later 149 | # Only two includes in app are: appStoreVersions and prices 150 | build = get_build(build_number) 151 | # NOTE: same as above 152 | group = group(group_id) 153 | build = update_export_compliance(build) 154 | 155 | submit_for_beta_review(build) unless group.is_internal_group 156 | build.add_beta_groups(beta_groups: [group]) 157 | end 158 | end 159 | 160 | # no of api calls: 1 161 | def metadata 162 | { 163 | id: app.id, 164 | name: app.name, 165 | bundle_id: app.bundle_id, 166 | sku: app.sku, 167 | primary_locale: app.primary_locale 168 | } 169 | end 170 | 171 | # no of api calls: 6-10 172 | def prepare_release(build_number:, version:, is_phased_release:, metadata:, is_force: false) 173 | execute do 174 | build = get_build(build_number) 175 | update_export_compliance(build) 176 | 177 | log "Ensure an editable app store version", {version: version, build: build.version} 178 | latest_version = ensure_editable_version(is_force, build.version) 179 | 180 | if latest_version 181 | log "There is an editable app store version, updating the details", {app_store_version: latest_version.to_json, version: version, build: build.version} 182 | update_version_details!(latest_version, version, build) 183 | else 184 | log "There is no editable app store version, creating it", {version: version, build: build.version} 185 | latest_version = create_app_store_version(version, build) 186 | end 187 | 188 | metadata.each { update_version_locale!(latest_version, _1) } 189 | 190 | if is_phased_release && latest_version.app_store_version_phased_release.nil? 191 | log "Creating phased release for the app store version" 192 | latest_version.create_app_store_version_phased_release(attributes: { 193 | phasedReleaseState: api::AppStoreVersionPhasedRelease::PhasedReleaseState::INACTIVE 194 | }) 195 | elsif !is_phased_release && latest_version.app_store_version_phased_release 196 | log "Removing phased release from the app store version" 197 | latest_version.app_store_version_phased_release.delete! 198 | end 199 | 200 | version = app.get_edit_app_store_version(includes: VERSION_DATA_INCLUDES) 201 | submission = app.get_ready_review_submission(platform: IOS_PLATFORM, includes: "items") 202 | review_submission_items = [] 203 | review_submission_items = get_other_ready_review_items(submission.id) if submission 204 | release_data(version, submission, review_submission_items) 205 | end 206 | end 207 | 208 | # no of api calls: 8-9 209 | def create_review_submission(build_number:, version: nil) 210 | execute do 211 | build = get_build(build_number) 212 | 213 | edit_version = app 214 | .get_app_store_versions(includes: "build", filter: INFLIGHT_RELEASE_FILTERS) 215 | .find { |v| v.build&.version == build_number.to_s } 216 | raise VersionNotFoundError unless edit_version 217 | 218 | ensure_correct_build(build, edit_version) 219 | if edit_version.version_string != version 220 | edit_version.update(attributes: {versionString: version}) 221 | end 222 | 223 | if app.get_in_progress_review_submission(platform: IOS_PLATFORM) 224 | raise ReviewAlreadyInProgressError 225 | end 226 | 227 | submission = app.get_ready_review_submission(platform: IOS_PLATFORM, includes: "items") 228 | 229 | if submission 230 | existing_reviewable_app_store_version = get_ready_app_store_version_item(submission.id) 231 | raise SubmissionWithItemsExistError if existing_reviewable_app_store_version&.any? 232 | end 233 | 234 | submission ||= app.create_review_submission(platform: IOS_PLATFORM) 235 | submission.add_app_store_version_to_review_items(app_store_version_id: edit_version.id) 236 | 237 | submit_review(submission, edit_version) 238 | end 239 | end 240 | 241 | def submit_review(submission, edit_version) 242 | execute_with_retry([AppStore::InvalidReviewStateError, AppStore::ReviewSubmissionNotFound]) do 243 | log("Submitting app #{edit_version.version_string} for review") 244 | submission.submit_for_review 245 | end 246 | end 247 | 248 | def cancel_review_submission(build_number:, version:) 249 | execute do 250 | edit_version = app 251 | .get_app_store_versions(includes: "build", filter: INFLIGHT_RELEASE_FILTERS) 252 | .find { |v| v.build&.version == build_number.to_s && v.version_string == version } 253 | raise VersionNotFoundError unless edit_version 254 | 255 | sub = app.get_in_progress_review_submission(platform: IOS_PLATFORM) 256 | 257 | raise SubmissionNotFoundError unless sub 258 | sub.cancel_submission 259 | 260 | version_data(app.get_edit_app_store_version(includes: VERSION_DATA_INCLUDES)) 261 | end 262 | end 263 | 264 | # no of api calls: 2 + 2 265 | def release(build_number: nil) 266 | execute do 267 | if build_number.nil? || build_number.empty? 268 | version = current_inflight_release 269 | raise VersionNotFoundError.new("No inflight release found") unless version 270 | else 271 | version = app.get_app_store_versions(includes: VERSION_DATA_INCLUDES, filter: INFLIGHT_RELEASE_FILTERS) 272 | .find { |v| v.build&.version == build_number } 273 | raise VersionNotFoundError.new("No release found for the build number - #{build_number}") unless version 274 | end 275 | 276 | existing_submission = app.get_ready_review_submission(platform: IOS_PLATFORM) 277 | review_submission_items = [] 278 | review_submission_items = get_other_ready_review_items(existing_submission.id) if existing_submission 279 | release_data(version, existing_submission, review_submission_items) 280 | end 281 | end 282 | 283 | # no of api calls: 2 284 | def start_release(build_number:) 285 | execute do 286 | filter = { 287 | appStoreState: [ 288 | api::AppStoreVersion::AppStoreState::PENDING_DEVELOPER_RELEASE 289 | ].join(","), 290 | platform: IOS_PLATFORM 291 | } 292 | edit_version = app.get_app_store_versions(includes: "build", filter: filter) 293 | .find { |v| v.build&.version == build_number } 294 | 295 | raise VersionNotFoundError.new("No startable release found for the build number - #{build_number}") unless edit_version 296 | 297 | edit_version.create_app_store_version_release_request 298 | end 299 | end 300 | 301 | # no of api calls: 3 302 | def pause_phased_release 303 | execute do 304 | live_version = app.get_live_app_store_version(includes: VERSION_DATA_INCLUDES) 305 | raise PhasedReleaseNotFoundError unless live_version.app_store_version_phased_release 306 | 307 | ensure_release_editable(live_version) 308 | updated_phased_release = live_version.app_store_version_phased_release.pause 309 | live_version.app_store_version_phased_release = updated_phased_release 310 | version_data(live_version) 311 | end 312 | end 313 | 314 | # no of api calls: 3 315 | def resume_phased_release 316 | execute do 317 | live_version = app.get_live_app_store_version(includes: VERSION_DATA_INCLUDES) 318 | raise PhasedReleaseNotFoundError unless live_version.app_store_version_phased_release 319 | 320 | ensure_release_editable(live_version) 321 | updated_phased_release = live_version.app_store_version_phased_release.resume 322 | live_version.app_store_version_phased_release = updated_phased_release 323 | version_data(live_version) 324 | end 325 | end 326 | 327 | # no of api calls: 3 328 | def complete_phased_release 329 | execute do 330 | live_version = app.get_live_app_store_version(includes: VERSION_DATA_INCLUDES) 331 | raise PhasedReleaseNotFoundError unless live_version.app_store_version_phased_release 332 | updated_phased_release = live_version.app_store_version_phased_release.complete 333 | live_version.app_store_version_phased_release = updated_phased_release 334 | version_data(live_version) 335 | end 336 | end 337 | 338 | # no of api calls: 3 339 | def halt_release 340 | execute do 341 | live_version = app.get_live_app_store_version 342 | if live_version.app_store_state == api::AppStoreVersion::AppStoreState::DEVELOPER_REMOVED_FROM_SALE 343 | raise ReleaseAlreadyHaltedError 344 | end 345 | 346 | body = { 347 | data: { 348 | type: "appAvailabilities", 349 | attributes: {availableInNewTerritories: false}, 350 | relationships: { 351 | app: {data: {type: "apps", 352 | id: app.id}}, 353 | availableTerritories: {data: []} 354 | } 355 | } 356 | } 357 | 358 | api.test_flight_request_client.post("appAvailabilities", body) 359 | end 360 | end 361 | 362 | # no of api calls: 2 363 | def live_release 364 | execute do 365 | live_version = app.get_live_app_store_version(includes: VERSION_DATA_INCLUDES) 366 | raise VersionNotFoundError.new("No release live yet.") unless live_version 367 | version_data(live_version) 368 | end 369 | end 370 | 371 | private 372 | 373 | def current_inflight_release 374 | app.get_app_store_versions(includes: VERSION_DATA_INCLUDES, filter: INFLIGHT_RELEASE_FILTERS) 375 | .max_by { |v| Date.parse(v.created_date) } 376 | end 377 | 378 | def inflight_release_info 379 | inflight_version = current_inflight_release 380 | return unless inflight_version 381 | { 382 | id: inflight_version.id, 383 | version_string: inflight_version.version_string, 384 | status: inflight_version.app_store_state, 385 | release_date: inflight_version.created_date, 386 | build_number: inflight_version.build&.version, 387 | localizations: build_localizations(inflight_version.app_store_version_localizations) 388 | } 389 | end 390 | 391 | # no of api calls: 2 392 | def live_app_info 393 | live_version = app.get_live_app_store_version(includes: VERSION_DATA_INCLUDES) 394 | return unless live_version 395 | { 396 | id: live_version.id, 397 | version_string: live_version.version_string, 398 | status: live_version.app_store_state, 399 | release_date: live_version.created_date, 400 | build_number: live_version.build&.version, 401 | localizations: build_localizations(live_version.app_store_version_localizations) 402 | } 403 | end 404 | 405 | def build_localizations(localizations = []) 406 | localizations.map do |localization| 407 | { 408 | language: localization.locale, 409 | whats_new: localization.whats_new, 410 | promotional_text: localization.promotional_text, 411 | description: localization.description, 412 | support_url: localization.support_url, 413 | marketing_url: localization.marketing_url, 414 | keywords: localization.keywords 415 | } 416 | end 417 | end 418 | 419 | # no of api calls: 1-4 ; +3 with every retry attempt 420 | def submit_for_beta_review(build) 421 | retry_proc = proc do 422 | log("Retrying submitting for beta review.") 423 | waiting_for_review_build = app.get_builds( 424 | filter: {"betaAppReviewSubmission.betaReviewState" => "WAITING_FOR_REVIEW,IN_REVIEW", 425 | "expired" => false, 426 | "preReleaseVersion.version" => build.pre_release_version.version} 427 | ).first 428 | if waiting_for_review_build 429 | waiting_for_review_build.expire! 430 | log("Expired build - #{waiting_for_review_build.version}.") 431 | end 432 | end 433 | 434 | execute_with_retry(AppStore::ReviewAlreadyInProgressError, retry_proc:) do 435 | log("Submitting beta build for review") 436 | build.post_beta_app_review_submission if build.ready_for_beta_submission? 437 | end 438 | end 439 | 440 | def get_latest_app_store_version 441 | filter = { 442 | platform: IOS_PLATFORM 443 | } 444 | app.get_app_store_versions(includes: VERSION_DATA_INCLUDES, filter:).max_by { |v| Time.parse(v.created_date) } 445 | end 446 | 447 | # no of api calls: 1-3 448 | def ensure_editable_version(is_force, build_number) 449 | latest_version = get_latest_app_store_version 450 | 451 | log "Latest app store version", latest_version.to_json 452 | 453 | case latest_version.app_store_state 454 | when api::AppStoreVersion::AppStoreState::READY_FOR_SALE, 455 | api::AppStoreVersion::AppStoreState::DEVELOPER_REMOVED_FROM_SALE 456 | 457 | log "Found a live app store version", latest_version.to_json 458 | return 459 | 460 | when api::AppStoreVersion::AppStoreState::REJECTED 461 | log "Found rejected app store version", latest_version.to_json 462 | raise VersionAlreadyAddedToSubmissionError unless is_force 463 | 464 | submission = app.get_in_progress_review_submission(platform: IOS_PLATFORM) 465 | log "Deleting rejected app store version submission", submission.to_json 466 | submission&.cancel_submission 467 | 468 | when api::AppStoreVersion::AppStoreState::PENDING_DEVELOPER_RELEASE, 469 | api::AppStoreVersion::AppStoreState::PENDING_APPLE_RELEASE, 470 | api::AppStoreVersion::AppStoreState::WAITING_FOR_REVIEW, 471 | api::AppStoreVersion::AppStoreState::IN_REVIEW 472 | 473 | log "Found releasable app store version", latest_version.to_json 474 | raise VersionAlreadyAddedToSubmissionError unless is_force 475 | # NOTE: Apple has deprecated this API, but even the appstore connect dashboard uses the deprecated API to do this action 476 | # https://developer.apple.com/documentation/appstoreconnectapi/delete_an_app_store_version_submission 477 | log "Cancelling the release for releasable app store version", latest_version.to_json 478 | latest_version.app_store_version_submission.delete! 479 | 480 | when api::AppStoreVersion::AppStoreState::PREPARE_FOR_SUBMISSION, 481 | api::AppStoreVersion::AppStoreState::DEVELOPER_REJECTED 482 | 483 | log "Found draft app store version", latest_version.to_json 484 | return latest_version if latest_version.build&.version == build_number 485 | raise VersionAlreadyAddedToSubmissionError unless is_force 486 | end 487 | 488 | latest_version 489 | end 490 | 491 | # no of api calls: 2 492 | def create_app_store_version(version, build) 493 | data = build_app_store_version_attributes(version, build) 494 | data[:relationships][:app] = {data: {type: "apps", id: app.id}} 495 | data[:attributes][:platform] = IOS_PLATFORM 496 | body = {data: data} 497 | 498 | log "Creating app store version with ", {body: body} 499 | api.tunes_request_client.post("appStoreVersions", body) 500 | 501 | execute_with_retry(AppStore::VersionNotFoundError, sleep_seconds: 15, max_retries: 10) do 502 | log("Fetching the created app store version") 503 | inflight_version = app.get_edit_app_store_version(includes: VERSION_DATA_INCLUDES) 504 | raise VersionNotFoundError unless inflight_version 505 | inflight_version 506 | end 507 | end 508 | 509 | def update_version_locale!(app_store_version, metadata) 510 | locale = app_store_version.app_store_version_localizations.find { |l| metadata[:locale] == l.locale } 511 | return if locale.nil? 512 | 513 | locale_params = if metadata[:whats_new].nil? || metadata[:whats_new].empty? 514 | {"whatsNew" => "The latest version contains bug fixes and performance improvements."} 515 | else 516 | {"whatsNew" => metadata[:whats_new]} 517 | end 518 | 519 | unless metadata[:promotional_text].nil? || metadata[:promotional_text].empty? 520 | locale_params["promotionalText"] = metadata[:promotional_text] 521 | end 522 | 523 | log "Updating locale for the app store version", {locale: locale.to_json, params: locale_params} 524 | locale.update(attributes: locale_params) 525 | end 526 | 527 | # no of api calls: 1 528 | def update_version_details!(app_store_version, version, build) 529 | attempts ||= 1 530 | execute do 531 | body = { 532 | data: { 533 | id: app_store_version.id 534 | }.merge(build_app_store_version_attributes(version, build, app_store_version)) 535 | } 536 | 537 | log "Updating app store version details with ", {body: body, attempts: attempts} 538 | api.tunes_request_client.patch("appStoreVersions/#{app_store_version.id}", body) 539 | 540 | app_store_version 541 | end 542 | rescue VersionNotEditableError => e 543 | if attempts <= 3 544 | attempts += 1 545 | sleep attempts 546 | retry 547 | else 548 | Sentry.capture_exception(e) 549 | raise e 550 | end 551 | end 552 | 553 | def build_app_store_version_attributes(version, build, app_store_version = nil) 554 | # Updating version to be released manually by tramline, not automatically after approval 555 | attributes = {releaseType: "MANUAL"} 556 | relationships = nil 557 | 558 | if version != app_store_version&.version_string 559 | attributes[:versionString] = version 560 | end 561 | 562 | if app_store_version&.build&.id != build.id 563 | relationships = { 564 | build: { 565 | data: { 566 | type: "builds", 567 | id: build.id 568 | } 569 | } 570 | } 571 | end 572 | 573 | body = { 574 | type: "appStoreVersions", 575 | attributes: attributes, 576 | relationships: (relationships unless relationships.nil?) 577 | } 578 | body.compact! 579 | body 580 | end 581 | 582 | def ensure_correct_build(build, version) 583 | raise BuildMismatchError if version.build.version != build.version 584 | end 585 | 586 | def ensure_release_editable(version) 587 | if version.app_store_version_phased_release.phased_release_state == api::AppStoreVersionPhasedRelease::PhasedReleaseState::COMPLETE 588 | raise ReleaseNotEditableError 589 | end 590 | end 591 | 592 | def version_data(version) 593 | { 594 | id: version.id, 595 | version_name: version.version_string, 596 | app_store_state: version.app_store_state, 597 | release_type: version.release_type, 598 | earliest_release_date: version.earliest_release_date, 599 | downloadable: version.downloadable, 600 | created_date: version.created_date, 601 | build_number: version.build&.version, 602 | build_id: version.build&.id, 603 | build_created_at: version.build&.uploaded_date, 604 | phased_release: version.app_store_version_phased_release, 605 | added_at: [version.created_date, version.build&.uploaded_date].compact.max, 606 | localizations: build_localizations(version.app_store_version_localizations) 607 | } 608 | end 609 | 610 | def release_data(version, submission, review_submission_items) 611 | ready_review_submission = {ready_review_submission: {}} 612 | ready_review_submission[:ready_review_submission] = {id: submission.id} if submission 613 | ready_review_submission[:ready_review_submission][:items] = review_submission_items if review_submission_items&.any? 614 | version_data(version).merge(ready_review_submission) 615 | end 616 | 617 | def get_builds_for_group(group_id, limit = 2) 618 | api.get_builds( 619 | filter: {app: app.id, betaGroups: group_id, expired: "false"}, 620 | sort: "-uploadedDate", 621 | includes: "buildBetaDetail,preReleaseVersion", 622 | limit: limit 623 | ) 624 | end 625 | 626 | def build_data(build) 627 | { 628 | id: build.id, 629 | build_number: build.version, 630 | beta_internal_state: build.build_beta_detail&.internal_build_state, 631 | beta_external_state: build.build_beta_detail&.external_build_state, 632 | uploaded_date: build.uploaded_date, 633 | expired: build.expired, 634 | processing_state: build.processing_state, 635 | version_string: build.pre_release_version&.version 636 | } 637 | end 638 | 639 | # fetch all non-appStoreVersion submission items that are ready for review 640 | def get_other_ready_review_items(submission_id) 641 | log "Fetching any non-appStoreVersion review items " 642 | review_items_includes = %w[appStoreVersionExperiment appCustomProductPageVersion appEvent] 643 | responses = get_review_submission_items(submission_id, review_items_includes.join(",")) 644 | 645 | responses.flat_map do |response| 646 | data_items = response.dig("data") || [] 647 | 648 | data_items.filter_map do |item| 649 | next unless item.dig("attributes", "state") == "READY_FOR_REVIEW" 650 | relationships = item.dig("relationships") || {} 651 | 652 | # each item can be only be one of the possible review item types 653 | # so we just pick the first non-nil one 654 | rel_type = review_items_includes.find do |type| 655 | relationships.dig(type, "data") 656 | end 657 | 658 | if rel_type 659 | rel_data = relationships.dig(rel_type, "data") 660 | { 661 | type: rel_type, 662 | id: rel_data["id"], 663 | status: "READY_FOR_REVIEW" 664 | } 665 | end 666 | end 667 | end 668 | end 669 | 670 | def get_ready_app_store_version_item(submission_id) 671 | responses = get_review_submission_items(submission_id, "appStoreVersion") 672 | responses.dig(0, "relationships", "appStoreVersion", "data") 673 | end 674 | 675 | def get_review_submission_items(submission_id, includes) 676 | api.get_review_submission_items(review_submission_id: submission_id, includes:).all_pages.map(&:body) 677 | end 678 | 679 | def update_export_compliance(build) 680 | execute do 681 | return build unless build.missing_export_compliance? 682 | 683 | api.patch_builds(build_id: build.id, attributes: {usesNonExemptEncryption: false}) 684 | updated_build = api::Build.get(build_id: build.id) 685 | raise ExportComplianceNotFoundError if updated_build.missing_export_compliance? 686 | updated_build 687 | end 688 | rescue ExportComplianceAlreadyUpdatedError => e 689 | Sentry.capture_exception(e) 690 | build 691 | end 692 | 693 | def group(id) 694 | group = app.get_beta_groups(filter: {id:}).first 695 | raise BetaGroupNotFoundError.new("Beta group with id #{id} not found") unless group 696 | group 697 | end 698 | 699 | def get_build(build_number, includes = []) 700 | execute_with_retry(BuildNotFoundError) do 701 | log "Fetching build with build number #{build_number}" 702 | build = app.get_builds(includes: %w[preReleaseVersion buildBetaDetail].concat(includes).join(","), filter: {version: build_number}).first 703 | log("Found build with build number #{build_number}", build.to_json) if build 704 | raise BuildNotFoundError.new("Build with number #{build_number} not found") unless build_ready?(build) 705 | build = update_export_compliance(build) 706 | build 707 | end 708 | end 709 | 710 | def build_ready?(build) 711 | build&.processed? && build&.build_beta_detail 712 | end 713 | 714 | def execute 715 | yield 716 | rescue Spaceship::UnexpectedResponse => e 717 | raise Spaceship::WrapperError.handle(e) 718 | end 719 | 720 | def execute_with_retry(exception_types, retry_proc: proc {}, sleep_seconds: RETRY_BASE_SLEEP_SECONDS, max_retries: MAX_RETRIES) 721 | on = exception_types.is_a?(Array) ? exception_types : [exception_types] 722 | Retryable.retryable(on:, tries: max_retries, sleep: ->(n) { n + sleep_seconds }, exception_cb: retry_proc) do 723 | execute { yield } 724 | end 725 | end 726 | 727 | def log(msg, data = {}) 728 | @logger.debug(msg, data) 729 | end 730 | end 731 | end 732 | --------------------------------------------------------------------------------