assiette 0.3.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66f3dd34f305d6fbfa1ab42498d834bb2820d83ee2d5a1b84cec5cc54f686873
4
- data.tar.gz: 4e824983daf15b8dad3eb6c4a9e9570b3fc9a2722575d21c31c8abd31e7fa70a
3
+ metadata.gz: 4f24fbf1362c382584825ebb9ea4adf6b0abf0e24ad0b00c0792810b469152be
4
+ data.tar.gz: 747cafe7a7af67efec260a8ff506eecb390b69225fd8aebf7ff52122175f7a4f
5
5
  SHA512:
6
- metadata.gz: a123ca3f4632f04c15b4deba0e71f8bcce05123ea380a1a7c5877aab92d1994b10fb5a79168f86d795edebcdbad2a2056659c9729a42fa8608e0e5a49e10a7bf
7
- data.tar.gz: 93273d9b24ee88b29d4fb7221e518a871e9931285c76c8bbc8c32af329d62a1a171e856068beb3fd0c337c8bf22bf6bc19ead2aac7ebb6a652d5c6671651d2bb
6
+ metadata.gz: df6146e2288d39315cd1d045e1af7c7b75e07fc89022d3288c7d52f544e01f4be7bdc1ee5a5a9fcf49d788cf967419801a7259e8477a57f3b3274a59475db1ad
7
+ data.tar.gz: d29c94ba6524db67f1c8f85a024c1648a9e1ea9ccf60078528ebcf24b1fbfd818181ce3c379b061519bfbd53a75ede042a15fda3734c47baddb7832cde3d4fd3
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest/sha1"
4
- require "digest/sha2"
5
- require "base64"
6
3
  require "pathname"
7
- require_relative "rewriter"
8
- require_relative "version_tag"
9
4
 
10
5
  module Assiette
11
6
  class AssetHandler
@@ -20,13 +15,28 @@ module Assiette
20
15
 
21
16
  JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
22
17
 
18
+ attr_reader :dependency_graph
19
+
23
20
  def initialize(root:, additional_directory_mappings: {})
24
21
  @mappings = build_mappings(root, additional_directory_mappings)
25
- @integrity_cache = {}
26
- @integrity_mutex = Mutex.new
27
- @modules_cache = nil
28
- @modules_mutex = Mutex.new
29
- @modules_version = nil
22
+ @dependency_graph = DependencyGraph.new(self)
23
+ end
24
+
25
+ # Yields (url_path, abs_path) for every file with a recognized extension.
26
+ def each_mapped_file
27
+ @mappings.each do |prefix, root|
28
+ CONTENT_TYPES.each_key do |ext|
29
+ Dir[File.join(root, "**/*#{ext}")].each do |abs|
30
+ relative = Pathname.new(abs).relative_path_from(root).to_s
31
+ url_path = if prefix.empty?
32
+ relative
33
+ else
34
+ "#{prefix}/#{relative}"
35
+ end
36
+ yield url_path, abs
37
+ end
38
+ end
39
+ end
30
40
  end
31
41
 
32
42
  def resolve_file(path)
@@ -52,40 +62,25 @@ module Assiette
52
62
  def absolute_asset_url_path(path, script_name = "")
53
63
  clean = path.sub(%r{\A/}, "")
54
64
  return nil unless resolve_file(clean)
55
- "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
65
+ hash = @dependency_graph.tree_sha(clean) || "00000000"
66
+ "#{script_name}/#{clean}?s=#{hash}"
56
67
  end
57
68
 
58
69
  def asset_integrity(path)
59
- version_tag = Assiette.version_tag
60
- @integrity_mutex.synchronize do
61
- if @integrity_version != version_tag
62
- @integrity_cache = {}
63
- @integrity_version = version_tag
64
- end
65
- return @integrity_cache[path] if @integrity_cache.key?(path)
66
-
67
- clean = path.sub(%r{\A/}, "")
68
- @integrity_cache[path] = compute_integrity(clean, version_tag)
69
- end
70
+ clean = path.sub(%r{\A/}, "")
71
+ return nil unless resolve_file(clean)
72
+ @dependency_graph.tree_integrity(clean)
70
73
  end
71
74
 
72
75
  def js_modules
73
- version_tag = Assiette.version_tag
74
- @modules_mutex.synchronize do
75
- return @modules_cache if @modules_version == version_tag
76
-
77
- @modules_cache = @mappings.flat_map { |prefix, root|
78
- Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
79
- next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
80
- relative = Pathname.new(abs).relative_path_from(root).to_s
81
- mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
82
- {path: mod_path, integrity: asset_integrity(mod_path)}
83
- }
84
- }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
85
-
86
- @modules_version = version_tag
87
- @modules_cache
88
- end
76
+ @mappings.flat_map { |prefix, root|
77
+ Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
78
+ next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
79
+ relative = Pathname.new(abs).relative_path_from(root).to_s
80
+ mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
81
+ {path: mod_path, integrity: asset_integrity(mod_path)}
82
+ }
83
+ }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
89
84
  end
90
85
 
91
86
  private
@@ -98,19 +93,5 @@ module Assiette
98
93
  end
99
94
  mappings
100
95
  end
101
-
102
- def compute_integrity(clean, version_tag)
103
- file_path = resolve_file(clean)
104
- return nil unless file_path
105
-
106
- raw = File.read(file_path)
107
- ext = File.extname(clean)
108
- served = case ext
109
- when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
110
- when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
111
- else raw
112
- end
113
- "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
114
- end
115
96
  end
116
97
  end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "base64"
5
+ require "pathname"
6
+
7
+ module Assiette
8
+ class DependencyGraph
9
+ # Per-file node in the dependency graph.
10
+ class Asset
11
+ attr_reader :url_path
12
+ attr_accessor :abs_path, :mtime, :digest, :deps, :dependents
13
+
14
+ def initialize(url_path:, abs_path:, mtime:)
15
+ @url_path = url_path.freeze
16
+ @abs_path = abs_path
17
+ @mtime = mtime
18
+ @digest = nil
19
+ @deps = []
20
+ @dependents = Set.new
21
+ end
22
+
23
+ # 8-char hex hash for ?s= cache-busting query params.
24
+ def checksum_tag
25
+ digest&.unpack1("H8")
26
+ end
27
+
28
+ # SRI integrity string for integrity= attributes.
29
+ def sri_integrity
30
+ return nil unless digest
31
+ "sha256-#{Base64.strict_encode64(digest)}"
32
+ end
33
+
34
+ # Whether this asset's file has changed since last scan.
35
+ def stale?
36
+ File.mtime(abs_path) != mtime
37
+ rescue Errno::ENOENT
38
+ true
39
+ end
40
+
41
+ # Whether the file no longer exists on disk.
42
+ def deleted?
43
+ !File.exist?(abs_path.to_s)
44
+ end
45
+ end
46
+
47
+ def initialize(handler)
48
+ @handler = handler
49
+ @mutex = Mutex.new
50
+ @assets = {}
51
+ end
52
+
53
+ # Returns the Asset for a url_path, or nil.
54
+ def [](url_path)
55
+ @mutex.synchronize do
56
+ ensure_asset!(url_path)
57
+ end
58
+ end
59
+
60
+ # Returns the 8-char hex content hash for a URL path.
61
+ def tree_sha(url_path)
62
+ self[url_path]&.checksum_tag
63
+ end
64
+
65
+ # Returns the SRI integrity string for a URL path.
66
+ def tree_integrity(url_path)
67
+ self[url_path]&.sri_integrity
68
+ end
69
+
70
+ # Resolves a relative import path to a URL path.
71
+ def resolve_import_for(from_url_path, relative_path)
72
+ if relative_path.start_with?("/")
73
+ relative_path.sub(%r{\A/}, "")
74
+ else
75
+ dir = File.dirname(from_url_path)
76
+ File.expand_path(relative_path, "/#{dir}").sub(%r{\A/}, "")
77
+ end
78
+ end
79
+
80
+ # Rewrites content with per-dependency hashes. Thread-safe.
81
+ def rewrite_content(url_path, raw_content)
82
+ @mutex.synchronize do
83
+ ensure_asset!(url_path)
84
+ rewrite_content_internal(url_path, raw_content)
85
+ end
86
+ end
87
+
88
+ # Forces a full rebuild on next access.
89
+ def invalidate!
90
+ @mutex.synchronize do
91
+ @assets = {}
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Lazily resolve a single asset and its transitive dependencies.
98
+ # Returns the Asset or nil if the file doesn't exist.
99
+ def ensure_asset!(url_path)
100
+ @resolving = Set.new
101
+ @checked = Set.new
102
+ @cycle_groups = []
103
+ asset = resolve_asset!(url_path)
104
+ # Process any cycles detected during resolution
105
+ @cycle_groups.each { |scc| compute_cycle_digests(scc) }
106
+ @resolving = nil
107
+ @checked = nil
108
+ @cycle_groups = nil
109
+ asset
110
+ end
111
+
112
+ # Recursive resolution. Detects cycles via @resolving set.
113
+ # @checked prevents re-verifying the same asset within one ensure_asset! call.
114
+ # Returns the Asset or nil. Also returns whether anything changed downstream.
115
+ def resolve_asset!(url_path)
116
+ # Already verified fresh in this ensure_asset! call
117
+ return @assets[url_path] if @checked.include?(url_path)
118
+
119
+ existing = @assets[url_path]
120
+ if existing
121
+ if existing.deleted?
122
+ remove_asset!(url_path)
123
+ return nil
124
+ end
125
+
126
+ if existing.stale?
127
+ rescan_asset!(existing)
128
+ @checked << url_path
129
+ return existing
130
+ end
131
+
132
+ # Not stale itself — but deps might be. Check them recursively.
133
+ @checked << url_path
134
+ old_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
135
+ existing.deps.each { |dp| resolve_asset!(dp) }
136
+ new_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
137
+
138
+ if old_dep_digests != new_dep_digests
139
+ compute_digest_for(url_path)
140
+ end
141
+
142
+ return existing
143
+ end
144
+
145
+ # Resolve url_path to an absolute path
146
+ abs_path = @handler.resolve_file(url_path)
147
+ return nil unless abs_path
148
+
149
+ # Cycle detection
150
+ unless @resolving.add?(url_path)
151
+ return @assets[url_path]
152
+ end
153
+
154
+ # Create the asset node
155
+ asset = @assets[url_path] = Asset.new(
156
+ url_path: url_path,
157
+ abs_path: abs_path,
158
+ mtime: File.mtime(abs_path)
159
+ )
160
+ asset.deps = parse_deps(url_path, File.read(abs_path))
161
+
162
+ # Recursively resolve dependencies
163
+ in_cycle = false
164
+ asset.deps.each do |dep_path|
165
+ if @resolving.include?(dep_path) && !@assets[dep_path]&.digest
166
+ in_cycle = true
167
+ end
168
+ dep = resolve_asset!(dep_path)
169
+ dep.dependents << url_path if dep
170
+ end
171
+
172
+ if in_cycle || asset.deps.any? { |dp| @resolving.include?(dp) && !@assets[dp]&.digest }
173
+ scc = collect_cycle(url_path)
174
+ @cycle_groups << scc unless scc.empty?
175
+ else
176
+ compute_digest_for(url_path)
177
+ end
178
+
179
+ @checked << url_path
180
+ @resolving.delete(url_path)
181
+ asset
182
+ end
183
+
184
+ # Collect strongly connected component members starting from url_path.
185
+ def collect_cycle(url_path)
186
+ visited = Set.new
187
+ stack = [url_path]
188
+ members = Set.new
189
+
190
+ while (node = stack.pop)
191
+ next unless visited.add?(node)
192
+ asset = @assets[node]
193
+ next unless asset
194
+ members << node if @resolving.include?(node)
195
+ asset.deps.each do |dep|
196
+ stack << dep if @resolving.include?(dep)
197
+ end
198
+ end
199
+
200
+ members.to_a
201
+ end
202
+
203
+ # Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
204
+ def rescan_asset!(asset)
205
+ url_path = asset.url_path
206
+ asset.mtime = File.mtime(asset.abs_path)
207
+
208
+ # Remove old reverse links
209
+ asset.deps.each do |dep_path|
210
+ dep = @assets[dep_path]
211
+ dep&.dependents&.delete(url_path)
212
+ end
213
+
214
+ # Re-parse imports
215
+ raw = File.read(asset.abs_path)
216
+ asset.deps = parse_deps(url_path, raw)
217
+
218
+ # Recursively ensure deps are fresh
219
+ asset.deps.each do |dep_path|
220
+ dep = resolve_asset!(dep_path)
221
+ dep.dependents << url_path if dep
222
+ end
223
+
224
+ # Recompute digest
225
+ compute_digest_for(url_path)
226
+
227
+ # Propagate to dependents already in the graph
228
+ propagate_to_dependents!(asset)
229
+ end
230
+
231
+ # Recompute digests for all transitive dependents of an asset.
232
+ def propagate_to_dependents!(asset)
233
+ queue = asset.dependents.to_a
234
+ visited = Set.new
235
+ while (dep_url = queue.shift)
236
+ next unless visited.add?(dep_url)
237
+ dep_asset = @assets[dep_url]
238
+ next unless dep_asset
239
+ compute_digest_for(dep_url)
240
+ queue.concat(dep_asset.dependents.to_a)
241
+ end
242
+ end
243
+
244
+ def parse_deps(url_path, raw)
245
+ ext = File.extname(url_path)
246
+ import_paths = case ext
247
+ when ".js", ".mjs", ".es" then Rewriter.extract_js_imports(raw)
248
+ when ".css" then Rewriter.extract_css_urls(raw)
249
+ else []
250
+ end
251
+ import_paths.map { |p| resolve_import_for(url_path, p) }
252
+ end
253
+
254
+ def compute_digest_for(url_path)
255
+ asset = @assets[url_path]
256
+ return unless asset
257
+
258
+ raw = File.read(asset.abs_path)
259
+ if asset.deps.empty?
260
+ asset.digest = Digest::SHA256.digest(raw)
261
+ else
262
+ rewritten = rewrite_content_internal(url_path, raw)
263
+ asset.digest = Digest::SHA256.digest(rewritten)
264
+ end
265
+ end
266
+
267
+ # For cyclic dependencies: hash all members' raw contents together.
268
+ def compute_cycle_digests(scc)
269
+ combined = scc.sort.filter_map { |url_path|
270
+ asset = @assets[url_path]
271
+ next unless asset
272
+ File.read(asset.abs_path)
273
+ }.join("\0")
274
+
275
+ digest = Digest::SHA256.digest(combined)
276
+ scc.each do |url_path|
277
+ asset = @assets[url_path]
278
+ asset.digest = digest if asset
279
+ end
280
+ end
281
+
282
+ def rewrite_content_internal(url_path, raw_content)
283
+ ext = File.extname(url_path)
284
+ case ext
285
+ when ".js", ".mjs", ".es"
286
+ Rewriter.rewrite_js_imports(raw_content) do |import_path|
287
+ resolved = resolve_import_for(url_path, import_path)
288
+ @assets[resolved]&.checksum_tag || "00000000"
289
+ end
290
+ when ".css"
291
+ Rewriter.rewrite_css_urls(raw_content) do |ref_path|
292
+ resolved = resolve_import_for(url_path, ref_path)
293
+ @assets[resolved]&.checksum_tag || "00000000"
294
+ end
295
+ else
296
+ raw_content
297
+ end
298
+ end
299
+
300
+ def remove_asset!(url_path)
301
+ asset = @assets.delete(url_path)
302
+ return unless asset
303
+
304
+ asset.deps.each do |dep_path|
305
+ dep = @assets[dep_path]
306
+ dep&.dependents&.delete(url_path)
307
+ end
308
+ end
309
+ end
310
+ end
@@ -15,16 +15,37 @@ module Assiette
15
15
 
16
16
  module_function
17
17
 
18
- def rewrite_js_imports(source, version_tag)
18
+ # Rewrites JS imports with per-import hashes via a block.
19
+ # The block receives the import path and must return the hash for that import.
20
+ def rewrite_js_imports(source, &block)
19
21
  source.gsub(JS_IMPORT_RE) do
20
- "#{$1}#{$2}?v=#{version_tag}#{$1}"
22
+ quote = $1
23
+ path = $2
24
+ hash = yield(path)
25
+ "#{quote}#{path}?s=#{hash}#{quote}"
21
26
  end
22
27
  end
23
28
 
24
- def rewrite_css_urls(source, version_tag)
29
+ # Rewrites CSS url() references with per-url hashes via a block.
30
+ # The block receives the url path and must return the hash for that url.
31
+ def rewrite_css_urls(source, &block)
25
32
  source.gsub(CSS_URL_RE) do
26
- "url(#{$1}#{$2}?v=#{version_tag}#{$3})"
33
+ open = $1
34
+ path = $2
35
+ close = $3
36
+ hash = yield(path)
37
+ "url(#{open}#{path}?s=#{hash}#{close})"
27
38
  end
28
39
  end
40
+
41
+ # Returns an array of import paths found in JS source.
42
+ def extract_js_imports(source)
43
+ source.scan(JS_IMPORT_RE).map { |m| m[1] }
44
+ end
45
+
46
+ # Returns an array of url() paths found in CSS source.
47
+ def extract_css_urls(source)
48
+ source.scan(CSS_URL_RE).map { |m| m[1] }
49
+ end
29
50
  end
30
51
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "asset_handler"
4
-
5
3
  module Assiette
6
4
  class Server
7
5
  CACHE_CONTROL = "public, max-age=432000, must-revalidate"
@@ -39,24 +37,17 @@ module Assiette
39
37
  file_path = @handler.resolve_file(path_info)
40
38
  return unless file_path
41
39
 
42
- raw_bytes = File.binread(file_path)
43
-
44
- # ETag from raw bytes for stability
45
- etag = %("#{Digest::SHA1.hexdigest(raw_bytes)}")
40
+ # Use the dependency graph's content hash for the ETag — it reflects
41
+ # the file's own content plus all its transitive dependencies, and is
42
+ # already computed as part of serving. This lets us short-circuit with
43
+ # a 304 before reading the file for rewriting.
44
+ etag = %("#{@handler.dependency_graph.tree_sha(path_info) || "0"}")
46
45
  if env["HTTP_IF_NONE_MATCH"] == etag
47
46
  return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
48
47
  end
49
48
 
50
- query = Rack::Utils.parse_query(env["QUERY_STRING"])
51
- tag = query["v"].to_s.empty? ? Assiette.version_tag : query["v"]
52
-
53
- body = if AssetHandler::JS_EXTENSIONS.include?(extension)
54
- Rewriter.rewrite_js_imports(raw_bytes, tag)
55
- elsif extension == ".css"
56
- Rewriter.rewrite_css_urls(raw_bytes, tag)
57
- else
58
- raw_bytes
59
- end
49
+ raw_bytes = File.binread(file_path)
50
+ body = @handler.dependency_graph.rewrite_content(path_info, raw_bytes)
60
51
 
61
52
  headers = {
62
53
  "content-type" => content_type,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assiette
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/assiette.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "assiette/version"
4
- require_relative "assiette/version_tag"
5
- require_relative "assiette/rewriter"
6
- require_relative "assiette/asset_handler"
7
- require_relative "assiette/server"
8
- require_relative "assiette/helpers"
9
- require_relative "assiette/rails_asset_url_helper"
10
- require_relative "assiette/railtie" if defined?(Rails::Railtie)
11
4
 
12
5
  module Assiette
6
+ autoload :Rewriter, File.expand_path("assiette/rewriter", __dir__)
7
+ autoload :AssetHandler, File.expand_path("assiette/asset_handler", __dir__)
8
+ autoload :DependencyGraph, File.expand_path("assiette/dependency_graph", __dir__)
9
+ autoload :Server, File.expand_path("assiette/server", __dir__)
10
+ autoload :Helpers, File.expand_path("assiette/helpers", __dir__)
11
+ autoload :RailsAssetUrlHelper, File.expand_path("assiette/rails_asset_url_helper", __dir__)
13
12
  end
13
+
14
+ require_relative "assiette/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assiette
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
10
+ date: 2026-05-26 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: actionpack
@@ -36,13 +35,13 @@ files:
36
35
  - Rakefile
37
36
  - lib/assiette.rb
38
37
  - lib/assiette/asset_handler.rb
38
+ - lib/assiette/dependency_graph.rb
39
39
  - lib/assiette/helpers.rb
40
40
  - lib/assiette/rails_asset_url_helper.rb
41
41
  - lib/assiette/railtie.rb
42
42
  - lib/assiette/rewriter.rb
43
43
  - lib/assiette/server.rb
44
44
  - lib/assiette/version.rb
45
- - lib/assiette/version_tag.rb
46
45
  - lib/generators/assiette/install/install_generator.rb
47
46
  - lib/generators/assiette/install/templates/initializer.rb.tt
48
47
  homepage: https://github.com/julik/assiette
@@ -53,7 +52,6 @@ metadata:
53
52
  homepage_uri: https://github.com/julik/assiette
54
53
  source_code_uri: https://github.com/julik/assiette
55
54
  changelog_uri: https://github.com/julik/assiette/blob/main/CHANGELOG.md
56
- post_install_message:
57
55
  rdoc_options: []
58
56
  require_paths:
59
57
  - lib
@@ -68,8 +66,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
66
  - !ruby/object:Gem::Version
69
67
  version: '0'
70
68
  requirements: []
71
- rubygems_version: 3.4.10
72
- signing_key:
69
+ rubygems_version: 3.6.6
73
70
  specification_version: 4
74
71
  summary: Zero-build asset serving for Rails engines
75
72
  test_files: []
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest/sha1"
4
-
5
- module Assiette
6
- # Computes a short version tag for cache busting.
7
- # In development: timestamp for instant invalidation on each request
8
- # In production: derived from APP_REVISION env var or Gemfile.lock digest
9
- def self.version_tag
10
- @version_tag ||= compute_version_tag
11
- end
12
-
13
- def self.reset_version_tag!
14
- @version_tag = nil
15
- end
16
-
17
- def self.compute_version_tag
18
- if Rails.env.development?
19
- Time.now.utc.strftime("%Y%m%d%H%M%S")
20
- elsif (app_revision = ENV["APP_REVISION"]).present?
21
- Digest::SHA1.hexdigest(app_revision)[0, 4]
22
- else
23
- gemfile_lock_path = Rails.root.join("Gemfile.lock")
24
- if gemfile_lock_path.exist?
25
- Digest::SHA1.file(gemfile_lock_path).hexdigest[0, 4]
26
- else
27
- (Time.now.utc.to_i / 300).to_s(16)
28
- end
29
- end
30
- end
31
-
32
- private_class_method :compute_version_tag
33
- end