fastlane-plugin-voip_push_certificate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b816333567b5063e96d1581ed6cf42362824ae82ec9783cccc09ab1c6efa8578
4
+ data.tar.gz: 8f0fc9c35319c303b6d1ee8041ac18063157a19250b4e333330db038cc20f8cc
5
+ SHA512:
6
+ metadata.gz: 7a2cdc229c63491b18f06539c66c2a038114c0748bb3985b08489f5d9f4993bbd27218e81c47c584a01043566bbd33d59e16285bf31fd8a96c38257b18666c1e
7
+ data.tar.gz: 2fb9a43b615924d7c3c2adf9f1045a25766de244bd45363d7cee98618ce1c4d465a6c3c5cc23285fa9bb37f943d044546e63c5312971877cf767f33076c4a585
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+
14
+ - `get_voip_push_certificate` action, creating and renewing Apple VoIP Services push
15
+ certificates through Spaceship's Developer Portal API.
16
+ - Writes `.pem` and `.p12` files, with optional `.pkey` and raw DER `.cer` output.
17
+ - `active_days_limit` (default 30) and `force` options controlling when an existing
18
+ certificate is renewed, matching the behaviour of `get_push_certificate`.
19
+ - `revoke_existing` option, disabled by default, to revoke the matching certificate
20
+ before creating a new one.
21
+ - `bag_attributes` option, writing the `.pem` with OpenSSL `friendlyName` and
22
+ `localKeyID` headers for push providers that require that format.
23
+ - Generated paths exposed through the lane context as
24
+ `VOIP_PUSH_CERTIFICATE_{PEM,P12,PKEY,CER}_PATH`.
25
+
26
+ [Unreleased]: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/compare/v0.1.0...HEAD
27
+ [0.1.0]: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rafael Ferreira <rafinha951026@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # fastlane-plugin-voip_push_certificate
2
+
3
+ [![Test](https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/actions/workflows/test.yml/badge.svg)](https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate/actions/workflows/test.yml)
4
+
5
+ Create and renew Apple **VoIP Services** push certificates (the certificate type PushKit
6
+ requires) from a fastlane lane.
7
+
8
+ fastlane's built-in [`get_push_certificate`](https://docs.fastlane.tools/actions/get_push_certificate/)
9
+ (`pem`) handles development, production and website push certificates, but not VoIP ones.
10
+ This plugin fills that gap using the same Spaceship APIs, so it behaves the way you already
11
+ expect `pem` to behave.
12
+
13
+ ## Getting Started
14
+
15
+ Requires Ruby 3.1 or newer.
16
+
17
+ Add the plugin to your project:
18
+
19
+ ```bash
20
+ fastlane add_plugin voip_push_certificate
21
+ ```
22
+
23
+ Or, to use it straight from source without installing the gem, add this to your `fastlane/Pluginfile`:
24
+
25
+ ```ruby
26
+ gem 'fastlane-plugin-voip_push_certificate', git: 'https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate'
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ lane :renew_voip do
33
+ get_voip_push_certificate(
34
+ app_identifier: "com.example.app",
35
+ output_path: "./certs"
36
+ )
37
+ end
38
+ ```
39
+
40
+ By default this creates `voip_com.example.app.pem` and `voip_com.example.app.p12` (plus the
41
+ private key) in `output_path`, but **only if** the current certificate expires within 30 days.
42
+ Otherwise it logs how long the existing one is still valid and does nothing.
43
+
44
+ The action returns the path to the generated `.pem`, or `nil` when the existing certificate
45
+ was kept. All generated paths are also exposed through the lane context:
46
+
47
+ ```ruby
48
+ lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PEM_PATH]
49
+ lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_P12_PATH]
50
+ lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PKEY_PATH]
51
+ lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_CER_PATH]
52
+ ```
53
+
54
+ ## Options
55
+
56
+ | Option | Type | Default | Description |
57
+ | --- | --- | --- | --- |
58
+ | `app_identifier` | String | from `Appfile` | The bundle identifier of your app |
59
+ | `username` | String | from `Appfile` | Your Apple ID username |
60
+ | `team_id` | String | from `Appfile` | Developer Portal team ID, if you're in multiple teams |
61
+ | `team_name` | String | from `Appfile` | Developer Portal team name, if you're in multiple teams |
62
+ | `active_days_limit` | Integer | `30` | Renew only if the current certificate expires within this many days |
63
+ | `force` | Boolean | `false` | Create a new certificate even if the current one is still valid |
64
+ | `revoke_existing` | Boolean | `false` | Revoke the matching certificate before creating a new one. See the warning below |
65
+ | `generate_p12` | Boolean | `true` | Also write a `.p12` file |
66
+ | `p12_password` | String | none | Password for the `.p12` file |
67
+ | `save_private_key` | Boolean | `true` | Also write the private key as a `.pkey` file |
68
+ | `save_cer` | Boolean | `false` | Also write the raw DER certificate as a `.cer` file |
69
+ | `bag_attributes` | Boolean | `false` | Write the `.pem` with OpenSSL bag attributes. See below |
70
+ | `pem_name` | String | `voip_<app_identifier>` | Base file name for the generated files |
71
+ | `output_path` | String | `.` | Directory to write the certificates into |
72
+
73
+ Every option also has an environment variable, e.g. `VOIP_PUSH_CERTIFICATE_APP_IDENTIFIER`.
74
+
75
+ ## Things worth knowing
76
+
77
+ ### There is no development/production distinction
78
+
79
+ Apple issues a single VoIP Services certificate that is valid for **both** the sandbox and the
80
+ production APNs environments. That is why, unlike `get_push_certificate`, this action has no
81
+ `development` option.
82
+
83
+ ### Revoking is opt-in, on purpose
84
+
85
+ Apple allows at most two active VoIP certificates per App ID. When that limit is reached the
86
+ action fails with an explanation rather than revoking anything, because revoking a certificate
87
+ **immediately** stops every push provider still using it, including your production one.
88
+
89
+ Set `revoke_existing: true` only when you are sure nothing depends on the old certificate:
90
+
91
+ ```ruby
92
+ get_voip_push_certificate(
93
+ app_identifier: "com.example.app",
94
+ force: true,
95
+ revoke_existing: true
96
+ )
97
+ ```
98
+
99
+ ### The `bag_attributes` option
100
+
101
+ Some push providers reject a plain concatenated PEM and expect the format that
102
+ `openssl pkcs12 -nodes` produces, with `friendlyName` and `localKeyID` headers around each
103
+ block. If yours does, set `bag_attributes: true`. The default (`false`) matches what
104
+ `get_push_certificate` writes: the certificate followed by the private key, nothing else.
105
+
106
+ ### Authentication
107
+
108
+ This uses Spaceship's Apple Developer Portal API, which needs an Apple ID and password:
109
+
110
+ ```bash
111
+ export FASTLANE_USER="you@example.com"
112
+ export FASTLANE_PASSWORD="…"
113
+ ```
114
+
115
+ Two-factor authentication is supported through fastlane's usual session mechanism
116
+ (`FASTLANE_SESSION`, `fastlane spaceauth`).
117
+
118
+ **App Store Connect API keys are not supported.** Apple's App Store Connect API does not cover
119
+ push certificates, so the portal API is the only option. This is a limitation of Apple's API,
120
+ not of this plugin. `get_push_certificate` has exactly the same constraint.
121
+
122
+ ## Running tests
123
+
124
+ ```bash
125
+ bundle install
126
+ bundle exec rake
127
+ ```
128
+
129
+ ## Relationship to fastlane
130
+
131
+ This is an unofficial plugin and is not affiliated with fastlane or Apple. Adding VoIP support
132
+ to `pem` itself has been requested since 2017 in
133
+ [fastlane/fastlane#8145](https://github.com/fastlane/fastlane/issues/8145); if it ever lands
134
+ upstream, this plugin will be deprecated in favour of it.
135
+
136
+ ## License
137
+
138
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,193 @@
1
+ require 'fastlane/action'
2
+ require 'fastlane_core/configuration/config_item'
3
+ require 'credentials_manager/appfile_config'
4
+ require_relative '../helper/voip_push_certificate_helper'
5
+
6
+ module Fastlane
7
+ module Actions
8
+ module SharedValues
9
+ VOIP_PUSH_CERTIFICATE_PEM_PATH = :VOIP_PUSH_CERTIFICATE_PEM_PATH
10
+ VOIP_PUSH_CERTIFICATE_P12_PATH = :VOIP_PUSH_CERTIFICATE_P12_PATH
11
+ VOIP_PUSH_CERTIFICATE_PKEY_PATH = :VOIP_PUSH_CERTIFICATE_PKEY_PATH
12
+ VOIP_PUSH_CERTIFICATE_CER_PATH = :VOIP_PUSH_CERTIFICATE_CER_PATH
13
+ end
14
+
15
+ class GetVoipPushCertificateAction < Action
16
+ def self.run(params)
17
+ paths = Helper::VoipPushCertificateHelper.new(params).run
18
+ return nil if paths.nil?
19
+
20
+ Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PEM_PATH] = paths[:pem]
21
+ Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_P12_PATH] = paths[:p12]
22
+ Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_PKEY_PATH] = paths[:pkey]
23
+ Actions.lane_context[SharedValues::VOIP_PUSH_CERTIFICATE_CER_PATH] = paths[:cer]
24
+
25
+ paths[:pem]
26
+ end
27
+
28
+ def self.description
29
+ "Create and renew Apple VoIP Services push certificates"
30
+ end
31
+
32
+ def self.details
33
+ [
34
+ "Generates an Apple *VoIP Services* push certificate (the certificate type used by PushKit)",
35
+ "and writes it to disk as `.pem`, `.p12` and, optionally, `.cer`.",
36
+ "",
37
+ "fastlane's built-in `get_push_certificate` covers development, production and website push",
38
+ "certificates, but not VoIP ones. This plugin fills that gap using the same Spaceship APIs.",
39
+ "",
40
+ "Apple issues a single VoIP Services certificate that works for both the sandbox and the",
41
+ "production APNs environments, so there is no `development` option here.",
42
+ "",
43
+ "By default an existing certificate is never revoked: if the app already has two active VoIP",
44
+ "certificates the action fails with an explanation. Set `revoke_existing` to true only if you",
45
+ "are sure no push provider still depends on the old certificate.",
46
+ "",
47
+ "Authentication goes through Spaceship's Apple Developer Portal API, which requires an Apple ID",
48
+ "and password (`FASTLANE_USER` / `FASTLANE_PASSWORD`) plus 2FA. App Store Connect API keys are",
49
+ "not supported for push certificates by Apple's portal API."
50
+ ].join("\n")
51
+ end
52
+
53
+ def self.return_value
54
+ "The path to the generated .pem file, or nil when the existing certificate was still valid and was kept"
55
+ end
56
+
57
+ def self.output
58
+ [
59
+ ['VOIP_PUSH_CERTIFICATE_PEM_PATH', 'The path to the generated .pem file'],
60
+ ['VOIP_PUSH_CERTIFICATE_P12_PATH', 'The path to the generated .p12 file'],
61
+ ['VOIP_PUSH_CERTIFICATE_PKEY_PATH', 'The path to the generated private key'],
62
+ ['VOIP_PUSH_CERTIFICATE_CER_PATH', 'The path to the generated .cer file']
63
+ ]
64
+ end
65
+
66
+ def self.authors
67
+ ["Rafael Ferreira"]
68
+ end
69
+
70
+ def self.example_code
71
+ [
72
+ 'get_voip_push_certificate',
73
+ 'get_voip_push_certificate(
74
+ app_identifier: "com.example.app",
75
+ output_path: "./certs"
76
+ )',
77
+ '# Renew even if the current certificate is still valid for a long time
78
+ get_voip_push_certificate(
79
+ app_identifier: "com.example.app",
80
+ force: true,
81
+ revoke_existing: true
82
+ )'
83
+ ]
84
+ end
85
+
86
+ def self.category
87
+ :push
88
+ end
89
+
90
+ def self.available_options
91
+ user = CredentialsManager::AppfileConfig.try_fetch_value(:apple_dev_portal_id)
92
+ user ||= CredentialsManager::AppfileConfig.try_fetch_value(:apple_id)
93
+
94
+ [
95
+ FastlaneCore::ConfigItem.new(key: :app_identifier,
96
+ short_option: "-a",
97
+ env_name: "VOIP_PUSH_CERTIFICATE_APP_IDENTIFIER",
98
+ description: "The bundle identifier of your app",
99
+ code_gen_sensitive: true,
100
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier),
101
+ default_value_dynamic: true),
102
+ FastlaneCore::ConfigItem.new(key: :username,
103
+ short_option: "-u",
104
+ env_name: "VOIP_PUSH_CERTIFICATE_USERNAME",
105
+ description: "Your Apple ID username",
106
+ default_value: user,
107
+ default_value_dynamic: true),
108
+ FastlaneCore::ConfigItem.new(key: :team_id,
109
+ short_option: "-b",
110
+ env_name: "VOIP_PUSH_CERTIFICATE_TEAM_ID",
111
+ description: "The ID of your Developer Portal team if you're in multiple teams",
112
+ code_gen_sensitive: true,
113
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_id),
114
+ default_value_dynamic: true,
115
+ optional: true,
116
+ verify_block: proc do |value|
117
+ ENV["FASTLANE_TEAM_ID"] = value.to_s
118
+ end),
119
+ FastlaneCore::ConfigItem.new(key: :team_name,
120
+ short_option: "-l",
121
+ env_name: "VOIP_PUSH_CERTIFICATE_TEAM_NAME",
122
+ description: "The name of your Developer Portal team if you're in multiple teams",
123
+ code_gen_sensitive: true,
124
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_name),
125
+ default_value_dynamic: true,
126
+ optional: true,
127
+ verify_block: proc do |value|
128
+ ENV["FASTLANE_TEAM_NAME"] = value.to_s
129
+ end),
130
+ FastlaneCore::ConfigItem.new(key: :active_days_limit,
131
+ env_name: "VOIP_PUSH_CERTIFICATE_ACTIVE_DAYS_LIMIT",
132
+ description: "If the current certificate is active for less than this number of days, generate a new one",
133
+ default_value: 30,
134
+ type: Integer,
135
+ verify_block: proc do |value|
136
+ UI.user_error!("Value of active_days_limit must be a positive integer") unless value.kind_of?(Integer) && value > 0
137
+ end),
138
+ FastlaneCore::ConfigItem.new(key: :force,
139
+ env_name: "VOIP_PUSH_CERTIFICATE_FORCE",
140
+ description: "Create a new certificate, even if the current one is active for more than `active_days_limit` days",
141
+ type: Boolean,
142
+ default_value: false),
143
+ FastlaneCore::ConfigItem.new(key: :revoke_existing,
144
+ env_name: "VOIP_PUSH_CERTIFICATE_REVOKE_EXISTING",
145
+ description: "Revoke the matching existing certificate before creating a new one. This immediately breaks any push provider still using it",
146
+ type: Boolean,
147
+ default_value: false),
148
+ FastlaneCore::ConfigItem.new(key: :generate_p12,
149
+ env_name: "VOIP_PUSH_CERTIFICATE_GENERATE_P12_FILE",
150
+ description: "Generate a p12 file additionally to the PEM file",
151
+ type: Boolean,
152
+ default_value: true),
153
+ FastlaneCore::ConfigItem.new(key: :p12_password,
154
+ short_option: "-p",
155
+ env_name: "VOIP_PUSH_CERTIFICATE_P12_PASSWORD",
156
+ sensitive: true,
157
+ description: "The password that is used for your p12 file",
158
+ optional: true),
159
+ FastlaneCore::ConfigItem.new(key: :save_private_key,
160
+ short_option: "-s",
161
+ env_name: "VOIP_PUSH_CERTIFICATE_SAVE_PRIVATE_KEY",
162
+ description: "Set to save the private RSA key as a separate .pkey file",
163
+ type: Boolean,
164
+ default_value: true),
165
+ FastlaneCore::ConfigItem.new(key: :save_cer,
166
+ env_name: "VOIP_PUSH_CERTIFICATE_SAVE_CER",
167
+ description: "Additionally save the raw DER certificate as a .cer file",
168
+ type: Boolean,
169
+ default_value: false),
170
+ FastlaneCore::ConfigItem.new(key: :bag_attributes,
171
+ env_name: "VOIP_PUSH_CERTIFICATE_BAG_ATTRIBUTES",
172
+ description: "Write the .pem with OpenSSL bag attributes (friendlyName / localKeyID), mimicking the output of `openssl pkcs12 -nodes`. Some push providers require this format",
173
+ type: Boolean,
174
+ default_value: false),
175
+ FastlaneCore::ConfigItem.new(key: :pem_name,
176
+ short_option: "-o",
177
+ env_name: "VOIP_PUSH_CERTIFICATE_PEM_NAME",
178
+ description: "The base file name of the generated files. Defaults to `voip_<app_identifier>`",
179
+ optional: true),
180
+ FastlaneCore::ConfigItem.new(key: :output_path,
181
+ short_option: "-e",
182
+ env_name: "VOIP_PUSH_CERTIFICATE_OUTPUT_PATH",
183
+ description: "The path to a directory in which all certificates and private keys should be stored",
184
+ default_value: ".")
185
+ ]
186
+ end
187
+
188
+ def self.is_supported?(platform)
189
+ [:ios].include?(platform)
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,178 @@
1
+ require 'fastlane_core/ui/ui'
2
+ require 'spaceship'
3
+ require 'openssl'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+
7
+ module Fastlane
8
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
9
+
10
+ module Helper
11
+ # Creates and renews Apple "VoIP Services" push certificates. Apple issues a single
12
+ # VoIP certificate valid for both the sandbox and the production APNs environments,
13
+ # so there is no development/production distinction here.
14
+ class VoipPushCertificateHelper
15
+ CERTIFICATE_LABEL = "VoIP Services".freeze
16
+
17
+ def initialize(params)
18
+ @params = params
19
+ end
20
+
21
+ # @return [Hash, nil] paths of the generated files, or nil when the existing
22
+ # certificate is still valid and no new one was created.
23
+ def run
24
+ login
25
+
26
+ existing = existing_certificate
27
+ if existing
28
+ days = remaining_days(existing)
29
+ UI.message("Existing VoIP Services certificate for '#{existing.owner_name}' is valid for #{days.round} more days.")
30
+ return nil if keep_existing?(days)
31
+
32
+ revoke!(existing) if @params[:revoke_existing]
33
+ end
34
+
35
+ create_certificate
36
+ end
37
+
38
+ private
39
+
40
+ def app_identifier
41
+ @params[:app_identifier]
42
+ end
43
+
44
+ def login
45
+ UI.message("Starting login with user '#{@params[:username]}'")
46
+ Spaceship.login(@params[:username], nil)
47
+ Spaceship.client.select_team
48
+ UI.message("Successfully logged in")
49
+ end
50
+
51
+ # `Spaceship.certificate.voip_push` resolves to
52
+ # `Spaceship::Portal::Certificate::VoipPush` through Spaceship's method_missing.
53
+ def certificate_class
54
+ Spaceship.certificate.voip_push
55
+ end
56
+
57
+ def existing_certificate
58
+ certificate_class.all.sort { |x, y| y.expires <=> x.expires }.detect do |cert|
59
+ cert.owner_name == app_identifier
60
+ end
61
+ end
62
+
63
+ def remaining_days(certificate)
64
+ (certificate.expires - Time.now) / 60 / 60 / 24
65
+ end
66
+
67
+ def keep_existing?(days)
68
+ return false if days <= @params[:active_days_limit]
69
+
70
+ if @params[:force]
71
+ UI.success("You already have an existing VoIP certificate, but a new one will be created since the `force` option has been set.")
72
+ return false
73
+ end
74
+
75
+ UI.success("You already have a VoIP certificate, which is active for more than #{@params[:active_days_limit]} more days. No need to create a new one.")
76
+ UI.success("If you still want to create a new one, use the `force` option.")
77
+ true
78
+ end
79
+
80
+ def revoke!(certificate)
81
+ UI.important("Revoking the existing VoIP Services certificate for '#{certificate.owner_name}' (expires #{certificate.expires}).")
82
+ UI.important("Any push provider still using that certificate will stop being able to send VoIP pushes.")
83
+ certificate.revoke!
84
+ UI.success("Revoked the previous certificate.")
85
+ end
86
+
87
+ def create_certificate
88
+ verify_app_exists!
89
+ UI.important("Creating a new VoIP Services certificate for app '#{app_identifier}'.")
90
+
91
+ csr, pkey = Spaceship.certificate.create_certificate_signing_request
92
+
93
+ begin
94
+ certificate = certificate_class.create!(csr: csr, bundle_id: app_identifier)
95
+ rescue StandardError => e
96
+ if e.to_s.include?("You already have a current")
97
+ UI.message(e.to_s)
98
+ UI.user_error!("You already have 2 active VoIP Services certificates for '#{app_identifier}'. " \
99
+ "Revoke one in the Apple Developer Portal, or re-run with `revoke_existing: true` " \
100
+ "to let this action revoke the matching one for you.")
101
+ else
102
+ raise e
103
+ end
104
+ end
105
+
106
+ write_files(certificate.download, pkey)
107
+ end
108
+
109
+ # `create!` already raises when the bundle id is unknown, but it does so from
110
+ # deep inside Spaceship. Failing early gives a much clearer message.
111
+ def verify_app_exists!
112
+ app = Spaceship.app.find(app_identifier)
113
+ UI.user_error!("Could not find an App ID for '#{app_identifier}' in the Apple Developer Portal.") unless app
114
+ UI.message("Found app '#{app.name}' for bundle id '#{app_identifier}'")
115
+ end
116
+
117
+ def write_files(x509_certificate, pkey)
118
+ output_path = File.expand_path(@params[:output_path])
119
+ FileUtils.mkdir_p(output_path)
120
+
121
+ filename_base = @params[:pem_name] || "voip_#{app_identifier}"
122
+ filename_base = File.basename(filename_base, ".pem")
123
+
124
+ paths = {}
125
+
126
+ if @params[:save_private_key]
127
+ paths[:pkey] = File.join(output_path, "#{filename_base}.pkey")
128
+ File.write(paths[:pkey], pkey.to_pem)
129
+ UI.message("Private key: ".green + Pathname.new(paths[:pkey]).realpath.to_s)
130
+ end
131
+
132
+ if @params[:save_cer]
133
+ paths[:cer] = File.join(output_path, "#{filename_base}.cer")
134
+ File.binwrite(paths[:cer], x509_certificate.to_der)
135
+ UI.message("Certificate (DER): ".green + Pathname.new(paths[:cer]).realpath.to_s)
136
+ end
137
+
138
+ if @params[:generate_p12]
139
+ paths[:p12] = File.join(output_path, "#{filename_base}.p12")
140
+ p12_password = @params[:p12_password] == "" ? nil : @params[:p12_password]
141
+ p12 = OpenSSL::PKCS12.create(p12_password, friendly_name, pkey, x509_certificate)
142
+ File.binwrite(paths[:p12], p12.to_der)
143
+ UI.message("p12 certificate: ".green + Pathname.new(paths[:p12]).realpath.to_s)
144
+ end
145
+
146
+ paths[:pem] = File.join(output_path, "#{filename_base}.pem")
147
+ File.write(paths[:pem], pem_contents(x509_certificate, pkey))
148
+ UI.message("PEM: ".green + Pathname.new(paths[:pem]).realpath.to_s)
149
+
150
+ paths
151
+ end
152
+
153
+ def friendly_name
154
+ "#{CERTIFICATE_LABEL}: #{app_identifier}"
155
+ end
156
+
157
+ def pem_contents(x509_certificate, pkey)
158
+ return x509_certificate.to_pem + pkey.to_pem unless @params[:bag_attributes]
159
+
160
+ header = "Bag Attributes\n " \
161
+ "friendlyName: #{friendly_name}\n " \
162
+ "localKeyID: #{local_key_id(pkey)} \n"
163
+
164
+ "#{header}subject=#{x509_certificate.subject}\n" \
165
+ "issuer=#{x509_certificate.issuer}\n" \
166
+ "#{x509_certificate.to_pem.strip}\n" \
167
+ "#{header}Key Attributes: <No Attributes>\n" \
168
+ "#{pkey.to_pem.strip}\n"
169
+ end
170
+
171
+ # Mimics the localKeyID that `openssl pkcs12 -nodes` writes: the SHA1 digest
172
+ # of the DER-encoded public key, as space separated uppercase hex bytes.
173
+ def local_key_id(pkey)
174
+ OpenSSL::Digest::SHA1.digest(pkey.public_key.to_der).bytes.map { |byte| format("%02X", byte) }.join(" ")
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module VoipPushCertificate
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require 'fastlane/plugin/voip_push_certificate/version'
2
+
3
+ module Fastlane
4
+ module VoipPushCertificate
5
+ # Return all .rb files inside the "actions" and "helper" directory
6
+ def self.all_classes
7
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
8
+ end
9
+ end
10
+ end
11
+
12
+ # By default we want to import all available actions and helpers
13
+ # A plugin can contain any number of actions and plugins
14
+ Fastlane::VoipPushCertificate.all_classes.each do |current|
15
+ require current
16
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-voip_push_certificate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rafael Ferreira
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email: rafinha951026@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/fastlane/plugin/voip_push_certificate.rb
23
+ - lib/fastlane/plugin/voip_push_certificate/actions/get_voip_push_certificate_action.rb
24
+ - lib/fastlane/plugin/voip_push_certificate/helper/voip_push_certificate_helper.rb
25
+ - lib/fastlane/plugin/voip_push_certificate/version.rb
26
+ homepage: https://github.com/Rafinha-rf/fastlane-plugin-voip_push_certificate
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ rubygems_mfa_required: 'true'
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.5.22
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Create and renew Apple VoIP Services push certificates
50
+ test_files: []