autosparkle 0.0.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/bin/autosparkle +3 -0
- data/lib/autosparkle/distribution.rb +42 -0
- data/lib/autosparkle/environment/environment.rb +73 -0
- data/lib/autosparkle/environment/variables/aws_s3_environment_variables.rb +15 -0
- data/lib/autosparkle/environment/variables/base_environment_variables.rb +26 -0
- data/lib/autosparkle/environment/variables/default_environment_variables.rb +35 -0
- data/lib/autosparkle/helpers/appcast_helpers.rb +89 -0
- data/lib/autosparkle/helpers/build_directory_helpers.rb +38 -0
- data/lib/autosparkle/helpers/commands_helpers.rb +28 -0
- data/lib/autosparkle/helpers/constants.rb +7 -0
- data/lib/autosparkle/helpers/dmg_helpers.rb +112 -0
- data/lib/autosparkle/helpers/keychain_helpers.rb +107 -0
- data/lib/autosparkle/helpers/puts_helpers.rb +20 -0
- data/lib/autosparkle/helpers/version_helpers.rb +28 -0
- data/lib/autosparkle/helpers/xcodeproj_helpers.rb +75 -0
- data/lib/autosparkle/main.rb +73 -0
- data/lib/autosparkle/packaging.rb +118 -0
- data/lib/autosparkle/sparkle/BinaryDelta +0 -0
- data/lib/autosparkle/sparkle/generate_appcast +0 -0
- data/lib/autosparkle/sparkle/generate_keys +0 -0
- data/lib/autosparkle/sparkle/old_dsa_scripts/sign_update +18 -0
- data/lib/autosparkle/sparkle/sign_update +0 -0
- data/lib/autosparkle/storages/aws_s3_storage.rb +45 -0
- data/lib/autosparkle/storages/storage.rb +20 -0
- metadata +163 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d68b72cb179b558388027771604619562da6a0293a8901b3aadf9c39fbd39df7
|
4
|
+
data.tar.gz: eaa1e667baba2cd385a63de1d5102ca950b37ecc154b5c8256e6087947d8e973
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 787e99f1fd204d1a9234d22109fc92a2572d09381c8277ffd83e85423ee9fbc286547c700e449876ac6ea3a2b7bf4912901856a5ba4fbd5875e9431ff2e3c540
|
7
|
+
data.tar.gz: 23c2358c76555744a099b67441e032b422f6e5a068f62edda8fae42b74531296e053fe4302bc883ccfa3770911e038238054654d23fbfab7c09482d2aeb4b73c
|
data/README.md
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
# autosparkle
|
2
|
+
|
3
|
+
![autosparkle main image](resources/readme-main-image.jpg)
|
4
|
+
|
5
|
+
autosparkle simplifies the process of **archiving**, **exporting**, **signing**, **packaging**, **notarizing**, and **uploading** your new version of the macOS app outside the App Store.
|
6
|
+
|
7
|
+
With autosparkle, you no longer have to worry about these steps anymore.
|
8
|
+
|
9
|
+
## Table of Contents
|
10
|
+
- [Introduction](#introduction)
|
11
|
+
- [Features](#features)
|
12
|
+
- [Installation](#installation)
|
13
|
+
- [Environment](#environment)
|
14
|
+
- [Storage](#storage)
|
15
|
+
- [Future Enhancements](#future-enhancements)
|
16
|
+
- [Contributing](#contributing)
|
17
|
+
- [License](#license)
|
18
|
+
|
19
|
+
## Introduction
|
20
|
+
|
21
|
+
Pushing a new version of your macOS application outside the App Store can be time-consuming. This is because there are several steps required by Apple, in addition to the ones related to Sparkle.
|
22
|
+
|
23
|
+
autosparkle is a Ruby command line tool that automates the delivery of your macOS applications outside the App Store. It is implemented around the [Sparkle](https://github.com/sparkle-project/Sparkle) framework.
|
24
|
+
|
25
|
+
> **Note:**
|
26
|
+
>
|
27
|
+
> *This tool is designed for use in a Continuous Delivery (CD) workflow. Although it is possible to run it locally, doing so is not recommended due to the creation of a custom keychain and the mounting of a new DMG. However, if you are comfortable with these steps, you can run it locally without any issues.*
|
28
|
+
|
29
|
+
## Features
|
30
|
+
- Archive and export your signed application using the provided Developer Application ID certificate.
|
31
|
+
- Package your app into a signed DMG.
|
32
|
+
- Customize your DMG appearance by adding a custom background image and a symbolic link to drag the app to the Applications folder.
|
33
|
+
- Notarize and staple your DMG.
|
34
|
+
- Automatically sign the Sparkle framework.
|
35
|
+
- Automatically handle your app versioning based on the specified bump method and the versions uploaded to the storage.
|
36
|
+
- Generate or update the appcast.xml file used by Sparkle.
|
37
|
+
- Upload the appcast.xml and your new version to the specified storage.
|
38
|
+
- Use environment files, making it easy to create a specific one for use in CD *(e.g., env.autosparkle.production)*.
|
39
|
+
|
40
|
+
## Installation
|
41
|
+
|
42
|
+
#### Using Ruby Gems:
|
43
|
+
1. Run the following command:
|
44
|
+
```bash
|
45
|
+
gem install autosparkle
|
46
|
+
```
|
47
|
+
2. (optional) Add autosparkle to your shell configuration file (e.g. ~/.bashrc or ~/.zshrc) by appending the following line:
|
48
|
+
```bash
|
49
|
+
export PATH="<<YOUR_GEMS_DIR>>/autosparkle-x.x.x/bin:$PATH"
|
50
|
+
```
|
51
|
+
Make sure to replace `<<YOUR_GEMS_DIR>>/autosparkle-x.x.x` with the actual path to the autosparkle gem on your system.
|
52
|
+
|
53
|
+
3. (optional) Run the following command: `source ~/.bashrc` or `source ~/.zshrc`.
|
54
|
+
|
55
|
+
|
56
|
+
#### Manual install:
|
57
|
+
|
58
|
+
1. Clone the repository:
|
59
|
+
```bash
|
60
|
+
git clone https://github.com/hadiidbouk/autosparkle.git
|
61
|
+
```
|
62
|
+
2. Call the `autosparkle.rb` script from the repository, you need to specify your Xcode project path and your environment file. Here's how you can do it:
|
63
|
+
```bash
|
64
|
+
ruby autosparkle.rb --project-path <<YOUR XCODE PROJECT>> --env <<YOUR ENV NAME>>
|
65
|
+
```
|
66
|
+
|
67
|
+
You can also pass your workspace path using `--workspace-path` and specify the environment path using `--env-file`.
|
68
|
+
|
69
|
+
|
70
|
+
## Environment
|
71
|
+
autosparkle utilizes the [dotenv](https://github.com/bkeepers/dotenv) gem to manage environment variables.
|
72
|
+
|
73
|
+
The purpose of using environment variable files is to facilitate the transition between the local environment on your personal machine and the production environment on your continuous delivery machine.
|
74
|
+
|
75
|
+
When you specify the environment name using the `--env` command, autosparkle will search for autosparkle environment files *(e.g., .env.autosparkle.local, .env.autosparkle.production)* in your project directory.
|
76
|
+
|
77
|
+
Remember to add your local environment file to the *.gitignore* file and remove any sensitive data from your production environment file. Instead, replace them with the environment variables provided by your continuous delivery system like this:
|
78
|
+
```
|
79
|
+
APP_SEPECIFIC_PASSWORD=$APP_SPECIFIC_PASSWORD
|
80
|
+
```
|
81
|
+
|
82
|
+
Below are the list of environment variables used by autosparkle:
|
83
|
+
|
84
|
+
| Variable | Description | Required | Default value |
|
85
|
+
|----------|-------------|----------|---------------|
|
86
|
+
| SCHEME | The macOS app scheme | Yes | |
|
87
|
+
| CONFIGURATION | The app configuration (Debug/Release..)| Yes | |
|
88
|
+
| APPLE_ID | The Apple ID that will be used in the notarize step | Yes | |
|
89
|
+
| APP_SPECIFIC_PASSWORD | The App specific password that will be used in the notarize step | Yes | |
|
90
|
+
| DEVELOPER_ID_APPLICATION_BASE64 | The Developer ID Application base64 certificate | Yes | |
|
91
|
+
| DEVELOPER_ID_APPLICATION_PASSWORD | The Developer ID Application base64 certificate password | Yes | |
|
92
|
+
| DMG_BACKGROUND_IMAGE | The DMG background image path that should exist in the project folder | Yes | |
|
93
|
+
| DMG_WINDOW_WIDTH | The DMG "Drag to Applications" window width | Yes | |
|
94
|
+
| DMG_WINDOW_HEIGHT | The DMG "Drag to Applications" window height | Yes | |
|
95
|
+
| DMG_ICON_SIZE | The icon size of the app and the Applications folder in the window | Yes | |
|
96
|
+
| SPARKLE_PRIVATE_KEY | The Sparkle private key that will be generated on your machine after running the `lib/autosparkle/sparkle/generate_keys`, you can export the private key into a file and then copy it into this variable. `lib/autosparkle/sparkle/generate_keys -x ~/Desktop/my_key.txt` | Yes | |
|
97
|
+
| SPARKLE_UPDATE_TITLE | The new version update title | Yes | |
|
98
|
+
| SPARKLE_RELEASE_NOTES | The HTML release notes, it shouldn't contains any body or header just simple html tags | Yes | |
|
99
|
+
| SPARKLE_BUMP_VERSION_METHOD | The version bump method for your marketing semantic version, `patch`, `minor`, `major` or `same`. | Yes | |
|
100
|
+
| WEBSITE_URL | The website URL that will be added to the appcast.xml | Yes | |
|
101
|
+
| STORAGE_TYPE | The storage type used to upload your app versions alongside the *appcast.yml* file, available values are: `aws-s3` | Yes | |
|
102
|
+
| AWS_S3_ACCESS_KEY | The AWS S3 access Key | Yes (if `aws-s3` STORAGE_TYPE is specified) | |
|
103
|
+
| AWS_S3_SECRET_ACCESS_KEY | The AWS S3 secret access key | Yes (if `aws-s3` STORAGE_TYPE is specified)| |
|
104
|
+
| AWS_S3_REGION | The AWS S3 region | Yes (if `aws-s3` STORAGE_TYPE is specified) | |
|
105
|
+
| AWS_S3_BUCKET_NAME | The AWS S3 bucket name | Yes (if `aws-s3` STORAGE_TYPE is specified) | |
|
106
|
+
|
107
|
+
> **Note:**
|
108
|
+
>
|
109
|
+
> *Remember to add the SUFeedURL and the **SUPublicEDKey** to your app's Info.plist.*
|
110
|
+
> **SUFeedURL** is the URL of your appcast.xml, which should be publicly accessible online.
|
111
|
+
> **SUPublicEDKey** will be displayed in your terminal after running the `bin/generate_keys` command.
|
112
|
+
|
113
|
+
You can refer to the [SparkleTestApp](https://github.com/hadiidbouk/sparkle-test-app) sample project for more information.
|
114
|
+
|
115
|
+
## Storage
|
116
|
+
In order to support multiple versions of your macOS application, autosparkle requires access to an online storage for uploading and reading the *appcast.xml* file and the application versions.
|
117
|
+
|
118
|
+
The currently supported storage options are:
|
119
|
+
|
120
|
+
- AWS S3
|
121
|
+
|
122
|
+
## Future Enhancements
|
123
|
+
Here are some future enhancements planned for autosparkle:
|
124
|
+
|
125
|
+
- Support binary delta updates.
|
126
|
+
- Support for additional storage options.
|
127
|
+
- Introduce the ability to package the app without distributing it.
|
128
|
+
- Support for the `pkg` format.
|
129
|
+
- Distributing autosparkle using Homebrew.
|
130
|
+
|
131
|
+
Stay tuned for these exciting updates!
|
132
|
+
|
133
|
+
|
134
|
+
|
135
|
+
## Contributing
|
136
|
+
Contributions to autosparkle are welcome! If you would like to contribute, please follow these guidelines:
|
137
|
+
- Fork the repository.
|
138
|
+
- Create a new branch for your feature or bug fix.
|
139
|
+
- Make your changes and commit them with descriptive messages.
|
140
|
+
- Push your changes to your forked repository.
|
141
|
+
- Submit a pull request to the main repository on the `develop` branch.
|
142
|
+
|
143
|
+
## License
|
144
|
+
autosparkle is licensed under the [MIT License](LICENSE).
|
data/bin/autosparkle
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'colorize'
|
4
|
+
require_relative 'helpers/build_directory_helpers'
|
5
|
+
require_relative 'helpers/commands_helpers'
|
6
|
+
require_relative 'helpers/puts_helpers'
|
7
|
+
require_relative 'helpers/constants'
|
8
|
+
require_relative 'helpers/appcast_helpers'
|
9
|
+
require_relative 'environment/environment'
|
10
|
+
|
11
|
+
# Distribution module to sign the update and upload it to the server
|
12
|
+
module Distribution
|
13
|
+
def self.upload_update(pkg_path)
|
14
|
+
puts_title 'Uploading the update to the server'
|
15
|
+
ed_signature_fragment = sign_update(pkg_path)
|
16
|
+
|
17
|
+
appcast_xml = AppcastXML.generate_appcast_xml(ed_signature_fragment, Env.storage.deployed_appcast_xml)
|
18
|
+
upload_update_to_server(pkg_path, appcast_xml)
|
19
|
+
|
20
|
+
app_display_name = Env.variables.app_display_name
|
21
|
+
version = Env.variables.marketing_version
|
22
|
+
build_version = Env.variables.current_project_version
|
23
|
+
puts "#{app_display_name} version #{version} (#{build_version}) has been uploaded successfully. ✅ 🚀".green
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.sign_update(pkg_path)
|
27
|
+
puts_if_verbose 'Signing the update...'
|
28
|
+
sign_update_path = File.join(__dir__, 'sparkle', 'sign_update')
|
29
|
+
sign_command = "echo \"#{Env.variables.sparkle_private_key}\" | "
|
30
|
+
sign_command += "#{sign_update_path} \"#{pkg_path}\" --ed-key-file -"
|
31
|
+
execute_command(sign_command, contains_sensitive_data: true)
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.upload_update_to_server(pkg_path, appcast_xml)
|
35
|
+
puts_if_verbose 'Uploading the update to the server...'
|
36
|
+
appcast_file = BuildDirectory.new_file('appcast.xml')
|
37
|
+
appcast_file.write(appcast_xml)
|
38
|
+
appcast_file.close
|
39
|
+
|
40
|
+
Env.storage.upload(pkg_path, appcast_file.path)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotenv'
|
4
|
+
require 'nokogiri'
|
5
|
+
require_relative '../helpers/appcast_helpers'
|
6
|
+
require_relative '../helpers/puts_helpers'
|
7
|
+
require_relative '../storages/aws_s3_storage'
|
8
|
+
require_relative 'variables/default_environment_variables'
|
9
|
+
|
10
|
+
# Environment module to load the environment variables
|
11
|
+
# It contains the app state
|
12
|
+
module Env
|
13
|
+
@variables = nil
|
14
|
+
@storage = nil
|
15
|
+
@verbose_enabled = false
|
16
|
+
|
17
|
+
class << self
|
18
|
+
attr_accessor :variables, :storage, :verbose_enabled
|
19
|
+
|
20
|
+
def initialize(options)
|
21
|
+
project_path = options[:project_path]
|
22
|
+
workspace_path = options[:workspace_path]
|
23
|
+
project_directory_path = File.dirname(workspace_path || project_path)
|
24
|
+
|
25
|
+
load_environment(project_directory_path, options)
|
26
|
+
|
27
|
+
@variables = DefaultEnvironmentVariables.new(project_directory_path)
|
28
|
+
@storage = retrieve_storage
|
29
|
+
|
30
|
+
retrieve_variables_from_xcode(project_path, workspace_path)
|
31
|
+
set_up_app_versions(project_path, workspace_path)
|
32
|
+
|
33
|
+
puts_if_verbose "Running the script with the #{options[:env]} environment...\n"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.load_environment(project_directory_path, options)
|
38
|
+
env_file_path = options[:env_file] || File.join(project_directory_path, ".env.autosparkle.#{options[:env]}")
|
39
|
+
raise "#{env_file_path} does not exist." unless File.exist?(env_file_path)
|
40
|
+
|
41
|
+
Dotenv.load(env_file_path)
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.retrieve_storage
|
45
|
+
storage_type = ENV.fetch('STORAGE_TYPE', nil)
|
46
|
+
raise 'Storage type is not defined in the environment' if storage_type.nil? || storage_type.empty?
|
47
|
+
|
48
|
+
case storage_type
|
49
|
+
when 'aws-s3'
|
50
|
+
AwsS3Storage.new
|
51
|
+
else
|
52
|
+
raise "Storage type #{storage_type} is not supported"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.retrieve_variables_from_xcode(project_path, workspace_path)
|
57
|
+
puts_if_verbose 'Fetching the minimum macOS version from the Xcode project...'
|
58
|
+
ENV['MINIMUM_MACOS_VERSION'] = Xcodeproj.get_minimum_deployment_macos_version(project_path, workspace_path)
|
59
|
+
|
60
|
+
puts_if_verbose 'Fetching the app display name from the Xcode project...'
|
61
|
+
ENV['APP_DISPLAY_NAME'] = Xcodeproj.get_app_display_name(project_path, workspace_path)
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.set_up_app_versions(project_path, workspace_path)
|
65
|
+
marketing_version, current_project_version = AppcastXML.retreive_versions(@storage.deployed_appcast_xml)
|
66
|
+
|
67
|
+
ENV['MARKETING_VERSION'] = marketing_version
|
68
|
+
ENV['CURRENT_PROJECT_VERSION'] = current_project_version
|
69
|
+
|
70
|
+
puts_if_verbose 'Updating the project versions from the environment variables...'
|
71
|
+
Xcodeproj.update_project_version(project_path, workspace_path)
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base_environment_variables'
|
4
|
+
|
5
|
+
# A class to load the AWS S3 environment variables
|
6
|
+
class AwsS3EnvironmentVariables < BaseEnvironmentVariables
|
7
|
+
def initialize
|
8
|
+
super({
|
9
|
+
access_key: 'AWS_S3_ACCESS_KEY',
|
10
|
+
secret_access_key: 'AWS_S3_SECRET_ACCESS_KEY',
|
11
|
+
region: 'AWS_S3_REGION',
|
12
|
+
bucket_name: 'AWS_S3_BUCKET_NAME'
|
13
|
+
})
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../helpers/puts_helpers'
|
4
|
+
|
5
|
+
# The base envrionment variables class
|
6
|
+
class BaseEnvironmentVariables
|
7
|
+
def initialize(variables)
|
8
|
+
@variables = variables
|
9
|
+
end
|
10
|
+
|
11
|
+
def method_missing(method_name, *arguments, &block)
|
12
|
+
key = method_name.to_sym
|
13
|
+
if @variables.key?(key)
|
14
|
+
value = ENV.fetch(@variables[key], nil)
|
15
|
+
raise "#{@variables[key]} is not defined in the environment variables" if value.nil? || value.empty?
|
16
|
+
|
17
|
+
value
|
18
|
+
else
|
19
|
+
super
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def respond_to_missing?(method_name, include_private = false)
|
24
|
+
@variables.key?(method_name.to_sym) || super
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../helpers/puts_helpers'
|
4
|
+
require_relative '../../helpers/xcodeproj_helpers'
|
5
|
+
require_relative 'base_environment_variables'
|
6
|
+
|
7
|
+
# A class to load the default environment variables
|
8
|
+
class DefaultEnvironmentVariables < BaseEnvironmentVariables
|
9
|
+
def initialize(project_directory_path)
|
10
|
+
super({
|
11
|
+
project_directory_path: 'PROJECT_DIRECTORY_PATH',
|
12
|
+
scheme: 'SCHEME',
|
13
|
+
configuration: 'CONFIGURATION',
|
14
|
+
marketing_version: 'MARKETING_VERSION',
|
15
|
+
current_project_version: 'CURRENT_PROJECT_VERSION',
|
16
|
+
minimum_macos_version: 'MINIMUM_MACOS_VERSION',
|
17
|
+
app_display_name: 'APP_DISPLAY_NAME',
|
18
|
+
apple_id: 'APPLE_ID',
|
19
|
+
app_specific_password: 'APP_SPECIFIC_PASSWORD',
|
20
|
+
developer_id_application_password: 'DEVELOPER_ID_APPLICATION_PASSWORD',
|
21
|
+
developer_id_application_base64: 'DEVELOPER_ID_APPLICATION_BASE64',
|
22
|
+
sparkle_private_key: 'SPARKLE_PRIVATE_KEY',
|
23
|
+
sparkle_update_title: 'SPARKLE_UPDATE_TITLE',
|
24
|
+
sparkle_release_notes: 'SPARKLE_RELEASE_NOTES',
|
25
|
+
sparkle_bump_version_method: 'SPARKLE_BUMP_VERSION_METHOD',
|
26
|
+
website_url: 'WEBSITE_URL',
|
27
|
+
dmg_background_image: 'DMG_BACKGROUND_IMAGE',
|
28
|
+
dmg_icon_size: 'DMG_ICON_SIZE',
|
29
|
+
dmg_window_width: 'DMG_WINDOW_WIDTH',
|
30
|
+
dmg_window_height: 'DMG_WINDOW_HEIGHT'
|
31
|
+
})
|
32
|
+
|
33
|
+
ENV['PROJECT_DIRECTORY_PATH'] = project_directory_path
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nokogiri'
|
4
|
+
require_relative '../environment/environment'
|
5
|
+
require_relative 'puts_helpers'
|
6
|
+
require_relative 'version_helpers'
|
7
|
+
|
8
|
+
# AppcastXML module to generate the appcast XML file and items
|
9
|
+
module AppcastXML
|
10
|
+
def self.generate_appcast_xml(ed_signature_fragment, deployed_appcast_xml)
|
11
|
+
if deployed_appcast_xml
|
12
|
+
append_item_to_existing_appcast(ed_signature_fragment, deployed_appcast_xml)
|
13
|
+
else
|
14
|
+
puts_if_verbose 'Creating a new appcast file...'
|
15
|
+
<<~XML
|
16
|
+
<?xml version="1.0" encoding="utf-8"?>
|
17
|
+
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
18
|
+
<channel>
|
19
|
+
<title>Changelog</title>
|
20
|
+
<description>Most recent changes with links to updates.</description>
|
21
|
+
<language>en</language>
|
22
|
+
#{generate_appcast_item(ed_signature_fragment)}
|
23
|
+
</channel>
|
24
|
+
</rss>
|
25
|
+
XML
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.append_item_to_existing_appcast(ed_signature_fragment, deployed_appcast_xml)
|
30
|
+
puts_if_verbose 'Appending the new item to the existing appcast file...'
|
31
|
+
|
32
|
+
doc = Nokogiri::XML(deployed_appcast_xml)
|
33
|
+
|
34
|
+
new_item_xml = generate_appcast_item(ed_signature_fragment)
|
35
|
+
new_item_doc = Nokogiri::XML(new_item_xml)
|
36
|
+
new_item = new_item_doc.root
|
37
|
+
|
38
|
+
channel = doc.at_xpath('//channel')
|
39
|
+
channel.add_child(new_item)
|
40
|
+
|
41
|
+
doc.to_xml
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.generate_appcast_item(ed_signature_fragment)
|
45
|
+
date = Time.now.strftime('%a %b %d %H:%M:%S %Z %Y')
|
46
|
+
pkg_url = "#{Env.variables.marketing_version}/#{Env.variables.app_display_name}.dmg"
|
47
|
+
<<~XML
|
48
|
+
<item>
|
49
|
+
<title>#{Env.variables.sparkle_update_title}</title>
|
50
|
+
<link>#{Env.variables.website_url}</link>
|
51
|
+
<sparkle:version>#{Env.variables.current_project_version}</sparkle:version>
|
52
|
+
<sparkle:shortVersionString>#{Env.variables.marketing_version}</sparkle:shortVersionString>
|
53
|
+
<description>
|
54
|
+
<![CDATA[
|
55
|
+
#{Env.variables.sparkle_release_notes}
|
56
|
+
]]>
|
57
|
+
</description>
|
58
|
+
<pubDate>#{date}</pubDate>
|
59
|
+
<enclosure url="#{pkg_url}" type="application/octet-stream" #{ed_signature_fragment} />
|
60
|
+
<sparkle:minimumSystemVersion>#{Env.variables.minimum_macos_version}</sparkle:minimumSystemVersion>
|
61
|
+
</item>
|
62
|
+
XML
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.retreive_versions(deployed_appcast_xml)
|
66
|
+
return ['1.0.0', '1'] unless deployed_appcast_xml
|
67
|
+
|
68
|
+
doc = Nokogiri::XML(deployed_appcast_xml)
|
69
|
+
[marketing_version(doc), current_project_version(doc)]
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.marketing_version(doc)
|
73
|
+
method_name = Env.variables.sparkle_bump_version_method
|
74
|
+
raise "Unsupported bump method name '#{method_name}'" unless %w[minor patch major same].include?(method_name)
|
75
|
+
|
76
|
+
# bump the marketing version from @variables.sparkle_bump_version_method
|
77
|
+
short_version_strings = doc.xpath('//item/sparkle:shortVersionString', 'sparkle' => 'http://www.andymatuschak.org/xml-namespaces/sparkle')
|
78
|
+
latest_semantic_version = short_version_strings.map { |s| Gem::Version.new(s.text) }.max
|
79
|
+
method_name == 'same' ? latest_semantic_version.to_s : Version.bump(latest_semantic_version, method_name)
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.current_project_version(doc)
|
83
|
+
# find the latest versions in the item tag for sparkle:version child
|
84
|
+
versions = doc.xpath('//item/sparkle:version', 'sparkle' => 'http://www.andymatuschak.org/xml-namespaces/sparkle')
|
85
|
+
.map { |s| s.text.to_i }
|
86
|
+
latest_version = versions.max + 1
|
87
|
+
latest_version.to_s
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require_relative 'constants'
|
5
|
+
require_relative 'puts_helpers'
|
6
|
+
|
7
|
+
# This module is used to create a new path inside the build directory
|
8
|
+
module BuildDirectory
|
9
|
+
@build_directory_path = File.expand_path(Constants::AUTOSPARKLE_BUILD_DIRECTORY_PATH)
|
10
|
+
|
11
|
+
def self.build_directory_path
|
12
|
+
@build_directory_path
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.create_build_directory
|
16
|
+
if File.directory?(build_directory_path)
|
17
|
+
puts_if_verbose 'Cleaning up the build directory...'
|
18
|
+
FileUtils.rm_rf(build_directory_path)
|
19
|
+
end
|
20
|
+
|
21
|
+
puts_if_verbose "Creating the build directory at #{build_directory_path} ..."
|
22
|
+
FileUtils.mkdir_p(build_directory_path)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.new_path(name)
|
26
|
+
"#{build_directory_path}/#{name}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.new_file(name)
|
30
|
+
File.open(new_path(name), 'w')
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.new_directory(name)
|
34
|
+
path = new_path(name)
|
35
|
+
FileUtils.mkdir_p(path)
|
36
|
+
path
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'colorize'
|
4
|
+
require 'english'
|
5
|
+
require_relative '../environment/environment'
|
6
|
+
|
7
|
+
#
|
8
|
+
# This method executes a command and returns the output
|
9
|
+
# It raises an error if the command fails
|
10
|
+
#
|
11
|
+
def execute_command(command, contains_sensitive_data: false)
|
12
|
+
# if the command has --password, --secret then replace it with *****
|
13
|
+
presented_command = command.gsub(/(--password|--secret) \S+/, '\1 *****')
|
14
|
+
|
15
|
+
puts "\n#{presented_command}\n".cyan if Env.verbose_enabled && !contains_sensitive_data
|
16
|
+
stdout, stderr, status = Open3.capture3(command)
|
17
|
+
|
18
|
+
# if status is not success
|
19
|
+
unless status.success?
|
20
|
+
puts "\nCommand failed: #{command}\n".red
|
21
|
+
puts "\nError: #{stderr}\n".red
|
22
|
+
raise
|
23
|
+
end
|
24
|
+
|
25
|
+
puts "#{stdout}\n\n".magenta if Env.verbose_enabled && !contains_sensitive_data
|
26
|
+
|
27
|
+
stdout
|
28
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Constants module to store the constants used in the application
|
4
|
+
module Constants
|
5
|
+
NOTARIZE_KEYCHAIN_PROFILE = 'autosparkle.keychain.notarize.profile'
|
6
|
+
AUTOSPARKLE_BUILD_DIRECTORY_PATH = '~/Library/Developer/autosparkle/build'
|
7
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require_relative 'build_directory_helpers'
|
5
|
+
require_relative 'puts_helpers'
|
6
|
+
require_relative '../environment/environment'
|
7
|
+
|
8
|
+
# This module is used to create a DMG file and set up the appearance
|
9
|
+
module DMG
|
10
|
+
def self.create(app_path)
|
11
|
+
volume_name = Env.variables.app_display_name
|
12
|
+
background_image_path = "#{Env.variables.project_directory_path}/#{Env.variables.dmg_background_image}"
|
13
|
+
|
14
|
+
dmg_path = create_blank_dmg(app_path, background_image_path, volume_name)
|
15
|
+
mount(dmg_path, volume_name)
|
16
|
+
copy_app_and_set_symbolic_link(app_path, volume_name)
|
17
|
+
copy_background_image(background_image_path, volume_name)
|
18
|
+
customize_dmg_appearence(volume_name)
|
19
|
+
unmount(volume_name)
|
20
|
+
create_read_only_dmg(dmg_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.create_blank_dmg(app_path, background_image_path, volume_name)
|
24
|
+
puts_if_verbose 'Creating a blank DMG...'
|
25
|
+
volume_size = volume_size(app_path, background_image_path)
|
26
|
+
uuid = `uuidgen`.strip
|
27
|
+
dmg_path = BuildDirectory.new_path("#{Env.variables.app_display_name}-#{uuid}.dmg")
|
28
|
+
execute_command("hdiutil create -size #{volume_size}m -fs HFS+ -volname '#{volume_name}' -ov '#{dmg_path}'")
|
29
|
+
dmg_path
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.volume_size(app_path, background_image_path)
|
33
|
+
app_size_kb = `du -sk "#{app_path}"`.split("\t").first.to_i
|
34
|
+
background_size_kb = File.size(background_image_path) / 1024
|
35
|
+
buffer_size_kb = 20 * 1024
|
36
|
+
volume_size_kb = app_size_kb + background_size_kb + buffer_size_kb
|
37
|
+
(volume_size_kb / 1024.0).ceil
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.mount(dmg_path, volume_name)
|
41
|
+
puts_if_verbose 'Mounting the DMG...'
|
42
|
+
execute_command("hdiutil attach '#{dmg_path}' -mountpoint '/Volumes/#{volume_name}'")
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.copy_app_and_set_symbolic_link(app_path, volume_name)
|
46
|
+
puts_if_verbose 'Copying the app to the DMG and creating a symbolic link to the Applications folder...'
|
47
|
+
FileUtils.cp_r(app_path, "/Volumes/#{volume_name}")
|
48
|
+
execute_command("ln -s /Applications /Volumes/#{volume_name}/Applications")
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.copy_background_image(background_image_path, volume_name)
|
52
|
+
puts_if_verbose 'Copying the background image to the DMG...'
|
53
|
+
FileUtils.mkdir_p("/Volumes/#{volume_name}/.background")
|
54
|
+
FileUtils.cp(background_image_path, "/Volumes/#{volume_name}/.background/")
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.customize_dmg_appearence(volume_name)
|
58
|
+
puts_if_verbose 'Customizing the appearance of the DMG...'
|
59
|
+
|
60
|
+
app_x_position = Env.variables.dmg_window_width.to_i * 0.25
|
61
|
+
applications_x_position = Env.variables.dmg_window_width.to_i * 0.75
|
62
|
+
item_y_position = Env.variables.dmg_window_height.to_i / 2
|
63
|
+
|
64
|
+
apple_script = dmg_appearence_apple_script(volume_name, app_x_position, applications_x_position, item_y_position)
|
65
|
+
execute_command("osascript -e '#{apple_script}'")
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.dmg_appearence_apple_script(volume_name, app_x_position, applications_x_position, item_y_position)
|
69
|
+
window_width = Env.variables.dmg_window_width.to_i
|
70
|
+
window_height = Env.variables.dmg_window_height.to_i
|
71
|
+
<<-APPLESCRIPT
|
72
|
+
tell application "Finder"
|
73
|
+
tell disk "#{volume_name}"
|
74
|
+
open
|
75
|
+
set current view of container window to icon view
|
76
|
+
set toolbar visible of container window to false
|
77
|
+
set statusbar visible of container window to false
|
78
|
+
set the bounds of container window to {0, 0, #{window_width}, #{window_height}}
|
79
|
+
set arrangement of icon view options of container window to not arranged
|
80
|
+
set icon size of icon view options of container window to #{Env.variables.dmg_icon_size}
|
81
|
+
set background picture of icon view options of container window to file ".background:#{Env.variables.dmg_background_image}"
|
82
|
+
set position of item "#{Env.variables.app_display_name}.app" of container window to {#{app_x_position}, #{item_y_position}}
|
83
|
+
set position of item "Applications" of container window to {#{applications_x_position}, #{item_y_position}}
|
84
|
+
close
|
85
|
+
open
|
86
|
+
update without registering applications
|
87
|
+
delay 5
|
88
|
+
end tell
|
89
|
+
end tell
|
90
|
+
APPLESCRIPT
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.unmount(volume_name)
|
94
|
+
puts_if_verbose 'Unmounting the DMG...'
|
95
|
+
command = "hdiutil detach '/Volumes/#{volume_name}'"
|
96
|
+
begin
|
97
|
+
execute_command(command)
|
98
|
+
rescue StandardError
|
99
|
+
puts_if_verbose 'Retrying unmount after a brief wait...'
|
100
|
+
sleep 5
|
101
|
+
execute_command(command)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.create_read_only_dmg(dmg_path)
|
106
|
+
puts_if_verbose 'Converting the DMG to read-only...'
|
107
|
+
dmg_final_path = BuildDirectory.new_path("#{Env.variables.app_display_name}.dmg")
|
108
|
+
execute_command("hdiutil convert '#{dmg_path}' -format UDZO -o '#{dmg_final_path}'")
|
109
|
+
execute_command("rm -rf '#{dmg_path}'")
|
110
|
+
dmg_final_path
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'open3'
|
3
|
+
require 'base64'
|
4
|
+
require_relative 'build_directory_helpers'
|
5
|
+
require_relative 'constants'
|
6
|
+
require_relative 'commands_helpers'
|
7
|
+
require_relative 'puts_helpers'
|
8
|
+
|
9
|
+
#
|
10
|
+
# Execute the given block with a temporary keychain,
|
11
|
+
# The block will receive the application certificate name and team id as arguments.
|
12
|
+
# The temporary keychain will be deleted after the block has been executed.
|
13
|
+
#
|
14
|
+
def with_temporary_keychain
|
15
|
+
keychain_name = "temporary.autosparkle.keychain-#{SecureRandom.uuid}"
|
16
|
+
keychain_path = "~/Library/Keychains/#{keychain_name}-db"
|
17
|
+
password = SecureRandom.hex(16)
|
18
|
+
|
19
|
+
original_keychain_list = `security list-keychains`.strip.split("\n").map(&:strip)
|
20
|
+
default_keychain = execute_command('security default-keychain')
|
21
|
+
default_keychain_path = default_keychain.gsub(/"(.*)"/, '\1').strip
|
22
|
+
|
23
|
+
delete_temporary_keychain_if_needed(keychain_path)
|
24
|
+
|
25
|
+
begin
|
26
|
+
# Create a temporary keychain
|
27
|
+
create_temporary_keychain(keychain_path, password, original_keychain_list)
|
28
|
+
import_certificates_in_temporary_keychain(keychain_path)
|
29
|
+
execute_command("security set-key-partition-list -S apple-tool:,apple:,codesign: \\
|
30
|
+
-s -k \"#{password}\" #{keychain_path}")
|
31
|
+
|
32
|
+
# Fetch the certificate names and team ids from the temporary keychain
|
33
|
+
application_cert_name, application_team_id = fetch_application_certificate_info(keychain_path)
|
34
|
+
store_notarization_credentials(keychain_path, application_team_id)
|
35
|
+
|
36
|
+
keychain_info = {
|
37
|
+
keychain_path: keychain_path,
|
38
|
+
application_cert_name: application_cert_name,
|
39
|
+
application_team_id: application_team_id
|
40
|
+
}
|
41
|
+
yield(keychain_info) if block_given?
|
42
|
+
ensure
|
43
|
+
puts_if_verbose 'Ensuring cleanup of temporary keychain...'
|
44
|
+
delete_temporary_keychain_if_needed(keychain_path)
|
45
|
+
|
46
|
+
# Reset the keychain to the default
|
47
|
+
execute_command("security list-keychains -s #{original_keychain_list.join(' ')}")
|
48
|
+
execute_command("security default-keychain -s \"#{default_keychain_path}\"")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def create_temporary_keychain(keychain_path, password, original_keychain_list)
|
55
|
+
execute_command("security create-keychain -p \"#{password}\" #{keychain_path}")
|
56
|
+
execute_command("security unlock-keychain -p \"#{password}\" #{keychain_path}")
|
57
|
+
execute_command("security list-keychains -d user -s #{(original_keychain_list + [keychain_path]).join(' ')}")
|
58
|
+
execute_command("security default-keychain -s #{keychain_path}")
|
59
|
+
end
|
60
|
+
|
61
|
+
def store_notarization_credentials(keychain_path, application_team_id)
|
62
|
+
command = "xcrun notarytool store-credentials #{Constants::NOTARIZE_KEYCHAIN_PROFILE} \\
|
63
|
+
--keychain #{keychain_path} \\
|
64
|
+
--apple-id #{Env.variables.apple_id} \\
|
65
|
+
--team-id #{application_team_id} \\
|
66
|
+
--password #{Env.variables.app_specific_password}"
|
67
|
+
execute_command(command)
|
68
|
+
end
|
69
|
+
|
70
|
+
def import_certificates_in_temporary_keychain(keychain_path)
|
71
|
+
developer_id_application_p12 = Base64.decode64(Env.variables.developer_id_application_base64 || '')
|
72
|
+
|
73
|
+
# Create temporary files for the .p12 certificates
|
74
|
+
application_cert_file = BuildDirectory.new_file('application_cert.p12')
|
75
|
+
|
76
|
+
# Write the decoded .p12 data to the temporary files
|
77
|
+
application_cert_file.write(developer_id_application_p12)
|
78
|
+
application_cert_file.close
|
79
|
+
|
80
|
+
# Import the certificates into the temporary keychain
|
81
|
+
import_file_to_keychain(keychain_path, application_cert_file.path,
|
82
|
+
Env.variables.developer_id_application_password)
|
83
|
+
end
|
84
|
+
|
85
|
+
def import_file_to_keychain(keychain_path, file_path, password)
|
86
|
+
command = "security import #{file_path} -k #{keychain_path} -P #{password}"
|
87
|
+
command += ' -T /usr/bin/codesign'
|
88
|
+
command += ' -T /usr/bin/security'
|
89
|
+
command += ' -T /usr/bin/productbuild'
|
90
|
+
command += ' -T /usr/bin/productsign'
|
91
|
+
execute_command(command)
|
92
|
+
end
|
93
|
+
|
94
|
+
def fetch_certificate_info(keychain_path, certificate_type)
|
95
|
+
command = "security find-certificate -c \"#{certificate_type}\" #{keychain_path} | grep \"labl\" | sed -E 's/^.*\"labl\"<blob>=\"(.*)\".*/\\1/'"
|
96
|
+
name = `#{command}`.strip
|
97
|
+
team_id = name[/\(([^)]+)\)$/, 1]
|
98
|
+
[name, team_id]
|
99
|
+
end
|
100
|
+
|
101
|
+
def fetch_application_certificate_info(keychain_path)
|
102
|
+
fetch_certificate_info(keychain_path, 'Developer ID Application')
|
103
|
+
end
|
104
|
+
|
105
|
+
def delete_temporary_keychain_if_needed(keychain_path)
|
106
|
+
execute_command("security delete-keychain #{keychain_path}") if File.exist?(File.expand_path(keychain_path.to_s))
|
107
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'colorize'
|
4
|
+
require_relative '../environment/environment'
|
5
|
+
|
6
|
+
def puts_if_verbose(message)
|
7
|
+
puts message if Env.verbose_enabled
|
8
|
+
end
|
9
|
+
|
10
|
+
def puts_error(message)
|
11
|
+
if message.is_a?(Array)
|
12
|
+
puts message.map(&:red).join("\n")
|
13
|
+
else
|
14
|
+
puts message.red
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def puts_title(message)
|
19
|
+
puts "\n🔷 #{message} ...\n".yellow
|
20
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is used to bump the version of the app
|
4
|
+
# It receives the current version and the method to bump the version
|
5
|
+
# It returns the new version
|
6
|
+
module Version
|
7
|
+
def self.bump(current_version, method)
|
8
|
+
major, minor, patch = current_version.segments
|
9
|
+
|
10
|
+
case method
|
11
|
+
when 'major'
|
12
|
+
major += 1
|
13
|
+
# Reset minor and patch versions
|
14
|
+
minor = 0
|
15
|
+
patch = 0
|
16
|
+
when 'minor'
|
17
|
+
minor += 1
|
18
|
+
# Reset patch version
|
19
|
+
patch = 0
|
20
|
+
when 'patch'
|
21
|
+
patch += 1
|
22
|
+
else
|
23
|
+
raise ArgumentError, "Unknown bump method: #{method}"
|
24
|
+
end
|
25
|
+
|
26
|
+
"#{major}.#{minor}.#{patch}"
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'colorize'
|
2
|
+
require 'xcodeproj'
|
3
|
+
require 'plist'
|
4
|
+
require 'optparse'
|
5
|
+
require_relative '../environment/environment'
|
6
|
+
require_relative 'puts_helpers'
|
7
|
+
|
8
|
+
# Xcodeproj module to handle the fetch and manipulation of Xcode project
|
9
|
+
module Xcodeproj
|
10
|
+
def self.get_app_display_name(project_path, workspace_path)
|
11
|
+
_, target = get_app_target(project_path, workspace_path)
|
12
|
+
build_settings = target.build_configurations.first.build_settings
|
13
|
+
display_name = build_settings['PRODUCT_NAME']
|
14
|
+
display_name = target.name if display_name == '$(TARGET_NAME)'
|
15
|
+
display_name
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.get_minimum_deployment_macos_version(project_path, workspace)
|
19
|
+
_, target = get_app_target(project_path, workspace)
|
20
|
+
config = target.build_configurations.first
|
21
|
+
config.build_settings['MACOSX_DEPLOYMENT_TARGET']
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.get_project_version(project_path, workspace_path)
|
25
|
+
_, target = get_app_target(project_path, workspace_path)
|
26
|
+
target.build_configurations.first.build_settings
|
27
|
+
[build_settings['MARKETING_VERSION'], build_settings['CURRENT_PROJECT_VERSION']]
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.update_project_version(project_path, workspace_path)
|
31
|
+
project, target = get_app_target(project_path, workspace_path)
|
32
|
+
target.build_configurations.each do |config|
|
33
|
+
config.build_settings['MARKETING_VERSION'] = Env.variables.marketing_version
|
34
|
+
config.build_settings['CURRENT_PROJECT_VERSION'] = Env.variables.current_project_version
|
35
|
+
end
|
36
|
+
project.save
|
37
|
+
|
38
|
+
puts_if_verbose "Successfully updated the project version to #{Env.variables.marketing_version} and saved the project."
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.get_app_target(project_path, workspace_path)
|
42
|
+
project = Xcodeproj::Project.open(project_path) if project_path
|
43
|
+
workspace = Xcodeproj::Workspace.new_from_xcworkspace(workspace_path) if workspace_path
|
44
|
+
|
45
|
+
target = project.targets.find { |t| t.name == Env.variables.scheme } if project
|
46
|
+
|
47
|
+
# If workspace is used, find the project containing the scheme
|
48
|
+
if workspace && !target
|
49
|
+
workspace.file_references.each do |file_reference|
|
50
|
+
project_path = File.join(File.dirname(workspace_path), file_reference.path)
|
51
|
+
project = Xcodeproj::Project.open(project_path)
|
52
|
+
target = project.targets.find { |t| t.name == Env.variables.scheme }
|
53
|
+
break if target
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
raise "Target not found for scheme #{Env.variables.scheme_name}".red unless target
|
58
|
+
|
59
|
+
[project, target]
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.check_sparkle_configuration_existence(project_path, workspace_path)
|
63
|
+
project, target = get_app_target(project_path, workspace_path)
|
64
|
+
info_plist_file = project.files.find { |f| f.path.end_with?('Info.plist') }
|
65
|
+
raise 'Info.plist not found in the project' unless info_plist_file
|
66
|
+
|
67
|
+
info_plist_path = File.join(Env.variables.project_directory_path, "#{target.name}/#{info_plist_file.path}")
|
68
|
+
info_plist = Xcodeproj::Plist.read_from_path(info_plist_path)
|
69
|
+
|
70
|
+
raise 'Info.plist does not contain the needed Sparkle configuration: SUFeedURL'.red if info_plist['SUFeedURL'].nil?
|
71
|
+
return unless info_plist['SUPublicEDKey'].nil?
|
72
|
+
|
73
|
+
raise 'Info.plist does not contain the needed Sparkle configuration: SUPublicEDKey'.red
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'securerandom'
|
3
|
+
require_relative 'packaging'
|
4
|
+
require_relative 'distribution'
|
5
|
+
require_relative 'helpers/build_directory_helpers'
|
6
|
+
require_relative 'helpers/commands_helpers'
|
7
|
+
require_relative 'helpers/keychain_helpers'
|
8
|
+
require_relative 'helpers/xcodeproj_helpers'
|
9
|
+
require_relative 'helpers/puts_helpers'
|
10
|
+
require_relative 'environment/environment'
|
11
|
+
|
12
|
+
def extract_options
|
13
|
+
options = {}
|
14
|
+
|
15
|
+
OptionParser.new do |opts|
|
16
|
+
opts.banner = 'Usage: automate-sparkle.rb [options]'
|
17
|
+
|
18
|
+
opts.on('--project-path PATH', 'Path to the Xcode project') do |path|
|
19
|
+
options[:project_path] = path
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on('--workspace-path PATH', 'Path to the Xcode workspace') do |path|
|
23
|
+
options[:workspace_path] = path
|
24
|
+
end
|
25
|
+
|
26
|
+
env_description = 'Environment to load (aka. local, production)'
|
27
|
+
env_description += ' it will look inside the Xcode project/workspace path'
|
28
|
+
env_description += ' for a file named `.autosparkle.env.<environment>`'
|
29
|
+
|
30
|
+
opts.on('--env ENVIRONMENT', env_description) do |env|
|
31
|
+
options[:env] = env
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on('--env-file PATH', 'Path to the environment file') do |path|
|
35
|
+
options[:env_file] = path
|
36
|
+
end
|
37
|
+
|
38
|
+
opts.on('-v', 'Enable verbose mode') do |verbose|
|
39
|
+
Env.verbose_enabled = verbose
|
40
|
+
end
|
41
|
+
end.parse!
|
42
|
+
|
43
|
+
# Check if both project path and environment are specified
|
44
|
+
unless (options[:workspace_path] || options[:project_path]) && (options[:env] || options[:env_file])
|
45
|
+
raise 'Xcode Project/Workspace path and Environment/Environment file are required.'
|
46
|
+
end
|
47
|
+
|
48
|
+
options
|
49
|
+
end
|
50
|
+
|
51
|
+
options = extract_options
|
52
|
+
Env.initialize(options)
|
53
|
+
|
54
|
+
Xcodeproj.check_sparkle_configuration_existence(options[:project_path], options[:workspace_path])
|
55
|
+
|
56
|
+
BuildDirectory.create_build_directory
|
57
|
+
|
58
|
+
puts "Automating the delivery of #{Env.variables.app_display_name} version #{Env.variables.marketing_version} (#{Env.variables.current_project_version})..."
|
59
|
+
dmg_path = nil
|
60
|
+
with_temporary_keychain do |keychain_info|
|
61
|
+
exported_app_path = Packaging.archive_and_sign(
|
62
|
+
keychain_info[:application_cert_name],
|
63
|
+
keychain_info[:application_team_id],
|
64
|
+
options
|
65
|
+
)
|
66
|
+
dmg_path = Packaging.create_and_sign_dmg(
|
67
|
+
exported_app_path,
|
68
|
+
keychain_info[:application_cert_name],
|
69
|
+
keychain_info[:keychain_path]
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
Distribution.upload_update(dmg_path)
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'helpers/build_directory_helpers'
|
4
|
+
require_relative 'helpers/constants'
|
5
|
+
require_relative 'helpers/commands_helpers'
|
6
|
+
require_relative 'helpers/dmg_helpers'
|
7
|
+
require_relative 'helpers/puts_helpers'
|
8
|
+
require_relative 'environment/environment'
|
9
|
+
|
10
|
+
# Packaging module to archive and sign the app
|
11
|
+
module Packaging
|
12
|
+
def self.archive_and_sign(
|
13
|
+
application_cert_name,
|
14
|
+
application_cert_team_id,
|
15
|
+
options
|
16
|
+
)
|
17
|
+
|
18
|
+
puts_title 'Archiving and signing the app'
|
19
|
+
app_archive_path = BuildDirectory.new_path("#{Env.variables.app_display_name}.xcarchive")
|
20
|
+
archive_command = 'xcodebuild clean analyze archive'
|
21
|
+
archive_command += " -scheme #{Env.variables.scheme}"
|
22
|
+
archive_command += " -archivePath '#{app_archive_path}'"
|
23
|
+
archive_command += " CODE_SIGN_IDENTITY='#{application_cert_name}'"
|
24
|
+
archive_command += " DEVELOPMENT_TEAM='#{application_cert_team_id}'"
|
25
|
+
archive_command += " -configuration #{Env.variables.configuration}"
|
26
|
+
archive_command += " OTHER_CODE_SIGN_FLAGS='--timestamp --options=runtime'"
|
27
|
+
|
28
|
+
archive_command += if options[:workspace_path]
|
29
|
+
" -workspace #{options[:workspace_path]}"
|
30
|
+
else
|
31
|
+
" -project #{options[:project_path]}"
|
32
|
+
end
|
33
|
+
|
34
|
+
execute_command(archive_command)
|
35
|
+
|
36
|
+
puts_title 'Exporting the app'
|
37
|
+
exported_app_path = export_app(app_archive_path, application_cert_name, application_cert_team_id)
|
38
|
+
|
39
|
+
puts_title 'Signing the Sparkle framework'
|
40
|
+
sign_sparkle_framework(
|
41
|
+
exported_app_path,
|
42
|
+
application_cert_name
|
43
|
+
)
|
44
|
+
|
45
|
+
exported_app_path
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.export_app(app_archive_path, application_cert_name, team_id)
|
49
|
+
puts_if_verbose 'Exporting the app...'
|
50
|
+
export_options = {
|
51
|
+
signingStyle: 'automatic',
|
52
|
+
method: 'developer-id',
|
53
|
+
teamID: team_id,
|
54
|
+
signingCertificate: application_cert_name,
|
55
|
+
destination: 'export'
|
56
|
+
}
|
57
|
+
|
58
|
+
export_options_file = BuildDirectory.new_file('exportOptions.plist')
|
59
|
+
plist_content = export_options.to_plist
|
60
|
+
export_options_file.write(plist_content)
|
61
|
+
export_options_file.close
|
62
|
+
|
63
|
+
puts_if_verbose "exportOptions.plist:\n#{plist_content}"
|
64
|
+
|
65
|
+
# Create temporary directory for the exported app
|
66
|
+
export_app_dir_path = BuildDirectory.new_directory('exported_app')
|
67
|
+
|
68
|
+
# Construct the export command
|
69
|
+
export_command = "xcodebuild -exportArchive -archivePath \"#{app_archive_path}\""
|
70
|
+
export_command += " -exportPath \"#{export_app_dir_path}\""
|
71
|
+
export_command += " -exportOptionsPlist \"#{export_options_file.path}\""
|
72
|
+
execute_command(export_command)
|
73
|
+
|
74
|
+
"#{export_app_dir_path}/#{Env.variables.app_display_name}.app"
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.create_and_sign_dmg(exported_app_path, application_cert_name, keychain_path)
|
78
|
+
puts_title "Creating #{Env.variables.app_display_name}.dmg"
|
79
|
+
puts_if_verbose 'Creating a DMG for the app...'
|
80
|
+
dmg_path = DMG.create(exported_app_path)
|
81
|
+
|
82
|
+
# Sign the DMG
|
83
|
+
puts_if_verbose 'Signing the DMG...'
|
84
|
+
execute_command("codesign --force --sign \"#{application_cert_name}\" --timestamp --options runtime \"#{dmg_path}\"")
|
85
|
+
|
86
|
+
# Notarize the DMG
|
87
|
+
puts_if_verbose 'Notarizing the DMG...'
|
88
|
+
execute_command("xcrun notarytool submit \"#{dmg_path}\" --keychain-profile \"#{Constants::NOTARIZE_KEYCHAIN_PROFILE}\" --keychain \"#{keychain_path}\" --wait")
|
89
|
+
|
90
|
+
# Staple the notarization ticket
|
91
|
+
puts_if_verbose 'Stapling the notarization ticket...'
|
92
|
+
execute_command("xcrun stapler staple \"#{dmg_path}\"")
|
93
|
+
|
94
|
+
dmg_path
|
95
|
+
end
|
96
|
+
|
97
|
+
def self.sign_sparkle_framework(exported_app_path, application_cert_name)
|
98
|
+
sparkle_auto_update_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/AutoUpdate"
|
99
|
+
codesign('Signing Sparkle AutoUpdate...', application_cert_name, sparkle_auto_update_path)
|
100
|
+
|
101
|
+
sparkle_updater_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/Updater.app"
|
102
|
+
codesign('Signing Sparkle Updater...', application_cert_name, sparkle_updater_path)
|
103
|
+
|
104
|
+
sparkle_installer_xpc_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/XPCServices/Installer.xpc/Contents/MacOS/Installer"
|
105
|
+
codesign('Signing Sparkle Installer XPC Service...', application_cert_name, sparkle_installer_xpc_path)
|
106
|
+
|
107
|
+
sparkle_downloader_xpc_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
|
108
|
+
codesign('Signing Sparkle Downloader XPC Service...', application_cert_name, sparkle_downloader_xpc_path)
|
109
|
+
|
110
|
+
sparkle_framework_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework"
|
111
|
+
codesign('Signing Sparkle framework...', application_cert_name, sparkle_framework_path)
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.codesign(title, application_cert_name, path)
|
115
|
+
puts_if_verbose title
|
116
|
+
execute_command("codesign -f -o runtime --timestamp -s \"#{application_cert_name}\" \"#{path}\"")
|
117
|
+
end
|
118
|
+
end
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,18 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
set -e
|
3
|
+
set -o pipefail
|
4
|
+
if [ "$#" -ne 2 ]; then
|
5
|
+
echo "Usage: $0 update_archive_file dsa_priv.pem"
|
6
|
+
echo "This is an old DSA signing script for deprecated DSA keys."
|
7
|
+
echo "Do not use this for new applications."
|
8
|
+
exit 1
|
9
|
+
fi
|
10
|
+
openssl=/usr/bin/openssl
|
11
|
+
version=`$openssl version`
|
12
|
+
if [[ $version =~ "OpenSSL 0.9" ]]; then
|
13
|
+
# pre-10.13 system: Fall back to OpenSSL DSS1 digest because it does not like the -sha1 option
|
14
|
+
$openssl dgst -sha1 -binary < "$1" | $openssl dgst -dss1 -sign "$2" | $openssl enc -base64
|
15
|
+
else
|
16
|
+
# 10.13 and later: Use LibreSSL SHA1 digest
|
17
|
+
$openssl dgst -sha1 -binary < "$1" | $openssl dgst -sha1 -sign "$2" | $openssl enc -base64
|
18
|
+
fi
|
Binary file
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aws-sdk-s3'
|
4
|
+
require_relative 'storage'
|
5
|
+
require_relative '../environment/environment'
|
6
|
+
require_relative '../environment/variables/aws_s3_environment_variables'
|
7
|
+
require_relative '../helpers/puts_helpers'
|
8
|
+
|
9
|
+
# AwsS3Storage class to upload the updated version of the app
|
10
|
+
class AwsS3Storage
|
11
|
+
include Storage
|
12
|
+
def initialize
|
13
|
+
@variables = AwsS3EnvironmentVariables.new
|
14
|
+
|
15
|
+
credentials = Aws::Credentials.new(@variables.access_key, @variables.secret_access_key)
|
16
|
+
Aws.config.update({
|
17
|
+
region: @variables.region,
|
18
|
+
credentials: credentials
|
19
|
+
})
|
20
|
+
s3 = Aws::S3::Resource.new
|
21
|
+
@bucket = s3.bucket(@variables.bucket_name)
|
22
|
+
super
|
23
|
+
end
|
24
|
+
|
25
|
+
def upload(pkg_path, appcast_path)
|
26
|
+
appcast_object = @bucket.object('appcast.xml')
|
27
|
+
appcast_object.upload_file(appcast_path)
|
28
|
+
|
29
|
+
puts_if_verbose "Uploaded the appcast file to the bucket #{@variables.bucket_name}"
|
30
|
+
|
31
|
+
version_object = @bucket.object(update_file_destination_path)
|
32
|
+
version_object.upload_file(pkg_path)
|
33
|
+
|
34
|
+
puts_if_verbose "Uploaded version #{Env.variables.marketing_version} to the bucket #{@variables.bucket_name}"
|
35
|
+
rescue StandardError => e
|
36
|
+
raise "Failed to upload file: #{e.message}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def deployed_appcast_xml
|
40
|
+
appcast_object = @bucket.object('appcast.xml')
|
41
|
+
appcast_object.get.body.read
|
42
|
+
rescue StandardError
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is used as an interface for the storage classes
|
4
|
+
# It contains the methods that the storage classes should implement
|
5
|
+
# A storage can be for example Google Drive, Azure Blob Storage, AWS S3, etc.
|
6
|
+
# The update_file_destination_path method will return the path where the file should be uploaded
|
7
|
+
# The upload method is used to upload the package and the appcast file to the storage
|
8
|
+
module Storage
|
9
|
+
def update_file_destination_path
|
10
|
+
"#{Env.variables.marketing_version}/#{Env.variables.app_display_name}.dmg"
|
11
|
+
end
|
12
|
+
|
13
|
+
def upload(pkg_path, appcast_path)
|
14
|
+
raise NotImplementedError, "This #{self.class} cannot respond to:"
|
15
|
+
end
|
16
|
+
|
17
|
+
def deployed_appcast_xml
|
18
|
+
raise NotImplementedError, "This #{self.class} cannot respond to:"
|
19
|
+
end
|
20
|
+
end
|
metadata
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: autosparkle
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.8
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hadi Dbouk
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-07-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: aws-sdk-s3
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.152.3
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.152.3
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: colorize
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.1.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 1.1.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: dotenv
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 3.1.2
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 3.1.2
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: nokogiri
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 1.16.6
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 1.16.6
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: xcodeproj
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 1.24.0
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 1.24.0
|
83
|
+
description: Autosparkle is a command-line tool that automates the delivery of macOS
|
84
|
+
apps outside the App Store. It is designed to be used in CI/CD pipelines to automate
|
85
|
+
the process of archiving, signing, notarizing, and distributing macOS apps.
|
86
|
+
email:
|
87
|
+
- hadiidbouk@gmail.com
|
88
|
+
executables:
|
89
|
+
- autosparkle
|
90
|
+
extensions: []
|
91
|
+
extra_rdoc_files: []
|
92
|
+
files:
|
93
|
+
- README.md
|
94
|
+
- bin/autosparkle
|
95
|
+
- lib/autosparkle/distribution.rb
|
96
|
+
- lib/autosparkle/environment/environment.rb
|
97
|
+
- lib/autosparkle/environment/variables/aws_s3_environment_variables.rb
|
98
|
+
- lib/autosparkle/environment/variables/base_environment_variables.rb
|
99
|
+
- lib/autosparkle/environment/variables/default_environment_variables.rb
|
100
|
+
- lib/autosparkle/helpers/appcast_helpers.rb
|
101
|
+
- lib/autosparkle/helpers/build_directory_helpers.rb
|
102
|
+
- lib/autosparkle/helpers/commands_helpers.rb
|
103
|
+
- lib/autosparkle/helpers/constants.rb
|
104
|
+
- lib/autosparkle/helpers/dmg_helpers.rb
|
105
|
+
- lib/autosparkle/helpers/keychain_helpers.rb
|
106
|
+
- lib/autosparkle/helpers/puts_helpers.rb
|
107
|
+
- lib/autosparkle/helpers/version_helpers.rb
|
108
|
+
- lib/autosparkle/helpers/xcodeproj_helpers.rb
|
109
|
+
- lib/autosparkle/main.rb
|
110
|
+
- lib/autosparkle/packaging.rb
|
111
|
+
- lib/autosparkle/sparkle/BinaryDelta
|
112
|
+
- lib/autosparkle/sparkle/generate_appcast
|
113
|
+
- lib/autosparkle/sparkle/generate_keys
|
114
|
+
- lib/autosparkle/sparkle/old_dsa_scripts/sign_update
|
115
|
+
- lib/autosparkle/sparkle/sign_update
|
116
|
+
- lib/autosparkle/storages/aws_s3_storage.rb
|
117
|
+
- lib/autosparkle/storages/storage.rb
|
118
|
+
homepage: https://github.com/hadiidbouk/autosparkle
|
119
|
+
licenses:
|
120
|
+
- MIT
|
121
|
+
metadata:
|
122
|
+
homepage_uri: https://github.com/hadiidbouk/autosparkle
|
123
|
+
source_code_uri: https://github.com/hadiidbouk/autosparkle
|
124
|
+
changelog_uri: https://github.com/hadiidbouk/autosparkle/blob/main/CHANGELOG.md
|
125
|
+
rubygems_mfa_required: 'true'
|
126
|
+
post_install_message: |+
|
127
|
+
###################################### AUTOSPAKRLE ######################################
|
128
|
+
|
129
|
+
Thank you for installing AutoSparkle!
|
130
|
+
|
131
|
+
To use AutoSparkle in your terminal, you need to update your shell configuration files.
|
132
|
+
Add the following line to your shell configuration file (e.g., ~/.bashrc, ~/.zshrc):
|
133
|
+
export PATH="<<YOUR_GEMS_DIR>>/autosparkle-x.x.x/bin:$PATH"
|
134
|
+
|
135
|
+
After updating your shell configuration file, run the following command:
|
136
|
+
source ~/.bashrc or source ~/.zshrc
|
137
|
+
|
138
|
+
For more information, visit the AutoSparkle GitHub repository:
|
139
|
+
https://github.com/hadiidbouk/autosparkle
|
140
|
+
|
141
|
+
Enjoy using AutoSparkle!
|
142
|
+
|
143
|
+
#########################################################################################
|
144
|
+
|
145
|
+
rdoc_options: []
|
146
|
+
require_paths:
|
147
|
+
- lib
|
148
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 2.5.0
|
153
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - ">="
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
requirements: []
|
159
|
+
rubygems_version: 3.5.9
|
160
|
+
signing_key:
|
161
|
+
specification_version: 4
|
162
|
+
summary: Automate macOS app delivery outside the App Store
|
163
|
+
test_files: []
|