autosparkle 0.0.8
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 +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
|
+

|
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: []
|