├── .circleci └── config.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── s3_asset_deploy.rb └── s3_asset_deploy │ ├── asset_helper.rb │ ├── errors.rb │ ├── local_asset.rb │ ├── local_asset_collector.rb │ ├── manager.rb │ ├── rails_local_asset.rb │ ├── rails_local_asset_collector.rb │ ├── remote_asset.rb │ ├── remote_asset_collector.rb │ ├── removal_manifest.rb │ └── version.rb ├── s3_asset_deploy.gemspec └── spec ├── asset_helper_spec.rb ├── helpers.rb ├── local_asset_spec.rb ├── manager_spec.rb ├── remote_asset_collector_spec.rb ├── remote_asset_spec.rb ├── removal_manifest_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 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 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.0.3](https://github.com/Loomly/s3_asset_deploy/compare/v1.0.1...v1.0.3) - 2024-09-23 4 | - Add support for Shakapacker 8.x+ 5 | 6 | ## [v1.0.2](https://github.com/Loomly/s3_asset_deploy/compare/v1.0.1...v1.0.2) - 2022-09-12 7 | - Remove `acl` specification when saving removal manifest. Bucket policies should be used instead. 8 | 9 | ## [v1.0.1](https://github.com/Loomly/s3_asset_deploy/compare/v1.0.0...v1.0.1) - 2022-02-23 10 | - Batch delete API calls with max 1000 keys - [PR #32](https://github.com/Loomly/s3_asset_deploy/pull/32) 11 | 12 | ## [v1.0.0](https://github.com/Loomly/s3_asset_deploy/compare/v0.1.1...v1.0.0) - 2021-05-13 13 | ### Breaking Changes 14 | - Remove default `acl` setting when uploading assets to bucket - [PR #25](https://github.com/Loomly/s3_asset_deploy/pull/25) 15 | 16 | ## [v0.1.1](https://github.com/Loomly/s3_asset_deploy/compare/v0.1.0...v0.1.1) - 2021-03-22 17 | - Fix bug in AssetHelper.remove_fingerprint referencing asset_path - [4f370ad](https://github.com/Loomly/s3_asset_deploy/commit/4f370ad9c0c1c274acb9b1d8585b878f47020277) 18 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 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. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 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 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | s3_asset_deploy (1.0.3) 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.3.0) 12 | aws-partitions (1.977.0) 13 | aws-sdk-core (3.208.0) 14 | aws-eventstream (~> 1, >= 1.3.0) 15 | aws-partitions (~> 1, >= 1.651.0) 16 | aws-sigv4 (~> 1.9) 17 | jmespath (~> 1, >= 1.6.1) 18 | aws-sdk-kms (1.93.0) 19 | aws-sdk-core (~> 3, >= 3.207.0) 20 | aws-sigv4 (~> 1.5) 21 | aws-sdk-s3 (1.164.0) 22 | aws-sdk-core (~> 3, >= 3.207.0) 23 | aws-sdk-kms (~> 1) 24 | aws-sigv4 (~> 1.5) 25 | aws-sigv4 (1.10.0) 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.6.2) 31 | method_source (1.0.0) 32 | mime-types (3.5.2) 33 | mime-types-data (~> 3.2015) 34 | mime-types-data (3.2024.0903) 35 | mini_portile2 (2.8.0) 36 | nokogiri (1.13.3) 37 | mini_portile2 (~> 2.8.0) 38 | racc (~> 1.4) 39 | pry (0.13.1) 40 | coderay (~> 1.1) 41 | method_source (~> 1.0) 42 | pry-byebug (3.9.0) 43 | byebug (~> 11.0) 44 | pry (~> 0.13.0) 45 | racc (1.6.0) 46 | rake (13.0.3) 47 | rspec (3.10.0) 48 | rspec-core (~> 3.10.0) 49 | rspec-expectations (~> 3.10.0) 50 | rspec-mocks (~> 3.10.0) 51 | rspec-core (3.10.1) 52 | rspec-support (~> 3.10.0) 53 | rspec-expectations (3.10.1) 54 | diff-lcs (>= 1.2.0, < 2.0) 55 | rspec-support (~> 3.10.0) 56 | rspec-mocks (3.10.2) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.10.0) 59 | rspec-support (3.10.2) 60 | rspec_junit_formatter (0.4.1) 61 | rspec-core (>= 2, < 4, != 2.12.0) 62 | timecop (0.9.2) 63 | 64 | PLATFORMS 65 | ruby 66 | 67 | DEPENDENCIES 68 | nokogiri (~> 1.13) 69 | pry (~> 0.13) 70 | pry-byebug (~> 3.9) 71 | rake (~> 13.0) 72 | rspec (~> 3.0) 73 | rspec_junit_formatter (~> 0.4) 74 | s3_asset_deploy! 75 | timecop (~> 0.9) 76 | 77 | BUNDLED WITH 78 | 2.2.32 79 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | [![Gem Version](https://badge.fury.io/rb/s3_asset_deploy.svg)](https://badge.fury.io/rb/s3_asset_deploy) 5 | 6 | During rolling deploys to our web instances, this is what we use at 7 | [Loomly](https://www.loomly.com) to safely deploy our web assets to S3 to be served via Cloudfront. 8 | This gem is designed to upload and clean unneeded assets from S3 in a safe manner such that older 9 | versions or recently removed assets are kept on S3 during the rolling deploy process. 10 | It also maintains a version limit and TTL (time-to-live) on assets to avoid deleting 11 | recent and outdated versions (up to a limit) or those that have been recently removed. 12 | 13 | ## Background 14 | 15 | 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`: 16 | 17 | - 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. 18 | - Uses a global configuration, which made it difficult to deploy to different S3 buckets depending on the environment (development, staging, production, etc.). 19 | - Didn't have a way to remove or retire outdated or old assets from storage (in this case S3). 20 | 21 | 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. 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem "s3_asset_deploy" 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle install 34 | 35 | Or install it yourself as: 36 | 37 | $ gem install s3_asset_deploy 38 | 39 | ## Usage 40 | 41 | 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: 42 | 43 | 44 | ```ruby 45 | manager = S3AssetDeploy::Manager.new("my-s3-bucket") 46 | manager.deploy do 47 | # Perform deploy to web instances in this block 48 | end 49 | ``` 50 | 51 | `S3AssetDeploy::Manager#deploy` will perform the following steps: 52 | - Upload your assets to the S3 bucket you specify 53 | - Yield to the block 54 | - Clean old versions assets or removed assets 55 | 56 | 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. 57 | 58 | ### Initializing [`S3AssetDeploy::Manager`](https://github.com/Loomly/s3_asset_deploy/blob/main/lib/s3_asset_deploy/manager.rb) 59 | You'll need to initialize `S3AssetDeploy::Manager` with an S3 bucket name and **optionally**: 60 | 61 | - **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`. 62 | - **logger** (Logger) -> A custom logger. By default things are logged to `STDOUT`. 63 | - **local_asset_collector** (S3AssetDeploy::LocalAssetCollector) -> A custom instance of `S3AssetDeploy::LocalAssetCollector`. This allows you to customize how locally compiled assets are collected. 64 | - **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 `cache_control` is set to `public, max-age=31536000`. 65 | - **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. 66 | 67 | Here's an example: 68 | 69 | ```ruby 70 | manager = S3AssetDeploy::Manager.new( 71 | "mybucket", 72 | s3_client_options: { region: "us-west-1", profile: "my-aws-profile" }, 73 | logger: Logger.new(STDOUT), 74 | remove_fingerprint: ->(path) { path.gsub("-myfingerprint", "") } 75 | ) 76 | ``` 77 | 78 | ### Deploying Assets 79 | Once you have an instance of `S3AssetDeploy::Manager`, you can deploy your precompiled assets with `S3AssetDeploy::Manager#deploy`: 80 | 81 | ```ruby 82 | manager.deploy(version_limit: 2, version_ttl: 3600, removed_ttl: 172800) do 83 | # Perform deploy to web instances in this block 84 | end 85 | ``` 86 | 87 | This will upload new assets and perform a clean, which deletes removed assets and old versions from your bucket after the block is executed. 88 | 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`). 89 | 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`: 90 | 91 | - **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`. 92 | - **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`. 93 | - **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`. 94 | - **clean** (Boolean) -> Skip the clean process during a deploy. Default is `true`. 95 | - **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`. 96 | 97 | `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. 98 | 99 | 100 | ```ruby 101 | # Upload new assets 102 | manager.upload 103 | 104 | # Delete old versions and removed assets from S3 105 | manager.clean 106 | ``` 107 | 108 | `S3AssetDeploy::Manager#deploy` and `S3AssetDeploy::Manager#clean` both accept `dry_run` as a keyword argument. 109 | `S3AssetDeploy::Manager#clean` also accepts `version_limit`, `version_ttl`, and `removed_ttl` just like `S3AssetDeploy::Manager#deploy`. 110 | 111 | ### Practical Example of Usage 112 | There are many ways to use and invoke `S3AssetDeploy`. How you use it will depend on your deploy process and pipeline. At Loomly, we have some rake tasks that are invoked from our CI/CD pipeline to perform deploys. 113 | Here's a basic example of how we use `S3AssetDeploy`: 114 | 115 | 116 | ```ruby 117 | # lib/tasks/deploy.rake 118 | 119 | require "s3_asset_deploy" 120 | 121 | namespace :deploy do 122 | task precompile: :environment do 123 | puts "Precompiling assets..." 124 | sh("RAILS_ENV=production SECRET_KEY_BASE='secret key' bundle exec rake assets:precompile") 125 | end 126 | 127 | task clobber_assets: :environment do 128 | puts "Clobbering assets..." 129 | sh("RAILS_ENV=production SECRET_KEY_BASE='secret key' bundle exec rake assets:clobber") 130 | end 131 | 132 | task :production do 133 | Rake::Task["deploy:precompile"].invoke 134 | 135 | manager = S3AssetDeploy::Manager.new("my-s3-bucket") 136 | manager.deploy do 137 | # Perform deploy to web instances in this block. 138 | # How you do this will depend on where you are hosting your application and what tools you use to deploy. 139 | end 140 | 141 | Rake::Task["deploy:clobber_assets"].invoke # <-- If you are running on CI where the precompiled assets directory is ephemeral, this may be unnecessary 142 | end 143 | end 144 | ``` 145 | 146 | Given the example above, we can perform a deploy by running `bundle exec rake deploy:production`. This task will: 147 | 1. Precompile assets 148 | 2. Upload any new assets to S3 using `S3AssetDeploy` 149 | 3. Deploy a new version of our application 150 | 4. Clean any outdated or unused assets from S3 using `S3AssetDeploy` 151 | 152 | ## Customizing local asset collection 153 | 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`/`Shakapacker` config (if either are 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: 154 | 155 | 156 | ```ruby 157 | class MyCustomLocalAssetCollector < S3AssetDeploy::LocalAssetCollector 158 | def assets 159 | # Override this method to return an array of your locally compiled assets 160 | # as instances of S3AssetDeploy::LocalAsset 161 | [S3AssetDeploy::LocalAsset.new("path-to-my-asset.jpg")] 162 | end 163 | end 164 | 165 | manager = S3AssetDeploy::Manager.new( 166 | "mybucket", 167 | local_asset_collector: MyCustomLocalAssetCollector.new 168 | ) 169 | ``` 170 | 171 | ## Dry run 172 | As mentioned above, you can run operations in "dry" mode by passing `dry_run: true`. 173 | This will skip any write or delete operations and only perform read opeartions with log output. 174 | This is helpful for debugging or planning purposes. 175 | 176 | ```ruby 177 | > manager = S3AssetDeploy::Manager.new("my-s3-bucket") 178 | > manager.deploy(dry_run: true) 179 | 180 | I, [2021-02-17T16:12:23.703677 #65335] INFO -- : S3AssetDeploy::Manager: Cleaning assets from test-bucket S3 bucket. Dry run: true 181 | 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) 182 | 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) 183 | ``` 184 | 185 | ## AWS IAM Permissions 186 | `S3AsetDeploy` requires the following AWS IAM permissions to list, put, and delete objects in your S3 Bucket: 187 | 188 | ```json 189 | "Statement": [ 190 | { 191 | "Action": [ 192 | "s3:ListBucket", 193 | "s3:PutObject*", 194 | "s3:DeleteObject" 195 | ], 196 | "Effect": "Allow", 197 | "Resource": [ 198 | "arn:aws:s3:::#{YOUR_BUCKET}", 199 | "arn:aws:s3:::#{YOUR_BUCKET}/*" 200 | ] 201 | } 202 | ] 203 | ``` 204 | 205 | ## Configuration with Cloudfront 206 | 207 | ### Restricting Access with Origin Access Identity 208 | If you want to setup Cloudfront to serve your assets, you can [restrict access to the bucket by using an Origin Access Identity](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#private-content-granting-permissions-to-oai) so that only Cloudfront can access the objects in your bucket. 209 | 210 | If you do this, your bucket policy will look something like this: 211 | 212 | ```json 213 | { 214 | "Version": "2012-10-17", 215 | "Statement": [ 216 | { 217 | "Sid": "AllowGetObject", 218 | "Effect": "Allow", 219 | "Principal": { 220 | "AWS": [ 221 | "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity #{YOUR_OAI_ID}" 222 | ] 223 | }, 224 | "Action": "s3:GetObject", 225 | "Resource": "arn:aws:s3:::#{YOUR_BUCKET}/*" 226 | }, 227 | { 228 | "Sid": "DenyGetObject", 229 | "Effect": "Deny", 230 | "Principal": { 231 | "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity #{YOUR_OAI_ID}" 232 | }, 233 | "Action": "s3:GetObject", 234 | "Resource": "arn:aws:s3:::#{YOUR_BUCKET}/s3-asset-deploy-removal-manifest.json" 235 | } 236 | ] 237 | } 238 | ``` 239 | 240 | This policy allows Cloudfront to access everything **except** the removal manifest uploaded and maintained by this gem since this manifest does not need to be served to clients. 241 | 242 | ### CORS 243 | Your CORS configuration on the bucket might look something like this: 244 | 245 | ```json 246 | [ 247 | { 248 | "AllowedHeaders": [ 249 | "Authorization" 250 | ], 251 | "AllowedMethods": [ 252 | "GET", 253 | "HEAD" 254 | ], 255 | "AllowedOrigins": [ 256 | "https://*.#{YOUR_SITE}.com" 257 | ], 258 | "ExposeHeaders": [], 259 | "MaxAgeSeconds": 3000 260 | } 261 | ] 262 | ``` 263 | 264 | ## Development 265 | 266 | 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. 267 | 268 | 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). 269 | 270 | ## Contributing 271 | 272 | 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). 273 | 274 | ## License 275 | 276 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 277 | 278 | ## Code of Conduct 279 | 280 | 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). 281 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 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__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 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 9 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "s3_asset_deploy/version" 4 | require "s3_asset_deploy/manager" 5 | 6 | module S3AssetDeploy 7 | end 8 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/asset_helper.rb: -------------------------------------------------------------------------------- 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 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 20 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/errors.rb: -------------------------------------------------------------------------------- 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 11 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/local_asset.rb: -------------------------------------------------------------------------------- 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 38 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/local_asset_collector.rb: -------------------------------------------------------------------------------- 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 30 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/manager.rb: -------------------------------------------------------------------------------- 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 | content_type: asset.mime_type, 172 | cache_control: "public, max-age=31536000" 173 | }.merge(@upload_options) 174 | 175 | put_object(params) 176 | ensure 177 | file_handle.close 178 | end 179 | 180 | def s3 181 | @s3 ||= Aws::S3::Client.new(@s3_client_options) 182 | end 183 | 184 | def verify_no_duplicate_assets! 185 | if local_asset_collector.original_asset_paths.uniq.length != local_asset_collector.asset_paths.length 186 | raise S3AssetDeploy::DuplicateAssetsError 187 | end 188 | end 189 | 190 | def put_object(object) 191 | s3.put_object(object) 192 | end 193 | 194 | def delete_objects(keys = []) 195 | return if keys.empty? 196 | keys.each_slice(1000) do |key_slice| 197 | s3.delete_objects( 198 | bucket: bucket_name, 199 | delete: { objects: key_slice.map { |key| { key: key }} } 200 | ) 201 | end 202 | end 203 | 204 | def log(msg) 205 | logger.info("#{self.class.name}: #{msg}") 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/rails_local_asset.rb: -------------------------------------------------------------------------------- 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 18 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/rails_local_asset_collector.rb: -------------------------------------------------------------------------------- 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?(::Shakapacker) || defined?(::Webpacker) 26 | 27 | Dir.chdir(public_path) do 28 | config = defined?(::Shakapacker) ? ::Shakapacker.config : ::Webpacker.config 29 | packs_dir = config.public_output_path.relative_path_from(public_path) 30 | 31 | Dir[File.join(packs_dir, "/**/**")] 32 | .select { |path| File.file?(path) } 33 | .reject { |path| path.ends_with?(".gz") || path.ends_with?("manifest.json") } 34 | .map { |path| S3AssetDeploy::RailsLocalAsset.new(path, remove_fingerprint: @remove_fingerprint) } 35 | end 36 | end 37 | 38 | private 39 | 40 | def public_path 41 | ::Rails.public_path 42 | end 43 | 44 | def assets_prefix 45 | ::Rails.application.config.assets.prefix.sub(/^\//, "") 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/remote_asset.rb: -------------------------------------------------------------------------------- 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 38 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/remote_asset_collector.rb: -------------------------------------------------------------------------------- 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 56 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/removal_manifest.rb: -------------------------------------------------------------------------------- 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 | content_type: "application/json" 49 | }) 50 | 51 | @changed = false 52 | 53 | true 54 | end 55 | 56 | def keys 57 | @manifest.keys 58 | end 59 | 60 | def delete(key) 61 | return unless loaded? 62 | @changed = true 63 | @manifest.delete(key) 64 | end 65 | 66 | def [](key) 67 | @manifest[key] 68 | end 69 | 70 | def []=(key, value) 71 | return unless loaded? 72 | @changed = true 73 | @manifest[key] = value 74 | end 75 | 76 | def to_h 77 | @manifest 78 | end 79 | 80 | def to_s 81 | @manifest.to_s 82 | end 83 | 84 | def inspect 85 | "#<#{self.class.name}:#{"0x0000%x" % (object_id << 1)} @bucket_name='#{bucket_name}'>" 86 | end 87 | 88 | private 89 | 90 | def fetch_manifest 91 | resp = s3.get_object({ 92 | bucket: bucket_name, 93 | key: PATH 94 | }) 95 | 96 | JSON.parse(resp.body.read) 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/s3_asset_deploy/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module S3AssetDeploy 4 | VERSION = "1.0.3" 5 | end 6 | -------------------------------------------------------------------------------- /s3_asset_deploy.gemspec: -------------------------------------------------------------------------------- 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 = "Deploy & manage static assets on S3 with rolling deploys & rollbacks in mind." 12 | spec.homepage = "https://github.com/Loomly/s3_asset_deploy" 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 | # Required for aws-sdk-ruby. 40 | # See https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-core/lib/aws-sdk-core/xml/parser.rb#L74 41 | spec.add_development_dependency "nokogiri", "~> 1.13" 42 | 43 | # For more information and examples about making a new gem, checkout our 44 | # guide at: https://bundler.io/guides/creating_gem.html 45 | end 46 | -------------------------------------------------------------------------------- /spec/asset_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::AssetHelper do 4 | describe ".remove_fingerprint" do 5 | it "accounts for multiple extensions" do 6 | expect(described_class.remove_fingerprint("packs/js/0-3e1f1b9c14ca587bae85.chunk.js")).to eq("packs/js/0.chunk.js") 7 | end 8 | 9 | it "handles multiple hyphens" do 10 | expect(described_class.remove_fingerprint("packs/js/pdf-post-previews-bundle-c574a9fdf0c69f19cce8.chunk.js")).to eq("packs/js/pdf-post-previews-bundle.chunk.js") 11 | end 12 | 13 | it "handles tilde" do 14 | expect(described_class.remove_fingerprint("packs/js/runtime~mobile-bundle-298e884ee611bb56b6ca.js.map")).to eq("packs/js/runtime~mobile-bundle.js.map") 15 | end 16 | 17 | it "handles single extentions" do 18 | expect(described_class.remove_fingerprint("assets/bootstrap/glyphicons-halflings-regular-42f60659d265c1a3c30f9fa42abcbb56bd4a53af4d83d316d6dd7a36903c43e5.svg")).to eq("assets/bootstrap/glyphicons-halflings-regular.svg") 19 | end 20 | end 21 | 22 | describe ".mime_type_for_path" do 23 | it "returns application/json for javascript map files" do 24 | expect(described_class.mime_type_for_path("packs/js/runtime~mobile-bundle-298e884ee611bb56b6ca.js.map")).to eq("application/json") 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | def create_remote_assets(*definitions) 3 | definitions.map do |definition| 4 | key, last_modified = definition 5 | last_modified = Time.parse(last_modified) unless last_modified.is_a?(Time) 6 | S3AssetDeploy::RemoteAsset.new(OpenStruct.new(key: key, last_modified: last_modified)) 7 | end 8 | end 9 | 10 | def create_local_assets(*paths) 11 | paths.map { |path| S3AssetDeploy::LocalAsset.new(path) } 12 | end 13 | 14 | def expect_instance_of_remote_asset_collector_to_receive_assets(times, *definitions) 15 | expect_any_instance_of(S3AssetDeploy::RemoteAssetCollector).to receive(:assets).exactly(times).times.and_return( 16 | create_remote_assets(*definitions) 17 | ) 18 | end 19 | 20 | def expect_instance_of_local_asset_collector_to_receive_assets(times, *paths) 21 | expect_any_instance_of(S3AssetDeploy::RailsLocalAssetCollector).to receive(:assets).exactly(times).times.and_return( 22 | create_local_assets(*paths) 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/local_asset_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::LocalAsset do 4 | describe "#original_path" do 5 | it "removes fingerprint after hyphen" do 6 | asset = described_class.new("packs/js/0-3e1f1b9c14ca587bae85.chunk.js") 7 | expect(asset.original_path).to eq("packs/js/0.chunk.js") 8 | end 9 | end 10 | 11 | context "when passing in remove_fingerprint" do 12 | describe "#original_path" do 13 | it "removes fingerprint according to remove_fingerint lambda" do 14 | asset = described_class.new( 15 | "packs/js/0-3e1f1b9c14ca587bae85.chunk.js", 16 | remove_fingerprint: ->(path) { path.gsub("bae85", "") } 17 | ) 18 | 19 | expect(asset.original_path).to eq("packs/js/0-3e1f1b9c14ca587.chunk.js") 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/manager_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::Manager do 4 | subject { described_class.new("test-bucket", logger: Logger.new(IO::NULL) ) } 5 | let(:s3_client) do 6 | Class.new do 7 | def put_object(*args) 8 | end 9 | 10 | def delete_objects(*args) 11 | end 12 | end 13 | end 14 | 15 | let(:s3_client_instance) { s3_client.new } 16 | 17 | let(:removal_manifest_s3_client) do 18 | Class.new do 19 | def initialize(&block) 20 | @get_object = block 21 | end 22 | 23 | def put_object(*args) 24 | end 25 | 26 | def get_object(*args) 27 | OpenStruct.new( 28 | body: StringIO.new((@get_object ? @get_object.call(*args) : {}).to_json) 29 | ) 30 | end 31 | end 32 | end 33 | 34 | let(:removal_manifest_s3_client_instance) { removal_manifest_s3_client.new } 35 | 36 | before { allow_any_instance_of(described_class).to receive(:s3) { s3_client_instance } } 37 | before { allow_any_instance_of(S3AssetDeploy::RemovalManifest).to receive(:s3) { removal_manifest_s3_client_instance } } 38 | 39 | describe "#local_assets_to_upload" do 40 | it "only returns assets not on remote" do 41 | expect_instance_of_remote_asset_collector_to_receive_assets( 42 | 1, 43 | ["assets/file-1-12345.jpg", "2018-05-01 15:38:31 UTC"] 44 | ) 45 | expect_instance_of_local_asset_collector_to_receive_assets( 46 | 1, 47 | "assets/file-1-12345.jpg", 48 | "assets/file-2-34567.jpg", 49 | "assets/file-3-9876666.jpg" 50 | ) 51 | 52 | expect(subject.local_assets_to_upload).to contain_exactly( 53 | *create_local_assets("assets/file-2-34567.jpg", "assets/file-3-9876666.jpg") 54 | ) 55 | end 56 | end 57 | 58 | describe "#upload" do 59 | before { allow(File).to receive(:file?) { true } } 60 | 61 | it "uploads new assets" do 62 | expect_instance_of_remote_asset_collector_to_receive_assets( 63 | 1, 64 | ["assets/file-2-34567.jpg", "2018-05-01 15:38:31 UTC"], 65 | ["assets/file-3-9876666.jpg", "2018-05-01 15:38:31 UTC"] 66 | ) 67 | 68 | expect_instance_of_local_asset_collector_to_receive_assets( 69 | 4, 70 | "assets/file-1-12345.jpg", 71 | "assets/file-4-3333.jpg", 72 | "assets/file-3-9876666.jpg" 73 | ) 74 | 75 | expect(subject).to receive(:upload_asset).with( 76 | S3AssetDeploy::LocalAsset.new("assets/file-1-12345.jpg") 77 | ) 78 | expect(subject).to receive(:upload_asset).with( 79 | S3AssetDeploy::LocalAsset.new("assets/file-4-3333.jpg") 80 | ) 81 | expect(subject.upload).to contain_exactly("assets/file-1-12345.jpg", "assets/file-4-3333.jpg") 82 | end 83 | 84 | context "with removed files in removal manifest" do 85 | let(:removal_manifest_s3_client_instance) do 86 | removal_manifest_s3_client.new do 87 | { 88 | "assets/file-2-34567.jpg" => (Time.now - 172801).utc.iso8601, 89 | "assets/file-3-9876666.jpg" => (Time.now - 172799).utc.iso8601 90 | } 91 | end 92 | end 93 | 94 | it "removes re-added assets from removal manifest" do 95 | expect_instance_of_remote_asset_collector_to_receive_assets( 96 | 1, 97 | ["assets/file-2-34567.jpg", "2018-05-01 15:38:31 UTC"], 98 | ["assets/file-3-9876666.jpg", "2018-05-01 15:38:31 UTC"] 99 | ) 100 | 101 | expect_instance_of_local_asset_collector_to_receive_assets( 102 | 4, 103 | "assets/file-1-12345.jpg", 104 | "assets/file-3-9876666.jpg" 105 | ) 106 | 107 | expect(subject.removal_manifest).to receive(:delete).with("assets/file-3-9876666.jpg") 108 | expect(subject.removal_manifest).to receive(:save) 109 | expect(subject).to receive(:upload_asset).with(S3AssetDeploy::LocalAsset.new("assets/file-1-12345.jpg")) 110 | expect(subject.upload).to contain_exactly("assets/file-1-12345.jpg") 111 | end 112 | end 113 | end 114 | 115 | describe "#clean" do 116 | it "adds removed files to removal manifest" do 117 | Timecop.freeze(Time.now) do 118 | expect_instance_of_remote_asset_collector_to_receive_assets( 119 | 2, 120 | ["assets/file-1-12345.jpg", "2018-05-01 15:38:31 UTC"], 121 | ["assets/file-2-34567.jpg", "2018-05-01 15:38:31 UTC"], 122 | ["assets/file-3-9876666.jpg", "2018-05-01 15:38:31 UTC"] 123 | ) 124 | 125 | expect_instance_of_local_asset_collector_to_receive_assets( 126 | 4, 127 | "assets/file-1-12345.jpg" 128 | ) 129 | 130 | expect(subject.removal_manifest).to receive(:load) 131 | expect(subject.removal_manifest).to receive(:[]=).once.with( 132 | "assets/file-2-34567.jpg", 133 | Time.now.utc.iso8601 134 | ) 135 | expect(subject.removal_manifest).to receive(:[]=).once.with( 136 | "assets/file-3-9876666.jpg", 137 | Time.now.utc.iso8601 138 | ) 139 | expect(subject.removal_manifest).to receive(:save) 140 | 141 | expect(subject.clean).to match_array([]) 142 | end 143 | end 144 | 145 | context "with removed files in removal manifest" do 146 | let(:removal_manifest_s3_client_instance) do 147 | removal_manifest_s3_client.new do 148 | { 149 | "assets/file-2-34567.jpg" => (Time.now - 172801).utc.iso8601, 150 | "assets/file-3-9876666.jpg" => (Time.now - 172799).utc.iso8601 151 | } 152 | end 153 | end 154 | 155 | it "deletes remote assets only after 'removed_ttl'" do 156 | Timecop.freeze(Time.now) do 157 | expect_instance_of_remote_asset_collector_to_receive_assets( 158 | 2, 159 | ["assets/file-1-12345.jpg", "2018-05-01 15:38:31 UTC"], 160 | ["assets/file-2-34567.jpg", "2018-05-01 15:38:31 UTC"], 161 | ["assets/file-3-9876666.jpg", "2018-05-01 15:38:31 UTC"] 162 | ) 163 | 164 | expect_instance_of_local_asset_collector_to_receive_assets( 165 | 4, 166 | "assets/file-1-12345.jpg" 167 | ) 168 | 169 | expect(subject.clean(removed_ttl: 172800)).to contain_exactly("assets/file-2-34567.jpg") 170 | end 171 | end 172 | end 173 | 174 | it "keeps old versions up to 'version_limit'" do 175 | expect_instance_of_remote_asset_collector_to_receive_assets( 176 | 2, 177 | ["assets/file-1-123.jpg", "2018-05-01 15:38:31 UTC"], 178 | ["assets/file-1-456.jpg", "2018-05-02 15:38:31 UTC"], 179 | ["assets/file-1-789.jpg", "2018-05-03 15:38:31 UTC"], 180 | ["assets/file-1-987.jpg", "2018-05-04 15:38:31 UTC"], 181 | ["assets/file-2-123.jpg", "2018-05-01 15:38:31 UTC"], 182 | ["assets/file-2-456.jpg", "2018-05-02 15:38:31 UTC"], 183 | ["assets/file-2-789.jpg", "2018-05-03 15:38:31 UTC"], 184 | ["assets/file-3-9876666.jpg", "2018-05-01 15:38:31 UTC"] 185 | ) 186 | expect_instance_of_local_asset_collector_to_receive_assets( 187 | 4, 188 | "assets/file-1-987.jpg", 189 | "assets/file-2-123.jpg", 190 | "assets/file-3-9876666.jpg" 191 | ) 192 | 193 | expect(subject.clean(version_limit: 2)).to contain_exactly("assets/file-1-123.jpg") 194 | end 195 | 196 | it "waits atleast 'version_ttl' seconds before deleting old versions" do 197 | Timecop.freeze(Time.now) do 198 | expect_instance_of_remote_asset_collector_to_receive_assets( 199 | 4, 200 | ["assets/file-1-123.jpg", (Time.now - 4)], 201 | ["assets/file-1-456.jpg", (Time.now - 3)], 202 | ["assets/file-1-789.jpg", (Time.now - 2)], 203 | ["assets/file-1-987.jpg", (Time.now - 1)] 204 | ) 205 | expect_instance_of_local_asset_collector_to_receive_assets( 206 | 8, 207 | "assets/file-1-987.jpg" 208 | ) 209 | 210 | expect(subject.clean).to be_empty 211 | 212 | Timecop.travel(Time.now + 3600) 213 | expect(subject.clean(version_ttl: 3600)).to contain_exactly("assets/file-1-123.jpg") 214 | end 215 | end 216 | 217 | it "raises DuplicateAssetsError if duplicate local assets" do 218 | expect_instance_of_local_asset_collector_to_receive_assets( 219 | 2, 220 | "assets/file-1-987.jpg", 221 | "assets/file-1-987.jpg" 222 | ) 223 | 224 | expect { subject.clean }.to raise_error(S3AssetDeploy::DuplicateAssetsError) 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /spec/remote_asset_collector_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::RemoteAssetCollector do 4 | subject { described_class.new("test-bucket" ) } 5 | 6 | let(:s3_client) do 7 | Class.new do 8 | def initialize(&block) 9 | @list_objects_v2 = block 10 | end 11 | 12 | def list_objects_v2(*args) 13 | [ 14 | OpenStruct.new( 15 | contents: @list_objects_v2 ? @list_objects_v2.call(*args) : [] 16 | ) 17 | ] 18 | end 19 | end 20 | end 21 | 22 | let(:s3_client_instance) { s3_client.new } 23 | 24 | before { allow_any_instance_of(described_class).to receive(:s3) { s3_client_instance } } 25 | 26 | 27 | context "with removal manifest file in bucket" do 28 | let(:s3_client_instance) do 29 | s3_client.new do 30 | [ 31 | OpenStruct.new(key: S3AssetDeploy::RemovalManifest::PATH) 32 | ] 33 | end 34 | end 35 | 36 | describe "#assets" do 37 | it "doesn't include removal manifest file in assets" do 38 | expect(subject.assets).to match_array([]) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/remote_asset_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::RemoteAsset do 4 | describe "#original_path" do 5 | it "removes fingerprint after hyphen" do 6 | asset = described_class.new( 7 | OpenStruct.new(key: "packs/js/0-3e1f1b9c14ca587bae85.chunk.js") 8 | ) 9 | 10 | expect(asset.original_path).to eq("packs/js/0.chunk.js") 11 | end 12 | end 13 | 14 | context "when passing in remove_fingerprint" do 15 | describe "#original_path" do 16 | it "removes fingerprint according to remove_fingerint lambda" do 17 | asset = described_class.new( 18 | OpenStruct.new(key: "packs/js/0-3e1f1b9c14ca587bae85.chunk.js"), 19 | remove_fingerprint: ->(path) { path.gsub("bae85", "") } 20 | ) 21 | 22 | expect(asset.original_path).to eq("packs/js/0-3e1f1b9c14ca587.chunk.js") 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/removal_manifest_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe S3AssetDeploy::RemovalManifest do 4 | subject { described_class.new("test-bucket" ) } 5 | 6 | let(:s3_client) do 7 | Class.new do 8 | def initialize(&block) 9 | @get_object = block 10 | end 11 | 12 | def put_object(*args) 13 | end 14 | 15 | def get_object(*args) 16 | OpenStruct.new( 17 | body: StringIO.new((@get_object ? @get_object.call(*args) : {}).to_json) 18 | ) 19 | end 20 | end 21 | end 22 | 23 | let(:s3_client_instance) { s3_client.new } 24 | 25 | before { allow_any_instance_of(S3AssetDeploy::RemovalManifest).to receive(:s3) { s3_client_instance } } 26 | 27 | describe "#changed?" do 28 | let(:s3_client_instance) do 29 | s3_client.new do 30 | { 31 | "assets/file-2-34567.jpg" => (Time.now - 172801).utc.iso8601, 32 | "assets/file-3-9876666.jpg" => (Time.now - 172799).utc.iso8601 33 | } 34 | end 35 | end 36 | 37 | it "is false after initializing" do 38 | expect(subject.changed?).to eq(false) 39 | end 40 | 41 | it "is true after setting an attribute" do 42 | expect(subject.changed?).to eq(false) 43 | subject.load 44 | expect(subject.changed?).to eq(false) 45 | subject["myfile"] = Time.now.utc.iso8601 46 | expect(subject.changed?).to eq(true) 47 | end 48 | 49 | it "is true after deleting an attribute" do 50 | expect(subject.changed?).to eq(false) 51 | subject.load 52 | expect(subject.changed?).to eq(false) 53 | subject.delete("assets/file-2-34567.jpg") 54 | expect(subject.changed?).to eq(true) 55 | end 56 | 57 | it "is false after saving" do 58 | expect(subject.changed?).to eq(false) 59 | subject.load 60 | expect(subject.changed?).to eq(false) 61 | subject.delete("assets/file-2-34567.jpg") 62 | expect(subject.changed?).to eq(true) 63 | subject.save 64 | expect(subject.changed?).to eq(false) 65 | end 66 | end 67 | 68 | describe "#save" do 69 | it "does not make put_object request to S3 when manifest has not changed" do 70 | subject.load 71 | expect(s3_client_instance).to_not receive(:put_object) 72 | expect(subject.save) 73 | end 74 | 75 | it "does makes put_object request to S3 when manifest has changed" do 76 | subject.load 77 | subject["myfile"] = Time.now.utc.iso8601 78 | expect(s3_client_instance).to receive(:put_object) 79 | expect(subject.save) 80 | end 81 | end 82 | 83 | describe "#load" do 84 | let(:s3_client_instance) do 85 | s3_client.new do 86 | { 87 | "assets/file-2-34567.jpg" => (Time.now - 172801).utc.iso8601, 88 | "assets/file-3-9876666.jpg" => (Time.now - 172799).utc.iso8601 89 | } 90 | end 91 | end 92 | 93 | it "loads manifest" do 94 | Timecop.freeze(Time.now) do 95 | expect(subject.loaded?).to eq(false) 96 | expect(subject.to_h).to eq({}) 97 | expect(subject.keys).to eq([]) 98 | subject.load 99 | expect(subject.loaded?).to eq(true) 100 | expect(subject.to_h).to eq({ 101 | "assets/file-2-34567.jpg" => (Time.now - 172801).utc.iso8601, 102 | "assets/file-3-9876666.jpg" => (Time.now - 172799).utc.iso8601 103 | }) 104 | expect(subject.keys).to eq([ 105 | "assets/file-2-34567.jpg", 106 | "assets/file-3-9876666.jpg" 107 | ]) 108 | end 109 | end 110 | 111 | it "won't refetch after initial load" do 112 | subject.load 113 | subject["my-file"] = Time.now.utc.iso8601 114 | subject.load 115 | expect(subject.keys).to contain_exactly( 116 | "assets/file-2-34567.jpg", 117 | "assets/file-3-9876666.jpg", 118 | "my-file" 119 | ) 120 | end 121 | 122 | context "with no manifest in S3 bucket" do 123 | let(:s3_client_instance) do 124 | s3_client.new do 125 | raise Aws::S3::Errors::NoSuchKey.new(nil, nil) 126 | end 127 | end 128 | 129 | it "initializes manfiest to empty hash when no manifest in S3 bucket" do 130 | subject.load 131 | expect(subject.to_h).to eq({}) 132 | expect(subject.loaded?).to eq(true) 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "s3_asset_deploy" 4 | require "ostruct" 5 | require "time" 6 | require "timecop" 7 | require "pry" 8 | require "helpers" 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.include Helpers 22 | end 23 | --------------------------------------------------------------------------------