Module: Mysigner::CredentialResolver
- Defined in:
- lib/mysigner/credential_resolver.rb
Overview
mysigner-22 Phase 5 — credential auto-discovery cascade for ‘–local-only` mode. Replaces the original Keychain-only lookup with a fastlane-style cascade: explicit per-command flags → env vars users already set (Apple’s APP_STORE_CONNECT_API_KEY_* and Google’s GOOGLE_APPLICATION_CREDENTIALS) →Keychain (‘onboard –local-only` store) → standard on-disk locations →interactive prompt (TTY only — never in CI).
The resolver is the ONLY place that knows about the cascade. Uploaders receive the resolved Struct and don’t touch ENV / disk / prompts directly, which keeps the wiring testable (Rule 9: tests verify the cascade, not the uploader plumbing).
Backward-compat contract: vault mode (no ‘–local-only`) never calls this module. The `LocalCredentials` API is unchanged — it just becomes one source in the cascade rather than the only one.
Defined Under Namespace
Classes: AmbiguousCredentialsError, AndroidKeystoreCreds, AscCreds, CredentialNotFoundError, PlayCreds
Constant Summary collapse
- APPLE_PRIVATE_KEYS_DIR =
Apple’s officially-blessed on-disk location for ASC private keys, documented in WWDC sessions and used by altool / xcrun / fastlane.
File.('~/.appstoreconnect/private_keys').freeze
- PLAY_PROJECT_FILE_NAMES =
Common file names users put service-account JSON under at a project root.
%w[ play-credentials.json service-account.json play-service-account.json ].freeze
- PROJECT_SNIFF_MAX_DEPTH =
Walk up at most this many parent directories looking for project-sniff files. Three levels covers the common monorepo-with-app-subdir layout without surprising the user by reaching into unrelated trees.
3- ANDROID_KEY_PROPERTIES_FILES =
Project-sniff filenames for Android signing config. ‘android/key.properties` is the Flutter convention (the `flutter create` template writes it); `android/keystore.properties` and a root-level `key.properties` are seen in a few Gradle-only setups (the docs use both names interchangeably). First match wins.
[ 'android/key.properties', 'android/keystore.properties', 'key.properties' ].freeze
- ANDROID_MISSING_LABELS =
Human-readable labels for the four cascade pieces. Used by the not-found error message so each ‘missing` symbol prints something the user can map back to a flag/env/sniff-key.
{ path: 'keystore path', keystore_password: 'keystore password', key_alias: 'key alias', key_password: 'key password' }.freeze
Class Method Summary collapse
-
.resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ AndroidKeystoreCreds
mysigner-22 Phase 7 — Android keystore cascade.
- .resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr) ⇒ AscCreds
- .resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ PlayCreds
Class Method Details
.resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ AndroidKeystoreCreds
mysigner-22 Phase 7 — Android keystore cascade.
Each tier may contribute any subset of the four required pieces (path, keystore_password, key_alias, key_password); the resolver stitches them together highest-priority-first and prompts (TTY) or fails (non-TTY) for whatever is still missing.
Priority: flag > env > keychain > project-sniff > prompt.
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/mysigner/credential_resolver.rb', line 251 def resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) tried = [] # Layered hash {path:, keystore_password:, key_alias:, key_password:, source:, tmpfile:}. # `path_source` mirrors the ASC cascade contract: source attribution # follows the tier that supplied the .jks (the primary material), not # the tier that filled in a stray password field. pieces = {} layer_android_flag_pieces!(pieces, ) layer_android_env_pieces!(pieces, env) layer_android_keychain_pieces!(pieces, hint: pieces[:key_alias], tried: tried) layer_android_sniff_pieces!(pieces, cwd: cwd, tried: tried) # All tiers exhausted — prompt for what's still missing (TTY only). missing = android_missing_pieces(pieces) if missing.any? unless stdin.respond_to?(:tty?) && stdin.tty? raise CredentialNotFoundError, (tried: tried, missing: missing) end fill_android_pieces_from_prompt!(pieces, missing, stdin: stdin, stderr: stderr) end AndroidKeystoreCreds.new( keystore_path: pieces[:path], keystore_password: pieces[:keystore_password], key_alias: pieces[:key_alias], key_password: pieces[:key_password] || pieces[:keystore_password], source: pieces[:source] || :prompt, tmpfile: pieces[:tmpfile] ) end |
.resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr) ⇒ AscCreds
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/mysigner/credential_resolver.rb', line 94 def resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr) tried = [] # Tier 1: per-command CLI flags (highest precedence). If all three are # present we short-circuit; partial flags layer in below. flag_path = string_option(, :asc_key_path) flag_key_id = string_option(, :asc_key_id) flag_issuer = string_option(, :asc_issuer_id) if flag_path && flag_key_id && flag_issuer pem = read_pem!(flag_path, label: '--asc-key-path') tried << "flag: --asc-key-path=#{flag_path}" return AscCreds.new(key_id: flag_key_id, issuer_id: flag_issuer, p8_pem: pem, source: :flag) end # Tier 2: env vars (fastlane convention). env_path = string_env(env, 'APP_STORE_CONNECT_API_KEY_PATH') env_key_id = string_env(env, 'APP_STORE_CONNECT_API_KEY_ID') env_issuer = string_env(env, 'APP_STORE_CONNECT_API_KEY_ISSUER_ID') # Tier 3: Keychain — list yields zero / one / many; "many without # disambiguator" is an explicit ambiguous-error rather than silently # picking the first. WHY: the old uploader took .first quietly, which # was fine when only `onboard --local-only` could write entries but is # dangerous now that users may layer flags/env on top. keychain_ids = safe_list_keychain(:asc) keychain_id = pick_keychain_id(keychain_ids, hint: flag_key_id || env_key_id, label: 'ASC') tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}" # Tier 4: disk scan. Skip entirely when a higher tier (flag or env) # already supplies a .p8 path — disk can't add anything we'd prefer. # WHY: scan_apple_private_keys_dir raises AmbiguousCredentialsError on # multiple .p8 files, and that error is misleading when the user has # already pointed at one via --asc-key-path / APP_STORE_CONNECT_API_KEY_PATH. # "Higher tier wins" means the lower tier shouldn't even probe. disk_path, disk_key_id = if flag_path || env_path [nil, nil] else scan_apple_private_keys_dir(tried) end # Stitch together the highest-priority *partial* tier first, then fill # the missing fields from lower tiers. This lets "disk found the .p8 # but no issuer_id env" continue down to env then to prompt without # restarting the cascade. Pieces are passed as one hash to keep the # helper under the parameter-list cop limit. path, key_id, issuer_id, source = assemble_asc_pieces( flag_path: flag_path, flag_key_id: flag_key_id, flag_issuer: flag_issuer, env_path: env_path, env_key_id: env_key_id, env_issuer: env_issuer, keychain_id: keychain_id, disk_path: disk_path, disk_key_id: disk_key_id ) # Keychain shortcut: when keychain holds the (key_id, issuer_id, pem) # triple we don't need disk/env for anything. if source == :keychain envelope = fetch_keychain_envelope(:asc, key_id, label: 'ASC') return AscCreds.new( key_id: key_id, issuer_id: envelope.fetch('issuer_id'), p8_pem: envelope.fetch('p8_pem'), source: :keychain ) end # All other tiers need a PEM read from disk. pem = path ? read_pem!(path, label: source_label(source)) : nil # Final fallback: prompt only when STDIN is a TTY. CI must fail loud. if path.nil? || key_id.nil? || issuer_id.nil? unless stdin.respond_to?(:tty?) && stdin.tty? raise CredentialNotFoundError, ( tried: tried, missing: { path: path.nil?, key_id: key_id.nil?, issuer_id: issuer_id.nil? } ) end if path.nil? path = prompt(stderr, stdin, 'Path to your App Store Connect .p8 private key:') pem = read_pem!(File.(path), label: 'prompt') end key_id ||= derive_key_id_from_filename(path) || prompt(stderr, stdin, 'App Store Connect Key ID:') issuer_id ||= prompt(stderr, stdin, 'App Store Connect Issuer ID (UUID):') # Only overwrite source to :prompt when the .p8 path itself was # prompted (source.nil? means no higher tier supplied it). When the # path came from env/flag/keychain/disk and only issuer_id was # prompted in, preserve the originating source — the audit log # should attribute the credential to where the primary material # (the .p8) came from; issuer_id is metadata. source ||= :prompt end AscCreds.new(key_id: key_id, issuer_id: issuer_id, p8_pem: pem, source: source) end |
.resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ PlayCreds
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/mysigner/credential_resolver.rb', line 195 def resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) tried = [] # Tier 1: flag. if (flag_path = string_option(, :play_credentials)) tried << "flag: --play-credentials=#{flag_path}" raw, email = read_sa_json!(flag_path, label: '--play-credentials') return PlayCreds.new(sa_json: raw, client_email: email, source: :flag) end # Tier 2: env (Google's documented convention). if (env_path = string_env(env, 'GOOGLE_APPLICATION_CREDENTIALS')) tried << "env: GOOGLE_APPLICATION_CREDENTIALS=#{env_path}" raw, email = read_sa_json!(env_path, label: 'GOOGLE_APPLICATION_CREDENTIALS') return PlayCreds.new(sa_json: raw, client_email: email, source: :env) end # Tier 3: Keychain. keychain_ids = safe_list_keychain(:google_play) tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}" if keychain_ids.length == 1 raw = fetch_keychain_raw(:google_play, keychain_ids.first, label: 'Google Play') return PlayCreds.new(sa_json: raw, client_email: keychain_ids.first, source: :keychain) elsif keychain_ids.length > 1 raise AmbiguousCredentialsError, "Multiple Google Play credentials in Keychain (#{keychain_ids.join(', ')}). " \ 'Pass --play-credentials PATH to disambiguate, or remove the unused ones with ' \ '`mysigner local-credential delete google_play <client_email>`.' end # Tier 4: project-sniff (walk up to PROJECT_SNIFF_MAX_DEPTH dirs). if (sniffed = sniff_project_for_play(cwd, tried)) raw, email = read_sa_json!(sniffed, label: 'project-sniff') return PlayCreds.new(sa_json: raw, client_email: email, source: :disk) end # Tier 5: prompt or fail. raise CredentialNotFoundError, (tried: tried) unless stdin.respond_to?(:tty?) && stdin.tty? path = prompt(stderr, stdin, 'Path to your Google Play service-account JSON:') raw, email = read_sa_json!(File.(path), label: 'prompt') PlayCreds.new(sa_json: raw, client_email: email, source: :prompt) end |