s3_asset_deploy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 72c4c1220b3051d9189389b3b956d509506380ae4bef112212480e1da9ac75e4
4
+ data.tar.gz: b9fbd6e07c1cced6e5a20d1cb72338093eec260a540a434ee3cc60012e680549
5
+ SHA512:
6
+ metadata.gz: cb3a963f7a47375414e9b51b1b3f525a890924995b380d193151e9a77193a7ddec8f1f50864fc60b37c5c768586fefa76b1335d3e849918572ec4b7be3af866a
7
+ data.tar.gz: d240fe42ae5578cf136f1dac4b637165603836f8cdceb7bf8d820cd6808048d78b50b4dbbe235f3e1678aedbe8f36a6b76f1310a5d64f0605622650cca2d01b1
@@ -0,0 +1,14 @@
1
+ version: 2.1
2
+ orbs:
3
+ ruby: circleci/ruby@1.1.2
4
+
5
+ jobs:
6
+ build:
7
+ parallelism: 1
8
+ docker:
9
+ - image: circleci/ruby:2.6.3-stretch
10
+ executor: ruby/default
11
+ steps:
12
+ - checkout
13
+ - ruby/install-deps
14
+ - ruby/rspec-test
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # Changelog
@@ -0,0 +1,84 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our Standards
10
+
11
+ Examples of behavior that contributes to a positive environment for our community include:
12
+
13
+ * Demonstrating empathy and kindness toward other people
14
+ * Being respectful of differing opinions, viewpoints, and experiences
15
+ * Giving and gracefully accepting constructive feedback
16
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17
+ * Focusing on what is best not just for us as individuals, but for the overall community
18
+
19
+ Examples of unacceptable behavior include:
20
+
21
+ * The use of sexualized language or imagery, and sexual attention or
22
+ advances of any kind
23
+ * Trolling, insulting or derogatory comments, and personal or political attacks
24
+ * Public or private harassment
25
+ * Publishing others' private information, such as a physical or email
26
+ address, without their explicit permission
27
+ * Other conduct which could reasonably be considered inappropriate in a
28
+ professional setting
29
+
30
+ ## Enforcement Responsibilities
31
+
32
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33
+
34
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35
+
36
+ ## Scope
37
+
38
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39
+
40
+ ## Enforcement
41
+
42
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at contact@loomly.com. All complaints will be reviewed and investigated promptly and fairly.
43
+
44
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45
+
46
+ ## Enforcement Guidelines
47
+
48
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49
+
50
+ ### 1. Correction
51
+
52
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53
+
54
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55
+
56
+ ### 2. Warning
57
+
58
+ **Community Impact**: A violation through a single incident or series of actions.
59
+
60
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61
+
62
+ ### 3. Temporary Ban
63
+
64
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65
+
66
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67
+
68
+ ### 4. Permanent Ban
69
+
70
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71
+
72
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
73
+
74
+ ## Attribution
75
+
76
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77
+ available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78
+
79
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80
+
81
+ [homepage]: https://www.contributor-covenant.org
82
+
83
+ For answers to common questions about this code of conduct, see the FAQ at
84
+ https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in s3_asset_deploy.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,72 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ s3_asset_deploy (0.1.0)
5
+ aws-sdk-s3 (~> 1.0)
6
+ mime-types (~> 3.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ aws-eventstream (1.1.0)
12
+ aws-partitions (1.422.0)
13
+ aws-sdk-core (3.111.2)
14
+ aws-eventstream (~> 1, >= 1.0.2)
15
+ aws-partitions (~> 1, >= 1.239.0)
16
+ aws-sigv4 (~> 1.1)
17
+ jmespath (~> 1.0)
18
+ aws-sdk-kms (1.41.0)
19
+ aws-sdk-core (~> 3, >= 3.109.0)
20
+ aws-sigv4 (~> 1.1)
21
+ aws-sdk-s3 (1.87.0)
22
+ aws-sdk-core (~> 3, >= 3.109.0)
23
+ aws-sdk-kms (~> 1)
24
+ aws-sigv4 (~> 1.1)
25
+ aws-sigv4 (1.2.2)
26
+ aws-eventstream (~> 1, >= 1.0.2)
27
+ byebug (11.1.3)
28
+ coderay (1.1.3)
29
+ diff-lcs (1.4.4)
30
+ jmespath (1.4.0)
31
+ method_source (1.0.0)
32
+ mime-types (3.3.1)
33
+ mime-types-data (~> 3.2015)
34
+ mime-types-data (3.2020.1104)
35
+ pry (0.13.1)
36
+ coderay (~> 1.1)
37
+ method_source (~> 1.0)
38
+ pry-byebug (3.9.0)
39
+ byebug (~> 11.0)
40
+ pry (~> 0.13.0)
41
+ rake (13.0.3)
42
+ rspec (3.10.0)
43
+ rspec-core (~> 3.10.0)
44
+ rspec-expectations (~> 3.10.0)
45
+ rspec-mocks (~> 3.10.0)
46
+ rspec-core (3.10.1)
47
+ rspec-support (~> 3.10.0)
48
+ rspec-expectations (3.10.1)
49
+ diff-lcs (>= 1.2.0, < 2.0)
50
+ rspec-support (~> 3.10.0)
51
+ rspec-mocks (3.10.2)
52
+ diff-lcs (>= 1.2.0, < 2.0)
53
+ rspec-support (~> 3.10.0)
54
+ rspec-support (3.10.2)
55
+ rspec_junit_formatter (0.4.1)
56
+ rspec-core (>= 2, < 4, != 2.12.0)
57
+ timecop (0.9.2)
58
+
59
+ PLATFORMS
60
+ ruby
61
+
62
+ DEPENDENCIES
63
+ pry (~> 0.13)
64
+ pry-byebug (~> 3.9)
65
+ rake (~> 13.0)
66
+ rspec (~> 3.0)
67
+ rspec_junit_formatter (~> 0.4)
68
+ s3_asset_deploy!
69
+ timecop (~> 0.9)
70
+
71
+ BUNDLED WITH
72
+ 2.2.7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Loomly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # S3AssetDeploy
2
+
3
+ [![CircleCI](https://circleci.com/gh/Loomly/s3_asset_deploy.svg?style=shield)](https://circleci.com/gh/Loomly/s3_asset_deploy)
4
+
5
+ During rolling deploys to our web instances, this is what we use at
6
+ [Loomly](https://www.loomly.com) to safely deploy our web assets to S3 to be served via Cloudfront.
7
+ This gem is designed to upload and clean unneeded assets from S3 in a safe manner such that older
8
+ versions or recently removed assets are kept on S3 during the rolling deploy process.
9
+ It also maintains a version limit and TTL (time-to-live) on assets to avoid deleting
10
+ recent and outdated versions (up to a limit) or those that have been recently removed.
11
+
12
+ ## Background
13
+
14
+ At the very beginning, we were serving our assets from our webservers. This isn't ideal for many reasons, but one big one is that it's problematic during rolling deploys where you temporarily have some web servers with new assets and some with old assets during the deploy. When round-robbining requests to instances behind a load balancer, this can result in requests for assets hitting web servers that don't have the asset being requested (either the new or the old depending on what web server and what's being requested). One way to fix this problem is to serve your assets from a CDN and keep both old and new versions of assets available on the CDN during the deploy process. So we decided to serve our assets from Cloudfront, backed by S3. In order to upload our assets to S3 during our deploy process, we started using [`asset_sync`](https://github.com/AssetSync/asset_sync). `asset_sync` served us well for quite some time, but our needs started to diverge a bit. Namely, `asset_sync`:
15
+
16
+ - Depends on the [`fog`](https://github.com/fog/fog) gem which was an extra dependency we didn't need since we already had the [`aws-sdk-s3`](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3.html) gem as a dependency.
17
+ - Uses a global configuration, which made it difficult to deploy to different S3 buckets depending on the environment (development, staging, production, etc.).
18
+ - Didn't have a way to remove or retire outdated or old assets from storage (in this case S3).
19
+
20
+ We took inspiration from `asset_sync` and ended up writing our own library inside our Rails app. We figured this could be useful to others, so we then moved it to an open source gem. While Rails is a "first-class citizen", this gem can be used with other frameworks by writing your own `S3AssetDeploy::LocalAssetCollector`. See the `Usage` section below for more details.
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem "s3_asset_deploy"
28
+ ```
29
+
30
+ And then execute:
31
+
32
+ $ bundle install
33
+
34
+ Or install it yourself as:
35
+
36
+ $ gem install s3_asset_deploy
37
+
38
+ ## Usage
39
+
40
+ Before using `S3AssetDeploy` you want to make sure to compile your assets. Assets must also be compiled using [fingerprinting](https://guides.rubyonrails.org/asset_pipeline.html#what-is-fingerprinting-and-why-should-i-care-questionmark) for things to work correctly. By default, `S3AssetDeploy` works with Rails and will find your locally compiled assets after running `rake assets:precompile`. Once you've compiled your assets, you can deploy them with:
41
+
42
+
43
+ ```ruby
44
+ manager = S3AssetDeploy::Manager("my-s3-bucket")
45
+ manager.deploy do
46
+ # Perform deploy to web instances in this block
47
+ end
48
+ ```
49
+
50
+ `S3AssetDeploy::Manager#deploy` will perform the following steps:
51
+ - Upload your assets to the S3 bucket you specify
52
+ - Yield to the block
53
+ - Clean old versions assets or removed assets
54
+
55
+ Since it's yielding to the block after uploading, but before cleaning, the block is an ideal place to perform a deploy, especially if it's a rolling deploy across multiple servers. If you want to perform an upload or a clean without using `#deploy`, you can call `#upload` or `#clean` directly. For more configuration options, see below.
56
+
57
+ ### Initializing [`S3AssetDeploy::Manager`](https://github.com/Loomly/s3_asset_deploy/blob/main/lib/s3_asset_deploy/manager.rb)
58
+ You'll need to initialize `S3AssetDeploy::Manager` with an S3 bucket name and **optionally**:
59
+
60
+ - **s3_client_options** (Hash) -> A hash that is passed directly to [`Aws::S3::Client#initialize`](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#initialize-instance_method) to configure the S3 client. By default the region is set to `us-east-1`.
61
+ - **logger** (Logger) -> A custom logger. By default things are logged to `STDOUT`.
62
+ - **local_asset_collector** (S3AssetDeploy::LocalAssetCollector) -> A custom instance of `S3AssetDeploy::LocalAssetCollector`. This allows you to customize how locally compiled assets are collected.
63
+ - **upload_options** (Hash) -> A hash consisting of options that are passed directly to [`Aws::S3::Client#put_object`](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_object-instance_method) when each asset is uploaded. By default `acl` is set to `public-read` and `cache_control` is set to `public, max-age=31536000`.
64
+ - **remove_fingerprint** (Lambda) -> Lambda for overriding how fingerprints are removed from asset paths. Fingerprints need to be removed during the cleaning process in order to group versions of the same file. If no Lambda is provided, [`S3AssetDeploy::AssetHelper.remove_fingerprint`](https://github.com/Loomly/s3_asset_deploy/blob/main/lib/s3_asset_deploy/asset_helper.rb#L8) is used by default.
65
+
66
+ Here's an example:
67
+
68
+ ```ruby
69
+ manager = S3AssetDeploy::Manager.new(
70
+ "mybucket",
71
+ s3_client_options: { region: "us-west-1", profile: "my-aws-profile" },
72
+ logger: Logger.new(STDOUT),
73
+ remove_fingerprint: ->(path) { path.gsub("-myfingerprint", "") }
74
+ )
75
+ ```
76
+
77
+ ### Deploying Assets
78
+ Once you have an instance of `S3AssetDeploy::Manager`, you can deploy your precompiled assets with `S3AssetDeploy::Manager#deploy`:
79
+
80
+ ```ruby
81
+ manager.deploy(version_limit: 2, version_ttl: 3600, removed_ttl: 172800) do
82
+ # Perform deploy to web instances in this block
83
+ end
84
+ ```
85
+
86
+ This will upload new assets and perform a clean, which deletes removed assets and old versions from your bucket after the block is executed.
87
+ With the arguments used above, the clean process will keep the latest version on S3, two of the most recent older versions (`version_limit`), and any versions created within the last hour (`version_ttl`).
88
+ If you there are assets that are in your S3 bucket but no longer included in your locally compiled bundle, they will be deleted from S3 using the `removed_ttl` (after two days in the case above). This process uses [S3 object tagging](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_object_tagging-instance_method) to track `removed_at` timestamps. Here are a list of all the options you can pass to `#deploy`:
89
+
90
+ - **version_limit** (Integer) -> Max number of older versions of an asset to keep around. Note that this limit does **not** include the current version. Therefore, setting this to 0 will keep the current version and delete any older versions. Default is `2`.
91
+ - **version_ttl** (Integer) -> Number of seconds to keep newly uploaded versions before deleting according to `version_limit`. If an older version is still within the `version_ttl`, it will be kept on S3 even if the total number of older versions is beyond `version_limit`. Default is `3600`.
92
+ - **removed_ttl** (Integer) -> Number of seconds to keep assets on S3 that have been removed from your compiled set of assets. If the age of a removed asset expires according to `removed_ttl`, it will be deleted on the next deploy. Default is `172800`.
93
+ - **clean** (Boolean) -> Skip the clean process during a deploy. Default is `true`.
94
+ - **dry_run** (Boolean) -> Run deploy in read-only mode. This is helpful for debugging purposes and seeing plan of what would happen without performing any writes or deletes. Default is `false`.
95
+
96
+ `S3AssetDeploy::Manager#deploy` performs its work by delegating to `S3AssetDeploy#upload` and `S3AssetDeploy#clean`, which you can call yourself if you need some more control.
97
+
98
+
99
+ ```ruby
100
+ # Upload new assets
101
+ manager.upload
102
+
103
+ # Delete old versions and removed assets from S3
104
+ manager.clean
105
+ ```
106
+
107
+ `S3AssetDeploy::Manager#deploy` and `S3AssetDeploy::Manager#clean` both accept `dry_run` as a keyword argument.
108
+ `S3AssetDeploy::Manager#clean` also accepts `version_limit`, `version_ttl`, and `removed_ttl` just like `S3AssetDeploy::Manager#deploy`.
109
+
110
+
111
+ ## Customizing local asset collection
112
+ By default, `S3AssetDeploy::Manager` will use [`S3AssetDeploy::RailsLocalAssetCollector`](https://github.com/Loomly/s3_asset_deploy/blob/main/lib/s3_asset_deploy/rails_local_asset_collector.rb) to collect locally compiled assets. This will use the `Sprockets::Manifest` and `Webpacker` config (if `Webpacker` is installed) to locate the compiled assets. `S3AssetDeploy::RailsLocalAssetCollector` inherits from the [`S3AssetDeploy::LocalAssetCollector`](https://github.com/Loomly/s3_asset_deploy/blob/main/lib/s3_asset_deploy/local_asset_collector.rb) base class. You can completely customize how your local assets are collected for deploys by creating your own class that inherits from `S3AssetDeploy::LocalAssetCollector` and passing it into the manager. You'll want override `S3AssetDeploy::LocalAssetCollector#assets` in your custom collector such that it returns an array of `S3AssetDeploy::LocalAsset` instances. Here's a basic example:
113
+
114
+
115
+ ```ruby
116
+ class MyCustomLocalAssetCollector < S3AssetDeploy::LocalAssetCollector
117
+ def assets
118
+ # Override this method to return an array of your locally compiled assets
119
+ # as instances of S3AssetDeploy::LocalAsset
120
+ [S3AssetDeploy::LocalAsset.new("path-to-my-asset.jpg")]
121
+ end
122
+ end
123
+
124
+ manager = S3AssetDeploy::Manager.new(
125
+ "mybucket",
126
+ local_asset_collector: MyCustomLocalAssetCollector.new
127
+ )
128
+ ```
129
+
130
+ ## Dry run
131
+ As mentioned above, you can run operations in "dry" mode by passing `dry_run: true`.
132
+ This will skip any write or delete operations and only perform read opeartions with log output.
133
+ This is helpful for debugging or planning purposes.
134
+
135
+ ```ruby
136
+ > manager = S3AssetDeploy::Manager("my-s3-bucket")
137
+ > manager.deploy(dry_run: true)
138
+
139
+ I, [2021-02-17T16:12:23.703677 #65335] INFO -- : S3AssetDeploy::Manager: Cleaning assets from test-bucket S3 bucket. Dry run: true
140
+ I, [2021-02-17T16:12:23.703677 #65335] INFO -- : S3AssetDeploy::Manager: Determining how long ago assets/file-2-34567.jpg was removed - removed on 2021-02-15 23:12:22 UTC (172801.703677 seconds ago)
141
+ I, [2021-02-17T16:12:23.703677 #65335] INFO -- : S3AssetDeploy::Manager: Determining how long ago assets/file-3-9876666.jpg was removed - removed on 2021-02-15 23:12:24 UTC (172799.703677 seconds ago)
142
+ ```
143
+
144
+ ## AWS IAM Permissions
145
+ `S3AsetDeploy` requires the following AWS IAM permissions:
146
+
147
+ ```json
148
+ "Statement": [
149
+ {
150
+ "Action": [
151
+ "s3:ListBucket",
152
+ "s3:PutObject*",
153
+ "s3:DeleteObject"
154
+ ],
155
+ "Effect": "Allow",
156
+ "Resource": [
157
+ "arn:aws:s3:::#{YOUR_BUCKET}",
158
+ "arn:aws:s3:::#{YOUR_BUCKET}/*"
159
+ ]
160
+ }
161
+ ]
162
+ ```
163
+
164
+ ## Development
165
+
166
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
167
+
168
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
169
+
170
+ ## Contributing
171
+
172
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Loomly/s3_asset_deploy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/Loomly/s3_asset_deploy/blob/main/CODE_OF_CONDUCT.md).
173
+
174
+ ## License
175
+
176
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
177
+
178
+ ## Code of Conduct
179
+
180
+ Everyone interacting in the S3AssetDeploy project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/Loomly/s3_asset_deploy/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "s3_asset_deploy"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "s3_asset_deploy/version"
4
+ require "s3_asset_deploy/manager"
5
+
6
+ module S3AssetDeploy
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mime/types"
4
+
5
+ class S3AssetDeploy::AssetHelper
6
+ FINGERPRINTED_ASSET_REGEX = /\A(.*)-([[:alnum:]]+)((?:(?:\.[[:alnum:]]+))+)\z/.freeze
7
+
8
+ def self.remove_fingerprint(path)
9
+ match_data = path.match(FINGERPRINTED_ASSET_REGEX)
10
+ return asset_path unless match_data
11
+ "#{match_data[1]}#{match_data[3]}"
12
+ end
13
+
14
+ def self.mime_type_for_path(path)
15
+ extension = File.extname(path)[1..-1]
16
+ return "application/json" if extension == "map"
17
+ MIME::Types.type_for(extension).first
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class S3AssetDeploy::Error < StandardError
4
+ end
5
+
6
+ class S3AssetDeploy::DuplicateAssetsError < S3AssetDeploy::Error
7
+ def initialize(msg = "Duplicate precompiled assets detected. Please make sure there are no duplicate precompiled assets in the public dir.")
8
+ super
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "s3_asset_deploy/asset_helper"
4
+
5
+ class S3AssetDeploy::LocalAsset
6
+ attr_reader :path
7
+
8
+ def initialize(path, remove_fingerprint: nil)
9
+ @path = path
10
+ @remove_fingerprint = remove_fingerprint
11
+ end
12
+
13
+ def original_path
14
+ @original_path ||=
15
+ if @remove_fingerprint
16
+ @remove_fingerprint.call(path)
17
+ else
18
+ S3AssetDeploy::AssetHelper.remove_fingerprint(path)
19
+ end
20
+ end
21
+
22
+ def full_path
23
+ File.join(ENV["PWD"], "public", path)
24
+ end
25
+
26
+ def mime_type
27
+ S3AssetDeploy::AssetHelper.mime_type_for_path(path).to_s
28
+ end
29
+
30
+ def ==(other_asset)
31
+ path == other_asset.path
32
+ end
33
+
34
+ def to_s
35
+ path
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class S3AssetDeploy::LocalAssetCollector
4
+ def initialize(remove_fingerprint: nil)
5
+ @remove_fingerprint = remove_fingerprint
6
+ end
7
+
8
+ def assets
9
+ []
10
+ end
11
+
12
+ def asset_paths
13
+ assets.map(&:path)
14
+ end
15
+
16
+ def asset_map
17
+ assets.map do |asset|
18
+ [asset.original_path, asset]
19
+ end.to_h
20
+ end
21
+
22
+ def original_asset_paths
23
+ assets.map(&:original_path)
24
+ end
25
+
26
+ def full_file_path(asset_path)
27
+ asset_path
28
+ end
29
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "time"
5
+ require "aws-sdk-s3"
6
+ require "s3_asset_deploy/errors"
7
+ require "s3_asset_deploy/removal_manifest"
8
+ require "s3_asset_deploy/rails_local_asset_collector"
9
+ require "s3_asset_deploy/remote_asset_collector"
10
+
11
+ class S3AssetDeploy::Manager
12
+ attr_reader :bucket_name, :logger, :local_asset_collector, :remote_asset_collector
13
+
14
+ def initialize(bucket_name, s3_client_options: {}, logger: nil, local_asset_collector: nil, upload_options: {}, remove_fingerprint: nil)
15
+ @bucket_name = bucket_name.to_s
16
+ @logger = logger || Logger.new(STDOUT)
17
+
18
+ @s3_client_options = {
19
+ region: "us-east-1",
20
+ logger: @logger
21
+ }.merge(s3_client_options)
22
+ @upload_options = upload_options
23
+
24
+ @local_asset_collector = local_asset_collector || S3AssetDeploy::RailsLocalAssetCollector.new(remove_fingerprint: remove_fingerprint)
25
+ @remote_asset_collector = S3AssetDeploy::RemoteAssetCollector.new(
26
+ bucket_name,
27
+ s3_client_options: @s3_client_options,
28
+ remove_fingerprint: remove_fingerprint
29
+ )
30
+ end
31
+
32
+ def removal_manifest
33
+ @removal_manifest ||= S3AssetDeploy::RemovalManifest.new(
34
+ bucket_name,
35
+ s3_client_options: @s3_client_options
36
+ )
37
+ end
38
+
39
+ def local_assets_to_upload
40
+ remote_asset_paths = remote_asset_collector.asset_paths
41
+ local_asset_collector.assets.reject { |asset| remote_asset_paths.include?(asset.path) }
42
+ end
43
+
44
+ def upload(dry_run: false)
45
+ verify_no_duplicate_assets!
46
+
47
+ removal_manifest.load
48
+ assets_to_upload = local_assets_to_upload
49
+
50
+ (removal_manifest.keys & local_asset_collector.asset_paths).each do |path|
51
+ log "#{path} has been re-added. Deleting from removal manifest."
52
+ removal_manifest.delete(path) unless dry_run
53
+ end
54
+
55
+ uploaded_assets = []
56
+ assets_to_upload.each do |asset|
57
+ next unless File.file?(asset.full_path)
58
+ log "Uploading #{asset.path}..."
59
+ upload_asset(asset) unless dry_run
60
+ uploaded_assets << asset.path
61
+ end
62
+
63
+ removal_manifest.save unless dry_run
64
+ remote_asset_collector.clear_cache
65
+ uploaded_assets
66
+ end
67
+
68
+ # Cleanup old assets on S3. By default it will
69
+ # keep the latest version, 2 older versions (version_limit) and any created within the past hour (version_ttl).
70
+ # When assets are removed completely, they are tagged with a removed_at timestamp
71
+ # and eventually deleted based on the removed_ttl.
72
+ def clean(version_limit: 2, version_ttl: 3600, removed_ttl: 172800, dry_run: false)
73
+ verify_no_duplicate_assets!
74
+
75
+ version_ttl = version_ttl.to_i
76
+ removed_ttl = removed_ttl.to_i
77
+
78
+ log "Cleaning assets from #{bucket_name} S3 bucket. Dry run: #{dry_run}."
79
+ s3_keys_to_delete = []
80
+
81
+ unless local_assets_to_upload.empty?
82
+ log "WARNING: Please upload latest asset versions to remote host before cleaning."
83
+ return s3_keys_to_delete
84
+ end
85
+
86
+ removal_manifest.load
87
+ local_asset_map = local_asset_collector.asset_map
88
+
89
+ remote_asset_collector.grouped_assets.each do |original_path, versions|
90
+ current_asset = local_asset_map[original_path]
91
+
92
+ # Remove current asset version from the list
93
+ versions_to_delete = versions.reject do |version|
94
+ version.path == current_asset.path if current_asset
95
+ end
96
+
97
+ # Sort remaining versions from newest to oldest
98
+ versions_to_delete = versions_to_delete.sort_by(&:last_modified).reverse
99
+
100
+ # If the asset has been completely removed from our set of locally compiled assets
101
+ # then use removed_at timestamp from manifest and removed_ttl to determine if it
102
+ # should be deleted from remote host.
103
+ # Otherwise, use version_ttl and version_limit to dermine whether version should be kept.
104
+ versions_to_delete = versions_to_delete.each_with_index.drop_while do |version, index|
105
+ if !current_asset
106
+ if (removed_at = removal_manifest[version.path])
107
+ removed_at = Time.parse(removed_at)
108
+ removed_age = Time.now.utc - removed_at
109
+ log "Determining how long ago #{version.path} was removed - removed on #{removed_at} (#{removed_age} seconds ago)."
110
+ drop = removed_age < removed_ttl
111
+ log "Marking removed asset #{version.path} for deletion." unless drop
112
+ removal_manifest.delete(version.path) unless drop || dry_run
113
+ drop
114
+ else
115
+ log "Adding #{version.path} to removal manifest."
116
+ removed_at = Time.now.utc
117
+ removal_manifest[version.path] = removed_at.iso8601 unless dry_run
118
+ true
119
+ end
120
+ else
121
+ # Keep if under age or within the version_limit
122
+ version_age = [0, Time.now - version.last_modified].max
123
+ drop = version_age < version_ttl || index < version_limit
124
+ log "Marking #{version.path} for deletion. Version age: #{version_age}. Version: #{index + 1}." unless drop
125
+ drop
126
+ end
127
+ end.map(&:first)
128
+
129
+ s3_keys_to_delete += versions_to_delete.map(&:path)
130
+ end
131
+
132
+ unless dry_run
133
+ delete_objects(s3_keys_to_delete)
134
+ removal_manifest.save
135
+ end
136
+
137
+ remote_asset_collector.clear_cache
138
+ s3_keys_to_delete
139
+ end
140
+
141
+ def deploy(version_limit: 2, version_ttl: 3600, removed_ttl: 172800, clean: true, dry_run: false)
142
+ upload(dry_run: dry_run)
143
+ yield if block_given?
144
+ if clean
145
+ clean(
146
+ dry_run: dry_run,
147
+ version_limit: version_limit,
148
+ version_ttl: version_ttl,
149
+ removed_ttl: removed_ttl
150
+ )
151
+ end
152
+ end
153
+
154
+ def to_s
155
+ "#<#{self.class.name}:#{"0x0000%x" % (object_id << 1)} @bucket_name='#{bucket_name}'>"
156
+ end
157
+
158
+ def inspect
159
+ to_s
160
+ end
161
+
162
+ protected
163
+
164
+ def upload_asset(asset)
165
+ file_handle = File.open(asset.full_path)
166
+
167
+ params = {
168
+ bucket: bucket_name,
169
+ key: asset.path,
170
+ body: file_handle,
171
+ acl: "public-read",
172
+ content_type: asset.mime_type,
173
+ cache_control: "public, max-age=31536000"
174
+ }.merge(@upload_options)
175
+
176
+ put_object(params)
177
+ ensure
178
+ file_handle.close
179
+ end
180
+
181
+ def s3
182
+ @s3 ||= Aws::S3::Client.new(@s3_client_options)
183
+ end
184
+
185
+ def verify_no_duplicate_assets!
186
+ if local_asset_collector.original_asset_paths.uniq.length != local_asset_collector.asset_paths.length
187
+ raise S3AssetDeploy::DuplicateAssetsError
188
+ end
189
+ end
190
+
191
+ def put_object(object)
192
+ s3.put_object(object)
193
+ end
194
+
195
+ def delete_objects(keys = [])
196
+ return if keys.empty?
197
+ s3.delete_objects(
198
+ bucket: bucket_name,
199
+ delete: { objects: keys.map { |key| { key: key }} }
200
+ )
201
+ end
202
+
203
+ def log(msg)
204
+ logger.info("#{self.class.name}: #{msg}")
205
+ end
206
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "s3_asset_deploy/local_asset"
4
+
5
+ class S3AssetDeploy::RailsLocalAsset < S3AssetDeploy::LocalAsset
6
+ attr_reader :path
7
+
8
+ def full_path
9
+ File.join(public_path, path)
10
+ end
11
+
12
+ protected
13
+
14
+ def public_path
15
+ ::Rails.public_path
16
+ end
17
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "s3_asset_deploy/rails_local_asset"
4
+ require "s3_asset_deploy/local_asset_collector"
5
+
6
+ class S3AssetDeploy::RailsLocalAssetCollector < S3AssetDeploy::LocalAssetCollector
7
+ def assets
8
+ assets_from_manifest + pack_assets
9
+ end
10
+
11
+ def assets_from_manifest
12
+ manifest = ::Sprockets::Manifest.new(
13
+ ::ActionView::Base.assets_manifest.environment,
14
+ ::ActionView::Base.assets_manifest.dir
15
+ )
16
+ manifest.assets.values.map do |f|
17
+ S3AssetDeploy::RailsLocalAsset.new(
18
+ File.join(assets_prefix, f),
19
+ remove_fingerprint: @remove_fingerprint
20
+ )
21
+ end
22
+ end
23
+
24
+ def pack_assets
25
+ return [] unless defined?(::Webpacker)
26
+
27
+ Dir.chdir(public_path) do
28
+ packs_dir = ::Webpacker.config.public_output_path.relative_path_from(public_path)
29
+
30
+ Dir[File.join(packs_dir, "/**/**")]
31
+ .select { |path| File.file?(path) }
32
+ .reject { |path| path.ends_with?(".gz") || path.ends_with?("manifest.json") }
33
+ .map { |path| S3AssetDeploy::RailsLocalAsset.new(path, remove_fingerprint: @remove_fingerprint) }
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def public_path
40
+ ::Rails.public_path
41
+ end
42
+
43
+ def assets_prefix
44
+ ::Rails.application.config.assets.prefix.sub(/^\//, "")
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "s3_asset_deploy/asset_helper"
4
+
5
+ class S3AssetDeploy::RemoteAsset
6
+ attr_reader :s3_object
7
+
8
+ def initialize(s3_object, remove_fingerprint: nil)
9
+ @s3_object = s3_object
10
+ @remove_fingerprint = remove_fingerprint
11
+ end
12
+
13
+ def original_path
14
+ @original_path ||=
15
+ if @remove_fingerprint
16
+ @remove_fingerprint.call(path)
17
+ else
18
+ S3AssetDeploy::AssetHelper.remove_fingerprint(path)
19
+ end
20
+ end
21
+
22
+ def last_modified
23
+ s3_object.last_modified
24
+ end
25
+
26
+ def path
27
+ s3_object.key
28
+ end
29
+
30
+ def ==(other_asset)
31
+ path == other_asset.path
32
+ end
33
+
34
+ def to_s
35
+ path
36
+ end
37
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-s3"
4
+ require "s3_asset_deploy/removal_manifest"
5
+ require "s3_asset_deploy/remote_asset"
6
+
7
+ class S3AssetDeploy::RemoteAssetCollector
8
+ attr_reader :bucket_name
9
+
10
+ def initialize(bucket_name, s3_client_options: {}, remove_fingerprint: nil)
11
+ @bucket_name = bucket_name
12
+ @remove_fingerprint = remove_fingerprint
13
+ @s3_client_options = {
14
+ region: "us-east-1",
15
+ logger: @logger
16
+ }.merge(s3_client_options)
17
+ end
18
+
19
+ def s3
20
+ @s3 ||= Aws::S3::Client.new(@s3_client_options)
21
+ end
22
+
23
+ def assets
24
+ @assets ||= s3.list_objects_v2(bucket: bucket_name).each_with_object([]) do |response, array|
25
+ remote_assets = response
26
+ .contents
27
+ .reject { |obj| obj.key == S3AssetDeploy::RemovalManifest::PATH }
28
+ .map do |obj|
29
+ S3AssetDeploy::RemoteAsset.new(obj, remove_fingerprint: @remove_fingerprint)
30
+ end
31
+
32
+ array.concat(remote_assets)
33
+ end
34
+ end
35
+
36
+ def clear_cache
37
+ @assets = nil
38
+ end
39
+
40
+ def asset_paths
41
+ assets.map(&:path)
42
+ end
43
+
44
+ def grouped_assets
45
+ assets.group_by(&:original_path)
46
+ end
47
+
48
+ def to_s
49
+ "#<#{self.class.name}:#{"0x0000%x" % (object_id << 1)} @bucket_name='#{bucket_name}'>"
50
+ end
51
+
52
+ def inspect
53
+ to_s
54
+ end
55
+ end
@@ -0,0 +1,99 @@
1
+ require "json"
2
+
3
+ class S3AssetDeploy::RemovalManifest
4
+ attr_reader :bucket_name
5
+
6
+ PATH = "s3-asset-deploy-removal-manifest.json".freeze
7
+
8
+ def initialize(bucket_name, s3_client_options: {})
9
+ @bucket_name = bucket_name
10
+ @loaded = false
11
+ @changed = false
12
+ @manifest = {}
13
+ @s3_client_options = {
14
+ region: "us-east-1",
15
+ logger: @logger
16
+ }.merge(s3_client_options)
17
+ end
18
+
19
+ def s3
20
+ @s3 ||= Aws::S3::Client.new(@s3_client_options)
21
+ end
22
+
23
+ def load
24
+ return true if loaded?
25
+ @manifest = fetch_manifest
26
+ @loaded = true
27
+ rescue Aws::S3::Errors::NoSuchKey
28
+ @manifest = {}
29
+ @loaded = true
30
+ end
31
+
32
+ def loaded?
33
+ @loaded
34
+ end
35
+
36
+ def changed?
37
+ @changed
38
+ end
39
+
40
+ def save
41
+ return false unless loaded?
42
+ return true unless changed?
43
+
44
+ s3.put_object({
45
+ bucket: bucket_name,
46
+ key: PATH,
47
+ body: @manifest.to_json,
48
+ acl: "private",
49
+ content_type: "application/json"
50
+ })
51
+
52
+ @changed = false
53
+
54
+ true
55
+ end
56
+
57
+ def keys
58
+ @manifest.keys
59
+ end
60
+
61
+ def delete(key)
62
+ return unless loaded?
63
+ @changed = true
64
+ @manifest.delete(key)
65
+ end
66
+
67
+ def [](key)
68
+ @manifest[key]
69
+ end
70
+
71
+ def []=(key, value)
72
+ return unless loaded?
73
+ @changed = true
74
+ @manifest[key] = value
75
+ end
76
+
77
+ def to_h
78
+ @manifest
79
+ end
80
+
81
+ def to_s
82
+ @manifest.to_s
83
+ end
84
+
85
+ def inspect
86
+ "#<#{self.class.name}:#{"0x0000%x" % (object_id << 1)} @bucket_name='#{bucket_name}'>"
87
+ end
88
+
89
+ private
90
+
91
+ def fetch_manifest
92
+ resp = s3.get_object({
93
+ bucket: bucket_name,
94
+ key: PATH
95
+ })
96
+
97
+ JSON.parse(resp.body.read)
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3AssetDeploy
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/s3_asset_deploy/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "s3_asset_deploy"
7
+ spec.version = S3AssetDeploy::VERSION
8
+ spec.authors = ["Loomly"]
9
+ spec.email = ["contact@loomly.com"]
10
+
11
+ spec.summary = "Safely deploy web app assets to S3 during rolling or multi-step deploys."
12
+ spec.homepage = "https://www.loomly.com"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/Loomly/s3_asset_deploy"
18
+ spec.metadata["changelog_uri"] = "https://github.com/Loomly/s3_asset_deploy/CHANGELOG.md"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_dependency "aws-sdk-s3", "~> 1.0"
30
+ spec.add_dependency "mime-types", "~> 3.0"
31
+
32
+ spec.add_development_dependency "rake", "~> 13.0"
33
+ spec.add_development_dependency "rspec", "~> 3.0"
34
+ spec.add_development_dependency "timecop", "~> 0.9"
35
+ spec.add_development_dependency "pry", "~> 0.13"
36
+ spec.add_development_dependency "pry-byebug", "~> 3.9"
37
+ spec.add_development_dependency "rspec_junit_formatter", "~> 0.4"
38
+
39
+ # For more information and examples about making a new gem, checkout our
40
+ # guide at: https://bundler.io/guides/creating_gem.html
41
+ end
metadata ADDED
@@ -0,0 +1,183 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: s3_asset_deploy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Loomly
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-02-27 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.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mime-types
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: timecop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.13'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.13'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry-byebug
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.9'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec_junit_formatter
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.4'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.4'
125
+ description:
126
+ email:
127
+ - contact@loomly.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".circleci/config.yml"
133
+ - ".gitignore"
134
+ - ".rspec"
135
+ - CHANGELOG.md
136
+ - CODE_OF_CONDUCT.md
137
+ - Gemfile
138
+ - Gemfile.lock
139
+ - LICENSE.txt
140
+ - README.md
141
+ - Rakefile
142
+ - bin/console
143
+ - bin/setup
144
+ - lib/s3_asset_deploy.rb
145
+ - lib/s3_asset_deploy/asset_helper.rb
146
+ - lib/s3_asset_deploy/errors.rb
147
+ - lib/s3_asset_deploy/local_asset.rb
148
+ - lib/s3_asset_deploy/local_asset_collector.rb
149
+ - lib/s3_asset_deploy/manager.rb
150
+ - lib/s3_asset_deploy/rails_local_asset.rb
151
+ - lib/s3_asset_deploy/rails_local_asset_collector.rb
152
+ - lib/s3_asset_deploy/remote_asset.rb
153
+ - lib/s3_asset_deploy/remote_asset_collector.rb
154
+ - lib/s3_asset_deploy/removal_manifest.rb
155
+ - lib/s3_asset_deploy/version.rb
156
+ - s3_asset_deploy.gemspec
157
+ homepage: https://www.loomly.com
158
+ licenses:
159
+ - MIT
160
+ metadata:
161
+ homepage_uri: https://www.loomly.com
162
+ source_code_uri: https://github.com/Loomly/s3_asset_deploy
163
+ changelog_uri: https://github.com/Loomly/s3_asset_deploy/CHANGELOG.md
164
+ post_install_message:
165
+ rdoc_options: []
166
+ require_paths:
167
+ - lib
168
+ required_ruby_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: 2.4.0
173
+ required_rubygems_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: '0'
178
+ requirements: []
179
+ rubygems_version: 3.0.3
180
+ signing_key:
181
+ specification_version: 4
182
+ summary: Safely deploy web app assets to S3 during rolling or multi-step deploys.
183
+ test_files: []