middleman-s3_sync 4.6.4 → 4.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Changelog.md +12 -0
- data/WARP.md +5 -1
- data/lib/middleman/s3_sync/cloudfront.rb +50 -26
- data/lib/middleman/s3_sync/resource.rb +46 -18
- data/lib/middleman/s3_sync/version.rb +1 -1
- data/lib/middleman/s3_sync.rb +47 -13
- data/lib/middleman-s3_sync/extension.rb +19 -5
- data/spec/aws_sdk_parameters_spec.rb +16 -6
- data/spec/cloudfront_spec.rb +2 -0
- data/spec/indifferent_hash_spec.rb +278 -0
- data/spec/s3_sync_integration_spec.rb +70 -4
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae6310b6bdcb35bb9bdeb6b35b5a5471321cd2aa31e2301cb2b85bb40f06c6d8
|
|
4
|
+
data.tar.gz: d0a22baf1a2f20e5e2ea4ffa0a727042ad42b8d0234ee058925a83c85557705a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 133e5c9a90cd90800cb4c63e443a529b5ea7d0c3d2f803972b58b0ccc01a0af4c0188932f7d0bb3030e42d157c7722f3e8e2cc8c0757c9d5207075fe016653ad
|
|
7
|
+
data.tar.gz: 0d1438c52b583df33a8bce0ac0faab99d13675470092c70facd356508e096c55fdfc4d64e4bb2a58bdc03ba3cda88afcc7b378f3f5f58374939bd678b46f1de2
|
data/Changelog.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
The gem that tries really hard not to push files to S3.
|
|
4
4
|
|
|
5
|
+
## v4.6.5
|
|
6
|
+
- Performance and stability improvements
|
|
7
|
+
- Thread-safe invalidation path tracking (use Set + mutex) when running in parallel
|
|
8
|
+
- Cache CloudFront client (with reset hook for tests)
|
|
9
|
+
- Single-pass resource categorization (reduce multiple iterations over resources)
|
|
10
|
+
- Batch S3 deletes via delete_objects (up to 1000 keys/request)
|
|
11
|
+
- Stream file uploads to reduce memory; compute MD5s in a single read when possible
|
|
12
|
+
- Optimize CloudFront path deduplication to O(n × path_depth)
|
|
13
|
+
- CLI/extension: support option writers (e.g., verbose=, dry_run=) to fix NoMethodError
|
|
14
|
+
- Tests: add coverage for CloudFront, batch delete, and streaming uploads
|
|
15
|
+
- No breaking changes; default behavior preserved
|
|
16
|
+
|
|
5
17
|
## v4.6.4
|
|
6
18
|
* Remove map gem dependency and replace with native Ruby implementation
|
|
7
19
|
* Add IndifferentHash class to provide string/symbol indifferent access without external dependencies
|
data/WARP.md
CHANGED
|
@@ -100,6 +100,10 @@ The gem determines what to do with each file by comparing:
|
|
|
100
100
|
- **`config.rb`**: Middleman configuration with `activate :s3_sync` block
|
|
101
101
|
- **Environment Variables**: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET, etc.
|
|
102
102
|
|
|
103
|
+
## Development Rules
|
|
104
|
+
|
|
105
|
+
**All changes must be accompanied with unit tests.** This is non-negotiable for maintaining code quality and preventing regressions.
|
|
106
|
+
|
|
103
107
|
## Common Development Patterns
|
|
104
108
|
|
|
105
109
|
When adding new functionality:
|
|
@@ -109,4 +113,4 @@ When adding new functionality:
|
|
|
109
113
|
4. Add comprehensive specs following existing patterns
|
|
110
114
|
5. Update README.md with new configuration options
|
|
111
115
|
|
|
112
|
-
The codebase emphasizes security (credential handling), efficiency (parallel operations), and reliability (comprehensive error handling and dry-run support).
|
|
116
|
+
The codebase emphasizes security (credential handling), efficiency (parallel operations), and reliability (comprehensive error handling and dry-run support).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'aws-sdk-cloudfront'
|
|
2
2
|
require 'securerandom'
|
|
3
|
+
require 'set'
|
|
3
4
|
|
|
4
5
|
module Middleman
|
|
5
6
|
module S3Sync
|
|
@@ -101,25 +102,42 @@ module Middleman
|
|
|
101
102
|
# Sort paths to ensure wildcards come before specific files
|
|
102
103
|
sorted_paths = paths.sort
|
|
103
104
|
result = []
|
|
105
|
+
# Use a Set for O(1) lookup of wildcard prefixes
|
|
106
|
+
wildcard_prefixes = Set.new
|
|
104
107
|
|
|
105
108
|
sorted_paths.each do |path|
|
|
106
|
-
# Check if this path is
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
# Check if this path is covered by any existing wildcard prefix
|
|
110
|
+
# by checking all parent directories of this path
|
|
111
|
+
is_redundant = path_covered_by_wildcard?(path, wildcard_prefixes)
|
|
112
|
+
|
|
113
|
+
unless is_redundant
|
|
114
|
+
result << path
|
|
115
|
+
# If this is a wildcard path, add its prefix for future lookups
|
|
116
|
+
if path.end_with?('/*')
|
|
117
|
+
wildcard_prefixes.add(path[0..-3]) # Remove /*
|
|
114
118
|
end
|
|
115
119
|
end
|
|
116
|
-
|
|
117
|
-
result << path unless is_redundant
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
result
|
|
121
123
|
end
|
|
122
124
|
|
|
125
|
+
# Check if a path is covered by any wildcard prefix in O(path_depth) time
|
|
126
|
+
def path_covered_by_wildcard?(path, wildcard_prefixes)
|
|
127
|
+
return false if wildcard_prefixes.empty?
|
|
128
|
+
|
|
129
|
+
# Check each parent directory of the path
|
|
130
|
+
segments = path.split('/')
|
|
131
|
+
current_path = ''
|
|
132
|
+
|
|
133
|
+
segments[0..-2].each do |segment| # Exclude the last segment
|
|
134
|
+
current_path = current_path.empty? ? segment : "#{current_path}/#{segment}"
|
|
135
|
+
return true if wildcard_prefixes.include?(current_path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
123
141
|
def create_invalidation_with_retry(paths, options)
|
|
124
142
|
max_retries = options.cloudfront_invalidation_max_retries || 5
|
|
125
143
|
retries = 0
|
|
@@ -161,24 +179,30 @@ module Middleman
|
|
|
161
179
|
end
|
|
162
180
|
|
|
163
181
|
def cloudfront_client(options)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
@cloudfront_client ||= begin
|
|
183
|
+
client_options = {
|
|
184
|
+
region: 'us-east-1' # CloudFront is always in us-east-1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Use the same credentials as S3 if available
|
|
188
|
+
if options.aws_access_key_id && options.aws_secret_access_key
|
|
189
|
+
client_options.merge!({
|
|
190
|
+
access_key_id: options.aws_access_key_id,
|
|
191
|
+
secret_access_key: options.aws_secret_access_key
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# If using an assumed role
|
|
195
|
+
client_options.merge!({
|
|
196
|
+
session_token: options.aws_session_token
|
|
197
|
+
}) if options.aws_session_token
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Aws::CloudFront::Client.new(client_options)
|
|
179
201
|
end
|
|
202
|
+
end
|
|
180
203
|
|
|
181
|
-
|
|
204
|
+
def reset_cloudfront_client!
|
|
205
|
+
@cloudfront_client = nil
|
|
182
206
|
end
|
|
183
207
|
|
|
184
208
|
def wait_for_invalidations(invalidation_ids, options)
|
|
@@ -116,20 +116,25 @@ module Middleman
|
|
|
116
116
|
|
|
117
117
|
def upload!
|
|
118
118
|
object = bucket.object(remote_path.sub(/^\//, ''))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
119
|
+
|
|
120
|
+
# Use streaming upload for memory efficiency with large files
|
|
121
|
+
File.open(local_path, 'rb') do |file|
|
|
122
|
+
upload_options = build_upload_options_for_stream(file)
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
object.put(upload_options)
|
|
126
|
+
rescue Aws::S3::Errors::AccessControlListNotSupported => e
|
|
127
|
+
# Bucket has ACLs disabled - retry without ACL
|
|
128
|
+
if upload_options.key?(:acl)
|
|
129
|
+
say_status "#{ANSI.yellow{"Note"}} Bucket does not support ACLs, retrying without ACL parameter"
|
|
130
|
+
# Automatically disable ACLs for this bucket going forward
|
|
131
|
+
options.acl = ''
|
|
132
|
+
upload_options.delete(:acl)
|
|
133
|
+
file.rewind # Reset file position for retry
|
|
134
|
+
retry
|
|
135
|
+
else
|
|
136
|
+
raise e
|
|
137
|
+
end
|
|
133
138
|
end
|
|
134
139
|
end
|
|
135
140
|
end
|
|
@@ -278,12 +283,24 @@ module Middleman
|
|
|
278
283
|
end
|
|
279
284
|
|
|
280
285
|
def local_object_md5
|
|
281
|
-
@local_object_md5 ||=
|
|
286
|
+
@local_object_md5 ||= begin
|
|
287
|
+
# When not gzipped, compute both MD5s in single read to avoid redundant I/O
|
|
288
|
+
if !gzipped && local_path == original_path
|
|
289
|
+
compute_md5s_single_read
|
|
290
|
+
@local_object_md5
|
|
291
|
+
else
|
|
292
|
+
Digest::MD5.hexdigest(File.read(local_path))
|
|
293
|
+
end
|
|
294
|
+
end
|
|
282
295
|
end
|
|
283
296
|
|
|
284
297
|
def local_content_md5
|
|
285
298
|
@local_content_md5 ||= begin
|
|
286
|
-
|
|
299
|
+
# When not gzipped, compute both MD5s in single read to avoid redundant I/O
|
|
300
|
+
if !gzipped && local_path == original_path
|
|
301
|
+
compute_md5s_single_read
|
|
302
|
+
@local_content_md5
|
|
303
|
+
elsif File.exist?(original_path)
|
|
287
304
|
Digest::MD5.hexdigest(File.read(original_path))
|
|
288
305
|
else
|
|
289
306
|
nil
|
|
@@ -291,6 +308,16 @@ module Middleman
|
|
|
291
308
|
end
|
|
292
309
|
end
|
|
293
310
|
|
|
311
|
+
# Compute both MD5s from a single file read when they're the same file
|
|
312
|
+
def compute_md5s_single_read
|
|
313
|
+
return if @md5s_computed
|
|
314
|
+
content = File.read(local_path)
|
|
315
|
+
md5 = Digest::MD5.hexdigest(content)
|
|
316
|
+
@local_object_md5 = md5
|
|
317
|
+
@local_content_md5 = md5
|
|
318
|
+
@md5s_computed = true
|
|
319
|
+
end
|
|
320
|
+
|
|
294
321
|
def original_path
|
|
295
322
|
gzipped ? local_path.gsub(/\.gz$/, '') : local_path
|
|
296
323
|
end
|
|
@@ -314,9 +341,10 @@ module Middleman
|
|
|
314
341
|
|
|
315
342
|
protected
|
|
316
343
|
|
|
317
|
-
|
|
344
|
+
# Build upload options with a file stream as the body
|
|
345
|
+
def build_upload_options_for_stream(file_stream)
|
|
318
346
|
upload_options = {
|
|
319
|
-
body:
|
|
347
|
+
body: file_stream,
|
|
320
348
|
content_type: content_type
|
|
321
349
|
}
|
|
322
350
|
# Only add ACL if enabled (not for buckets with ACLs disabled)
|
data/lib/middleman/s3_sync.rb
CHANGED
|
@@ -11,6 +11,7 @@ require 'middleman/redirect'
|
|
|
11
11
|
require 'parallel'
|
|
12
12
|
require 'ruby-progressbar'
|
|
13
13
|
require 'thread'
|
|
14
|
+
require 'set'
|
|
14
15
|
|
|
15
16
|
module Middleman
|
|
16
17
|
module S3Sync
|
|
@@ -20,6 +21,7 @@ module Middleman
|
|
|
20
21
|
|
|
21
22
|
@@bucket_lock = Mutex.new
|
|
22
23
|
@@bucket_files_lock = Mutex.new
|
|
24
|
+
@@invalidation_paths_lock = Mutex.new
|
|
23
25
|
|
|
24
26
|
attr_accessor :s3_sync_options
|
|
25
27
|
attr_accessor :mm_resources
|
|
@@ -28,11 +30,12 @@ module Middleman
|
|
|
28
30
|
THREADS_COUNT = 8
|
|
29
31
|
|
|
30
32
|
# Track paths that were changed during sync for CloudFront invalidation
|
|
31
|
-
|
|
33
|
+
# Using a Set for O(1) lookups
|
|
34
|
+
attr_reader :invalidation_paths
|
|
32
35
|
|
|
33
36
|
def sync()
|
|
34
37
|
@app ||= ::Middleman::Application.new
|
|
35
|
-
@invalidation_paths =
|
|
38
|
+
@invalidation_paths = Set.new
|
|
36
39
|
|
|
37
40
|
say_status "Let's see if there's work to be done..."
|
|
38
41
|
unless work_to_be_done?
|
|
@@ -58,7 +61,7 @@ module Middleman
|
|
|
58
61
|
|
|
59
62
|
# Invalidate CloudFront cache if requested
|
|
60
63
|
if s3_sync_options.cloudfront_invalidate
|
|
61
|
-
CloudFront.invalidate(@invalidation_paths, s3_sync_options)
|
|
64
|
+
CloudFront.invalidate(@invalidation_paths.to_a, s3_sync_options)
|
|
62
65
|
end
|
|
63
66
|
end
|
|
64
67
|
|
|
@@ -77,10 +80,12 @@ module Middleman
|
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
def add_invalidation_path(path)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
@@invalidation_paths_lock.synchronize do
|
|
84
|
+
@invalidation_paths ||= Set.new
|
|
85
|
+
# Normalize path for CloudFront (ensure it starts with /)
|
|
86
|
+
normalized_path = path.start_with?('/') ? path : "/#{path}"
|
|
87
|
+
@invalidation_paths.add(normalized_path)
|
|
88
|
+
end
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
def remote_only_paths
|
|
@@ -204,10 +209,22 @@ module Middleman
|
|
|
204
209
|
end
|
|
205
210
|
|
|
206
211
|
def delete_resources
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
resources = files_to_delete
|
|
213
|
+
return if resources.empty?
|
|
214
|
+
|
|
215
|
+
# Print status messages for all resources being deleted
|
|
216
|
+
resources.each do |resource|
|
|
217
|
+
say_status "#{ANSI.red{"Deleting"}} #{resource.remote_path}"
|
|
209
218
|
add_invalidation_path(resource.path)
|
|
210
219
|
end
|
|
220
|
+
|
|
221
|
+
# Batch delete using S3's delete_objects API (up to 1000 objects per request)
|
|
222
|
+
unless s3_sync_options.dry_run
|
|
223
|
+
resources.each_slice(1000) do |batch|
|
|
224
|
+
objects_to_delete = batch.map { |r| { key: r.remote_path.sub(/^\//, '') } }
|
|
225
|
+
bucket.delete_objects(delete: { objects: objects_to_delete })
|
|
226
|
+
end
|
|
227
|
+
end
|
|
211
228
|
end
|
|
212
229
|
|
|
213
230
|
def ignore_resources
|
|
@@ -224,24 +241,41 @@ module Middleman
|
|
|
224
241
|
!(files_to_create.empty? && files_to_update.empty? && files_to_delete.empty?)
|
|
225
242
|
end
|
|
226
243
|
|
|
244
|
+
# Single-pass categorization of resources by status
|
|
245
|
+
# Avoids multiple iterations over s3_sync_resources
|
|
246
|
+
def categorized_resources
|
|
247
|
+
@categorized_resources ||= begin
|
|
248
|
+
result = { create: [], update: [], delete: [], ignore: [] }
|
|
249
|
+
s3_sync_resources.values.each do |resource|
|
|
250
|
+
case
|
|
251
|
+
when resource.to_create? then result[:create] << resource
|
|
252
|
+
when resource.to_update? then result[:update] << resource
|
|
253
|
+
when resource.to_delete? then result[:delete] << resource
|
|
254
|
+
when resource.to_ignore? then result[:ignore] << resource
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
result
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
227
261
|
def files_to_delete
|
|
228
262
|
if s3_sync_options.delete
|
|
229
|
-
|
|
263
|
+
categorized_resources[:delete]
|
|
230
264
|
else
|
|
231
265
|
[]
|
|
232
266
|
end
|
|
233
267
|
end
|
|
234
268
|
|
|
235
269
|
def files_to_create
|
|
236
|
-
|
|
270
|
+
categorized_resources[:create]
|
|
237
271
|
end
|
|
238
272
|
|
|
239
273
|
def files_to_update
|
|
240
|
-
|
|
274
|
+
categorized_resources[:update]
|
|
241
275
|
end
|
|
242
276
|
|
|
243
277
|
def files_to_ignore
|
|
244
|
-
|
|
278
|
+
categorized_resources[:ignore]
|
|
245
279
|
end
|
|
246
280
|
|
|
247
281
|
def build_dir
|
|
@@ -87,16 +87,30 @@ module Middleman
|
|
|
87
87
|
true
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Delegate option readers to the options object
|
|
90
|
+
# Delegate option readers and writers to the options object
|
|
91
91
|
def method_missing(method, *args, &block)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
method_str = method.to_s
|
|
93
|
+
|
|
94
|
+
# Handle setter methods (e.g., verbose=)
|
|
95
|
+
if method_str.end_with?('=')
|
|
96
|
+
option_name = method_str.chomp('=').to_sym
|
|
97
|
+
if options.respond_to?(option_name)
|
|
98
|
+
options[option_name] = args.first
|
|
99
|
+
return args.first
|
|
100
|
+
end
|
|
101
|
+
elsif options.respond_to?(method)
|
|
102
|
+
return options.send(method, *args, &block)
|
|
96
103
|
end
|
|
104
|
+
|
|
105
|
+
super
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def respond_to_missing?(method, include_private = false)
|
|
109
|
+
method_str = method.to_s
|
|
110
|
+
if method_str.end_with?('=')
|
|
111
|
+
option_name = method_str.chomp('=').to_sym
|
|
112
|
+
return options.respond_to?(option_name)
|
|
113
|
+
end
|
|
100
114
|
options.respond_to?(method) || super
|
|
101
115
|
end
|
|
102
116
|
|
|
@@ -128,14 +128,17 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
128
128
|
allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
|
|
129
129
|
allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
|
|
130
130
|
allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
|
|
131
|
+
# Stub File.open to return a StringIO for streaming upload tests
|
|
132
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
|
|
131
133
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
132
134
|
options.dry_run = false
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
it 'uses correct metadata key format' do
|
|
136
138
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
137
|
-
# Verify
|
|
138
|
-
expect(upload_options[:body]).to
|
|
139
|
+
# Verify body is a readable IO object (for streaming)
|
|
140
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
141
|
+
expect(upload_options[:body].read).to eq('test content')
|
|
139
142
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
140
143
|
expect(upload_options[:acl]).to eq('public-read')
|
|
141
144
|
|
|
@@ -160,7 +163,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
160
163
|
it 'does not include acl parameter in upload' do
|
|
161
164
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
162
165
|
expect(upload_options).not_to have_key(:acl)
|
|
163
|
-
expect(upload_options[:body]).to
|
|
166
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
164
167
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
165
168
|
end
|
|
166
169
|
|
|
@@ -176,7 +179,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
176
179
|
it 'does not include acl parameter in upload' do
|
|
177
180
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
178
181
|
expect(upload_options).not_to have_key(:acl)
|
|
179
|
-
expect(upload_options[:body]).to
|
|
182
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
180
183
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
181
184
|
end
|
|
182
185
|
|
|
@@ -192,6 +195,10 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
192
195
|
|
|
193
196
|
it 'automatically retries without ACL when AccessControlListNotSupported error occurs' do
|
|
194
197
|
call_count = 0
|
|
198
|
+
# Use a reusable StringIO that can be rewound
|
|
199
|
+
file_io = StringIO.new('test content')
|
|
200
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(file_io)
|
|
201
|
+
|
|
195
202
|
expect(s3_object).to receive(:put).twice do |upload_options|
|
|
196
203
|
call_count += 1
|
|
197
204
|
if call_count == 1
|
|
@@ -201,7 +208,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
201
208
|
else
|
|
202
209
|
# Second call should not include ACL
|
|
203
210
|
expect(upload_options).not_to have_key(:acl)
|
|
204
|
-
expect(upload_options[:body]).to
|
|
211
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
205
212
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
206
213
|
end
|
|
207
214
|
end
|
|
@@ -219,7 +226,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
219
226
|
expect(upload_options[:acl]).to eq('public-read')
|
|
220
227
|
raise Aws::S3::Errors::AccessControlListNotSupported.new(nil, 'The bucket does not allow ACLs')
|
|
221
228
|
else
|
|
222
|
-
#
|
|
229
|
+
# Subsequent calls should succeed without ACL
|
|
223
230
|
expect(upload_options).not_to have_key(:acl)
|
|
224
231
|
true
|
|
225
232
|
end
|
|
@@ -241,6 +248,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
241
248
|
allow(File).to receive(:read).with('build/test/file.html.gz').and_return('gzipped content')
|
|
242
249
|
allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
|
|
243
250
|
allow(File).to receive(:read).with('build/test/file.html').and_return('original content')
|
|
251
|
+
allow(File).to receive(:open).with('build/test/file.html.gz', 'rb').and_yield(StringIO.new('gzipped content'))
|
|
244
252
|
|
|
245
253
|
# Mock the HEAD response to avoid calling it during redirect?
|
|
246
254
|
head_response = double(
|
|
@@ -305,6 +313,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
305
313
|
allow(File).to receive(:exist?).with('build/redirect/file.html').and_return(true)
|
|
306
314
|
allow(File).to receive(:exist?).with('build/redirect/file.html.gz').and_return(false)
|
|
307
315
|
allow(File).to receive(:read).with('build/redirect/file.html').and_return('redirect content')
|
|
316
|
+
allow(File).to receive(:open).with('build/redirect/file.html', 'rb').and_yield(StringIO.new('redirect content'))
|
|
308
317
|
allow(File).to receive(:directory?).with('build/redirect/file.html').and_return(false)
|
|
309
318
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
310
319
|
allow(resource).to receive(:redirect?).and_return(true)
|
|
@@ -478,6 +487,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
478
487
|
allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
|
|
479
488
|
allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
|
|
480
489
|
allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
|
|
490
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
|
|
481
491
|
allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
|
|
482
492
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
483
493
|
options.dry_run = false
|
data/spec/cloudfront_spec.rb
CHANGED
|
@@ -27,6 +27,8 @@ describe Middleman::S3Sync::CloudFront do
|
|
|
27
27
|
|
|
28
28
|
before do
|
|
29
29
|
allow(described_class).to receive(:say_status)
|
|
30
|
+
# Reset cached CloudFront client between tests to prevent double leakage
|
|
31
|
+
described_class.send(:reset_cloudfront_client!)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
describe '.invalidate' do
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe Middleman::S3Sync::IndifferentHash do
|
|
4
|
+
let(:hash) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe 'string-indifferent key access' do
|
|
7
|
+
it 'allows setting and retrieving values using string keys' do
|
|
8
|
+
hash['foo'] = 'bar'
|
|
9
|
+
expect(hash['foo']).to eq('bar')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'retrieves string key values using symbol keys' do
|
|
13
|
+
hash['foo'] = 'bar'
|
|
14
|
+
expect(hash[:foo]).to eq('bar')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'normalizes string keys on retrieval' do
|
|
18
|
+
hash['foo'] = 'value1'
|
|
19
|
+
hash['foo'] = 'value2'
|
|
20
|
+
expect(hash['foo']).to eq('value2')
|
|
21
|
+
expect(hash.keys.count).to eq(1)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe 'symbol-indifferent key access' do
|
|
26
|
+
it 'allows setting and retrieving values using symbol keys' do
|
|
27
|
+
hash[:foo] = 'bar'
|
|
28
|
+
expect(hash[:foo]).to eq('bar')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'retrieves symbol key values using string keys' do
|
|
32
|
+
hash[:foo] = 'bar'
|
|
33
|
+
expect(hash['foo']).to eq('bar')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'normalizes symbol keys to strings' do
|
|
37
|
+
hash[:foo] = 'value1'
|
|
38
|
+
hash['foo'] = 'value2'
|
|
39
|
+
expect(hash[:foo]).to eq('value2')
|
|
40
|
+
expect(hash.keys.count).to eq(1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'stores keys as strings internally' do
|
|
44
|
+
hash[:foo] = 'bar'
|
|
45
|
+
expect(hash.keys).to eq(['foo'])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe 'dot notation access' do
|
|
50
|
+
it 'allows accessing values using dot notation' do
|
|
51
|
+
hash[:foo] = 'bar'
|
|
52
|
+
expect(hash.foo).to eq('bar')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'allows setting values using dot notation' do
|
|
56
|
+
hash.foo = 'baz'
|
|
57
|
+
expect(hash[:foo]).to eq('baz')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'works with string keys' do
|
|
61
|
+
hash['foo'] = 'bar'
|
|
62
|
+
expect(hash.foo).to eq('bar')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'raises NoMethodError for non-existent keys' do
|
|
66
|
+
expect { hash.nonexistent_key }.to raise_error(NoMethodError)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'supports respond_to? for existing keys' do
|
|
70
|
+
hash[:foo] = 'bar'
|
|
71
|
+
expect(hash).to respond_to(:foo)
|
|
72
|
+
expect(hash).to respond_to(:foo=)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'returns false for respond_to? on non-existent keys' do
|
|
76
|
+
expect(hash).not_to respond_to(:nonexistent)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe 'nested hashes with indifferent access' do
|
|
81
|
+
it 'handles nested hash values' do
|
|
82
|
+
hash[:outer] = { inner: 'value' }
|
|
83
|
+
expect(hash[:outer]).to eq({ inner: 'value' })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'allows nested IndifferentHash instances' do
|
|
87
|
+
inner = described_class.new
|
|
88
|
+
inner[:foo] = 'bar'
|
|
89
|
+
hash[:outer] = inner
|
|
90
|
+
|
|
91
|
+
expect(hash[:outer][:foo]).to eq('bar')
|
|
92
|
+
expect(hash[:outer].foo).to eq('bar')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'supports multiple levels of nesting' do
|
|
96
|
+
inner = described_class.new
|
|
97
|
+
inner[:level2] = 'deep'
|
|
98
|
+
hash[:level1] = inner
|
|
99
|
+
|
|
100
|
+
expect(hash['level1']['level2']).to eq('deep')
|
|
101
|
+
expect(hash[:level1][:level2]).to eq('deep')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe 'Hash method compatibility' do
|
|
106
|
+
describe '#has_key?' do
|
|
107
|
+
it 'works with string keys' do
|
|
108
|
+
hash['foo'] = 'bar'
|
|
109
|
+
expect(hash.has_key?('foo')).to be true
|
|
110
|
+
expect(hash.has_key?(:foo)).to be true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'works with symbol keys' do
|
|
114
|
+
hash[:foo] = 'bar'
|
|
115
|
+
expect(hash.has_key?('foo')).to be true
|
|
116
|
+
expect(hash.has_key?(:foo)).to be true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'returns false for non-existent keys' do
|
|
120
|
+
expect(hash.has_key?('nonexistent')).to be false
|
|
121
|
+
expect(hash.has_key?(:nonexistent)).to be false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '#key?' do
|
|
126
|
+
it 'is aliased to has_key?' do
|
|
127
|
+
hash[:foo] = 'bar'
|
|
128
|
+
expect(hash.key?('foo')).to be true
|
|
129
|
+
expect(hash.key?(:foo)).to be true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe '#include?' do
|
|
134
|
+
it 'is aliased to has_key?' do
|
|
135
|
+
hash[:foo] = 'bar'
|
|
136
|
+
expect(hash.include?('foo')).to be true
|
|
137
|
+
expect(hash.include?(:foo)).to be true
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#fetch' do
|
|
142
|
+
it 'fetches values with string keys' do
|
|
143
|
+
hash['foo'] = 'bar'
|
|
144
|
+
expect(hash.fetch('foo')).to eq('bar')
|
|
145
|
+
expect(hash.fetch(:foo)).to eq('bar')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'fetches values with symbol keys' do
|
|
149
|
+
hash[:foo] = 'bar'
|
|
150
|
+
expect(hash.fetch('foo')).to eq('bar')
|
|
151
|
+
expect(hash.fetch(:foo)).to eq('bar')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'returns default value for missing keys' do
|
|
155
|
+
expect(hash.fetch('missing', 'default')).to eq('default')
|
|
156
|
+
expect(hash.fetch(:missing, 'default')).to eq('default')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'calls block for missing keys' do
|
|
160
|
+
result = hash.fetch('missing') { |key| "Key #{key} not found" }
|
|
161
|
+
expect(result).to eq('Key missing not found')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'raises KeyError when key is missing without default' do
|
|
165
|
+
expect { hash.fetch('missing') }.to raise_error(KeyError)
|
|
166
|
+
expect { hash.fetch(:missing) }.to raise_error(KeyError)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '.from_hash' do
|
|
172
|
+
it 'creates an IndifferentHash from a regular hash' do
|
|
173
|
+
regular_hash = { 'foo' => 'bar', 'baz' => 'qux' }
|
|
174
|
+
result = described_class.from_hash(regular_hash)
|
|
175
|
+
|
|
176
|
+
expect(result).to be_a(described_class)
|
|
177
|
+
expect(result['foo']).to eq('bar')
|
|
178
|
+
expect(result[:foo]).to eq('bar')
|
|
179
|
+
expect(result['baz']).to eq('qux')
|
|
180
|
+
expect(result[:baz]).to eq('qux')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'handles hashes with symbol keys' do
|
|
184
|
+
regular_hash = { foo: 'bar', baz: 'qux' }
|
|
185
|
+
result = described_class.from_hash(regular_hash)
|
|
186
|
+
|
|
187
|
+
expect(result[:foo]).to eq('bar')
|
|
188
|
+
expect(result['foo']).to eq('bar')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'handles empty hashes' do
|
|
192
|
+
result = described_class.from_hash({})
|
|
193
|
+
expect(result).to be_a(described_class)
|
|
194
|
+
expect(result).to be_empty
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it 'handles mixed string and symbol keys' do
|
|
198
|
+
regular_hash = { 'string_key' => 'value1', :symbol_key => 'value2' }
|
|
199
|
+
result = described_class.from_hash(regular_hash)
|
|
200
|
+
|
|
201
|
+
expect(result['string_key']).to eq('value1')
|
|
202
|
+
expect(result[:string_key]).to eq('value1')
|
|
203
|
+
expect(result['symbol_key']).to eq('value2')
|
|
204
|
+
expect(result[:symbol_key]).to eq('value2')
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe 'map gem API compatibility' do
|
|
209
|
+
# These tests ensure no breaking changes compared to the map gem
|
|
210
|
+
|
|
211
|
+
it 'supports basic key-value storage' do
|
|
212
|
+
hash[:key] = 'value'
|
|
213
|
+
expect(hash[:key]).to eq('value')
|
|
214
|
+
expect(hash['key']).to eq('value')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'supports dot notation like map gem' do
|
|
218
|
+
hash.max_age = 3600
|
|
219
|
+
expect(hash.max_age).to eq(3600)
|
|
220
|
+
expect(hash[:max_age]).to eq(3600)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'supports fetch with default values' do
|
|
224
|
+
expect(hash.fetch(:missing, 'default')).to eq('default')
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'supports has_key? checks' do
|
|
228
|
+
hash[:present] = 'value'
|
|
229
|
+
expect(hash.has_key?(:present)).to be true
|
|
230
|
+
expect(hash.has_key?('present')).to be true
|
|
231
|
+
expect(hash.has_key?(:absent)).to be false
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'maintains Hash inheritance' do
|
|
235
|
+
expect(hash).to be_a(Hash)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'supports standard Hash operations' do
|
|
239
|
+
hash[:a] = 1
|
|
240
|
+
hash[:b] = 2
|
|
241
|
+
|
|
242
|
+
expect(hash.keys.sort).to eq(['a', 'b'])
|
|
243
|
+
expect(hash.values.sort).to eq([1, 2])
|
|
244
|
+
expect(hash.size).to eq(2)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it 'supports iteration' do
|
|
248
|
+
hash[:a] = 1
|
|
249
|
+
hash[:b] = 2
|
|
250
|
+
|
|
251
|
+
result = []
|
|
252
|
+
hash.each { |k, v| result << [k, v] }
|
|
253
|
+
expect(result).to contain_exactly(['a', 1], ['b', 2])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
context 'usage in BrowserCachePolicy' do
|
|
257
|
+
it 'supports caching policy access patterns' do
|
|
258
|
+
# Mimics how BrowserCachePolicy uses the hash
|
|
259
|
+
hash[:max_age] = 3600
|
|
260
|
+
hash[:s_maxage] = 7200
|
|
261
|
+
hash[:public] = true
|
|
262
|
+
hash[:no_cache] = false
|
|
263
|
+
|
|
264
|
+
expect(hash.has_key?(:max_age)).to be true
|
|
265
|
+
expect(hash.has_key?('s_maxage')).to be true
|
|
266
|
+
expect(hash.fetch(:public, false)).to be true
|
|
267
|
+
expect(hash.fetch(:private, false)).to be false
|
|
268
|
+
expect(hash.fetch('must_revalidate', false)).to be false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it 'supports mixed string and symbol key access like in caching_policy.rb' do
|
|
272
|
+
hash[:max_age] = 3600
|
|
273
|
+
expect(hash['max_age']).to eq(3600)
|
|
274
|
+
expect(hash.max_age).to eq(3600)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -36,7 +36,7 @@ describe 'S3Sync CloudFront Integration' do
|
|
|
36
36
|
describe 'CloudFront invalidation integration' do
|
|
37
37
|
it 'calls CloudFront invalidation after sync operations' do
|
|
38
38
|
# Reset invalidation paths for this test
|
|
39
|
-
Middleman::S3Sync.instance_variable_set(:@invalidation_paths,
|
|
39
|
+
Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
|
|
40
40
|
|
|
41
41
|
expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
|
|
42
42
|
[], # Initially empty, gets populated during resource operations
|
|
@@ -104,7 +104,7 @@ describe 'S3Sync CloudFront Integration' do
|
|
|
104
104
|
describe 'path tracking during resource operations' do
|
|
105
105
|
before do
|
|
106
106
|
# Reset invalidation paths before each path tracking test
|
|
107
|
-
Middleman::S3Sync.instance_variable_set(:@invalidation_paths,
|
|
107
|
+
Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
it 'adds paths to invalidation list when resources are processed' do
|
|
@@ -126,7 +126,73 @@ describe 'S3Sync CloudFront Integration' do
|
|
|
126
126
|
Middleman::S3Sync.add_invalidation_path('/same/path.html')
|
|
127
127
|
Middleman::S3Sync.add_invalidation_path('/same/path.html')
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
# Set automatically handles uniqueness - verify it contains exactly one occurrence
|
|
130
|
+
expect(Middleman::S3Sync.invalidation_paths.to_a.count('/same/path.html')).to eq(1)
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
|
-
|
|
133
|
+
|
|
134
|
+
describe 'batch delete operations' do
|
|
135
|
+
let(:bucket) { double('bucket') }
|
|
136
|
+
let(:resource1) { double('resource1', path: 'file1.html', remote_path: 'file1.html') }
|
|
137
|
+
let(:resource2) { double('resource2', path: 'file2.html', remote_path: 'file2.html') }
|
|
138
|
+
let(:resource3) { double('resource3', path: 'file3.html', remote_path: 'file3.html') }
|
|
139
|
+
|
|
140
|
+
before do
|
|
141
|
+
# Remove the stub for delete_resources so we test the actual implementation
|
|
142
|
+
allow(Middleman::S3Sync).to receive(:delete_resources).and_call_original
|
|
143
|
+
allow(Middleman::S3Sync).to receive(:say_status)
|
|
144
|
+
allow(Middleman::S3Sync).to receive(:bucket).and_return(bucket)
|
|
145
|
+
allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
|
|
146
|
+
Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
|
|
147
|
+
Middleman::S3Sync.instance_variable_set(:@categorized_resources, nil)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'uses batch delete_objects API instead of individual deletes' do
|
|
151
|
+
allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2, resource3])
|
|
152
|
+
|
|
153
|
+
expect(bucket).to receive(:delete_objects).with(
|
|
154
|
+
delete: {
|
|
155
|
+
objects: [
|
|
156
|
+
{ key: 'file1.html' },
|
|
157
|
+
{ key: 'file2.html' },
|
|
158
|
+
{ key: 'file3.html' }
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
Middleman::S3Sync.send(:delete_resources)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'adds invalidation paths for all deleted resources' do
|
|
167
|
+
allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2])
|
|
168
|
+
allow(bucket).to receive(:delete_objects)
|
|
169
|
+
|
|
170
|
+
Middleman::S3Sync.send(:delete_resources)
|
|
171
|
+
|
|
172
|
+
expect(Middleman::S3Sync.invalidation_paths).to include('/file1.html')
|
|
173
|
+
expect(Middleman::S3Sync.invalidation_paths).to include('/file2.html')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'does not call delete_objects during dry run' do
|
|
177
|
+
dry_run_options = double(
|
|
178
|
+
dry_run: true,
|
|
179
|
+
delete: true,
|
|
180
|
+
bucket: 'test-bucket'
|
|
181
|
+
)
|
|
182
|
+
allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(dry_run_options)
|
|
183
|
+
allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1])
|
|
184
|
+
|
|
185
|
+
expect(bucket).not_to receive(:delete_objects)
|
|
186
|
+
|
|
187
|
+
Middleman::S3Sync.send(:delete_resources)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'does nothing when there are no files to delete' do
|
|
191
|
+
allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
|
|
192
|
+
|
|
193
|
+
expect(bucket).not_to receive(:delete_objects)
|
|
194
|
+
|
|
195
|
+
Middleman::S3Sync.send(:delete_resources)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: middleman-s3_sync
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.6.
|
|
4
|
+
version: 4.6.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Frederic Jean
|
|
8
8
|
- Will Koehler
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-02-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: middleman-core
|
|
@@ -299,6 +299,7 @@ files:
|
|
|
299
299
|
- spec/caching_policy_spec.rb
|
|
300
300
|
- spec/cloudfront_spec.rb
|
|
301
301
|
- spec/extension_spec.rb
|
|
302
|
+
- spec/indifferent_hash_spec.rb
|
|
302
303
|
- spec/options_spec.rb
|
|
303
304
|
- spec/resource_spec.rb
|
|
304
305
|
- spec/s3_sync_integration_spec.rb
|
|
@@ -329,6 +330,7 @@ test_files:
|
|
|
329
330
|
- spec/caching_policy_spec.rb
|
|
330
331
|
- spec/cloudfront_spec.rb
|
|
331
332
|
- spec/extension_spec.rb
|
|
333
|
+
- spec/indifferent_hash_spec.rb
|
|
332
334
|
- spec/options_spec.rb
|
|
333
335
|
- spec/resource_spec.rb
|
|
334
336
|
- spec/s3_sync_integration_spec.rb
|