pray-cli 1.21.0 → 1.22.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.
@@ -5,7 +5,10 @@ require "open3"
5
5
  require_relative "error"
6
6
  require_relative "git_clone"
7
7
  require_relative "git_run"
8
+ require_relative "git_store"
8
9
  require_relative "path_safety"
10
+ require_relative "resource_limits"
11
+ require_relative "tar_validation"
9
12
 
10
13
  module Pray
11
14
  module GitMaterialize
@@ -14,56 +17,129 @@ module Pray
14
17
 
15
18
  module_function
16
19
 
17
- def read_local_artifact_bytes(source_root, artifact)
18
- if artifact.start_with?("file://")
19
- path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
20
- raise Error.resolution("package artifact path escapes distribution root") unless path
21
-
22
- return File.binread(path)
23
- end
24
- if artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
25
- raise Error.integrity("remote artifact path must be relative: #{artifact}")
20
+ def read_local_artifact_bytes(source_root, artifact, offline: false)
21
+ if artifact.include?("://") || artifact.start_with?("file:")
22
+ raise Error.integrity(
23
+ "local and git catalog artifacts must be relative paths under the distribution root, not URLs: #{artifact}"
24
+ )
26
25
  end
27
26
 
28
27
  path = PathSafety.join_under_root(source_root, artifact)
29
28
  raise Error.resolution("package artifact path escapes distribution root") unless path
30
29
 
31
- materialize_catalog_file(source_root, artifact) unless File.exist?(path)
30
+ materialize_catalog_file(source_root, artifact, offline: offline) unless File.exist?(path)
32
31
  raise Error.resolution("package artifact missing at #{path}") unless File.exist?(path)
33
32
 
34
33
  File.binread(path)
35
34
  end
36
35
 
37
- def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh)
38
- return if !refresh && catalog_matches?(dest, git_dir, revision)
36
+ def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh, offline: false)
37
+ require_revision_present!(git_dir, revision, offline: offline)
38
+ GitStore.with_git_cache_lock(dest) do
39
+ return if !refresh && catalog_matches?(dest, git_dir, revision)
39
40
 
40
- FileUtils.rm_rf(dest) if File.exist?(dest)
41
- FileUtils.mkdir_p(dest)
42
- unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
43
- unpack_archive_prefix(git_dir, dest, revision, prefix)
41
+ staging = "#{dest}.partial"
42
+ FileUtils.rm_rf(staging)
43
+ FileUtils.mkdir_p(staging)
44
+ unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
45
+ unpack_archive_prefix(git_dir, staging, revision, prefix, offline: offline)
46
+ end
47
+ unless unpacked
48
+ FileUtils.rm_rf(staging)
49
+ if offline
50
+ raise Error.resolution(
51
+ "git catalog at revision #{revision} is not materializable locally and offline mode is enabled"
52
+ )
53
+ end
54
+ raise Error.resolution("no pray distribution root in git source at revision #{revision}")
55
+ end
56
+
57
+ File.write(File.join(staging, REVISION_MARKER), revision)
58
+ File.write(File.join(staging, GIT_DIR_MARKER), git_dir)
59
+ publish_catalog_tree!(dest, staging)
44
60
  end
45
- raise Error.resolution("no pray distribution root in git source at revision #{revision}") unless unpacked
61
+ end
46
62
 
47
- File.write(File.join(dest, REVISION_MARKER), revision)
48
- File.write(File.join(dest, GIT_DIR_MARKER), git_dir)
63
+ def publish_catalog_tree!(dest, staging)
64
+ FileUtils.mkdir_p(File.dirname(dest))
65
+ if File.exist?(dest)
66
+ backup = "#{dest}.previous"
67
+ FileUtils.rm_rf(backup)
68
+ File.rename(dest, backup)
69
+ begin
70
+ File.rename(staging, dest)
71
+ rescue
72
+ File.rename(backup, dest) if File.exist?(backup)
73
+ FileUtils.rm_rf(staging)
74
+ raise
75
+ end
76
+ FileUtils.rm_rf(backup)
77
+ else
78
+ File.rename(staging, dest)
79
+ end
49
80
  end
81
+ private_class_method :publish_catalog_tree!
50
82
 
51
- def materialize_catalog_file(source_root, relative)
52
- return if File.file?(File.join(source_root, relative))
83
+ def materialize_catalog_file(source_root, relative, offline: false)
84
+ full_path = File.join(source_root, relative)
85
+ return if File.file?(full_path)
53
86
 
54
87
  markers = find_catalog_markers(source_root)
55
88
  return unless markers
56
89
 
57
- full_path = File.join(source_root, relative)
58
- repo_relative = full_path.delete_prefix(markers.fetch(:work_tree)).delete_prefix("/")
59
- GitRun.run_git(
60
- markers.fetch(:git_dir),
61
- "--work-tree",
62
- markers.fetch(:work_tree),
63
- "checkout",
64
- markers.fetch(:revision),
65
- "--",
66
- repo_relative
90
+ GitStore.with_git_cache_lock(markers.fetch(:catalog_root)) do
91
+ return if File.file?(full_path)
92
+
93
+ require_revision_present!(markers.fetch(:git_dir), markers.fetch(:revision), offline: offline)
94
+ repo_relative = full_path.delete_prefix(markers.fetch(:catalog_root)).delete_prefix("/")
95
+ object = "#{markers.fetch(:revision)}:#{repo_relative}"
96
+ require_blob_within_limit!(markers.fetch(:git_dir), object, offline: offline)
97
+ env = GitRun.git_env(offline_git_env(offline))
98
+ stdout, status = Open3.capture2(
99
+ env,
100
+ "git",
101
+ "-c",
102
+ "protocol.file.allow=always",
103
+ "-C",
104
+ markers.fetch(:git_dir),
105
+ "show",
106
+ object,
107
+ stdin_data: ""
108
+ )
109
+ unless status.success?
110
+ if offline
111
+ raise Error.resolution(
112
+ "git catalog blob #{repo_relative} at revision #{markers.fetch(:revision)} is not available locally and offline mode is enabled"
113
+ )
114
+ end
115
+ annotate_git_materialize_failure!(
116
+ markers.fetch(:git_dir),
117
+ markers.fetch(:revision),
118
+ "git show failed"
119
+ )
120
+ end
121
+ write_catalog_blob_atomic!(full_path, stdout)
122
+ end
123
+ end
124
+
125
+ def require_revision_present!(git_dir, revision, offline: false)
126
+ return if GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
127
+
128
+ if offline
129
+ raise Error.resolution(
130
+ "git revision #{revision} is not available in #{git_dir} and offline mode is enabled (git_revision_unfetched)."
131
+ )
132
+ end
133
+ raise Error.resolution(
134
+ "git revision #{revision} is not available in #{git_dir} (git_revision_unfetched). " \
135
+ "Run `pray install --locked`, or clear the project git cache and shared bare then install again."
136
+ )
137
+ end
138
+
139
+ def annotate_git_materialize_failure!(git_dir, revision, detail)
140
+ raise Error.resolution(
141
+ "git catalog materialize failed for revision #{revision} in #{git_dir} (git_cache_stale): #{detail}. " \
142
+ "Run `pray install --locked`, or clear `.pray/cache/git` and the shared bare then install again."
67
143
  )
68
144
  end
69
145
 
@@ -73,6 +149,7 @@ module Pray
73
149
  return false unless File.file?(revision_path) && File.file?(git_dir_path)
74
150
  return false unless File.read(revision_path).strip == revision
75
151
  return false unless File.read(git_dir_path).strip == git_dir
152
+ return false unless GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
76
153
 
77
154
  File.directory?(File.join(dest, "v1", "packages")) ||
78
155
  File.directory?(File.join(dest, "prayers", "v1", "packages")) ||
@@ -81,8 +158,10 @@ module Pray
81
158
  false
82
159
  end
83
160
 
84
- def unpack_archive_prefix(git_dir, dest, revision, prefix)
85
- env = GitRun.git_env("GIT_TERMINAL_PROMPT" => "0")
161
+ def unpack_archive_prefix(git_dir, dest, revision, prefix, offline: false)
162
+ return false unless tree_prefix_within_limits?(git_dir, revision, prefix, offline: offline)
163
+
164
+ env = GitRun.git_env(offline_git_env(offline).merge("GIT_TERMINAL_PROMPT" => "0"))
86
165
  stdout, _stderr, status = Open3.capture3(
87
166
  env,
88
167
  "git",
@@ -98,13 +177,78 @@ module Pray
98
177
  stdin_data: ""
99
178
  )
100
179
  return false unless status.success? && !stdout.empty?
180
+ if stdout.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
181
+ raise Error.integrity(
182
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
183
+ )
184
+ end
101
185
 
102
- _ignored, extract_status = Open3.capture2(env, "tar", "-x", "-C", dest, stdin_data: stdout)
103
- raise Error.resolution("failed to unpack git catalog archive") unless extract_status.success?
104
-
186
+ TarValidation.extract!(stdout, dest)
105
187
  true
106
188
  end
107
189
 
190
+ def require_blob_within_limit!(git_dir, object, offline: false)
191
+ env = GitRun.git_env(offline_git_env(offline))
192
+ stdout, status = Open3.capture2(
193
+ env, "git", "-c", "protocol.file.allow=always", "-C", git_dir, "cat-file", "-s", object,
194
+ stdin_data: ""
195
+ )
196
+ return unless status.success?
197
+
198
+ size = stdout.strip.to_i
199
+ return if size <= ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
200
+
201
+ raise Error.integrity(
202
+ "git catalog blob exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
203
+ )
204
+ end
205
+
206
+ def tree_prefix_within_limits?(git_dir, revision, prefix, offline: false)
207
+ env = GitRun.git_env(offline_git_env(offline))
208
+ stdout, status = Open3.capture2(
209
+ env, "git", "-c", "protocol.file.allow=always", "-C", git_dir,
210
+ "ls-tree", "-r", "-l", revision, "--", prefix, stdin_data: ""
211
+ )
212
+ return false unless status.success? && !stdout.strip.empty?
213
+
214
+ entry_count = 0
215
+ total_bytes = 0
216
+ stdout.each_line do |line|
217
+ parts = line.split
218
+ next unless parts[1] == "blob"
219
+
220
+ size = parts[3].to_i
221
+ entry_count += 1
222
+ if entry_count > ResourceLimits::MAX_ARCHIVE_ENTRIES
223
+ raise Error.integrity(
224
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRIES} entries"
225
+ )
226
+ end
227
+ if size > ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
228
+ raise Error.integrity(
229
+ "git catalog archive entry exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
230
+ )
231
+ end
232
+ total_bytes += size
233
+ if total_bytes > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
234
+ raise Error.integrity(
235
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
236
+ )
237
+ end
238
+ end
239
+ entry_count.positive?
240
+ end
241
+
242
+ def write_catalog_blob_atomic!(full_path, bytes)
243
+ FileUtils.mkdir_p(File.dirname(full_path))
244
+ partial = "#{full_path}.partial"
245
+ File.binwrite(partial, bytes)
246
+ File.rename(partial, full_path)
247
+ rescue
248
+ FileUtils.rm_f(partial)
249
+ raise
250
+ end
251
+
108
252
  def find_catalog_markers(start)
109
253
  current = start
110
254
  8.times do
@@ -113,9 +257,9 @@ module Pray
113
257
  if File.file?(revision_path) && File.file?(git_dir_path)
114
258
  revision = File.read(revision_path).strip
115
259
  git_dir = File.read(git_dir_path).strip
116
- return if revision.empty? || !File.exist?(git_dir)
260
+ return unless acceptable_revision?(revision) && acceptable_shared_bare?(git_dir)
117
261
 
118
- return {git_dir: git_dir, revision: revision, work_tree: current}
262
+ return {git_dir: git_dir, revision: revision, catalog_root: current}
119
263
  end
120
264
  parent = File.dirname(current)
121
265
  return if parent == current
@@ -124,5 +268,27 @@ module Pray
124
268
  end
125
269
  nil
126
270
  end
271
+
272
+ def acceptable_revision?(revision)
273
+ return false if revision.empty? || revision.start_with?("-")
274
+ return false unless [40, 64].include?(revision.length)
275
+
276
+ revision.match?(/\A[0-9a-fA-F]+\z/)
277
+ end
278
+
279
+ def acceptable_shared_bare?(git_dir)
280
+ return false unless File.file?(File.join(git_dir, "HEAD"))
281
+ return false unless File.directory?(File.join(git_dir, "objects"))
282
+
283
+ root = GitStore.global_cache_root
284
+ return false unless root
285
+
286
+ expected = File.join(root, "git")
287
+ PathSafety.path_under_root?(expected, git_dir)
288
+ end
289
+
290
+ def offline_git_env(offline)
291
+ offline ? {"GIT_NO_LAZY_FETCH" => "1"} : {}
292
+ end
127
293
  end
128
294
  end
@@ -8,27 +8,28 @@ require_relative "git_clone"
8
8
 
9
9
  module Pray
10
10
  module GitStore
11
+ READY_MARKER = ".pray-ready"
12
+
11
13
  module_function
12
14
 
13
15
  def ensure_global_git_db(clone_url, pinned_revision:, refresh:, offline:, working_directory:)
14
16
  db = global_git_cache_directory(clone_url)
15
17
  raise Error.resolution("git object cache is not configured") unless db
16
18
 
17
- unless global_git_cache_ready?(db)
18
- raise Error.resolution(offline_git_source_uncached(clone_url)) if offline
19
+ with_git_cache_lock(db) do
20
+ unless global_git_cache_ready?(db)
21
+ raise Error.resolution(offline_git_source_uncached(clone_url)) if offline
19
22
 
20
- FileUtils.rm_rf(db) if File.exist?(db)
21
- FileUtils.mkdir_p(File.dirname(db))
22
- GitClone.clone_bare_git_db(working_directory, clone_url, db, quiet: false)
23
- ensure_git_remote_origin(db, clone_url)
24
- end
25
- fetch_origin_tip(db, clone_url) if refresh && !offline
26
- if pinned_revision
27
- ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
28
- return [db, pinned_revision]
29
- end
23
+ install_bare_clone(working_directory, clone_url, db)
24
+ end
25
+ fetch_origin_tip(db, clone_url) if refresh && !offline
26
+ if pinned_revision
27
+ ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
28
+ return [db, pinned_revision]
29
+ end
30
30
 
31
- [db, git_head_revision(db)]
31
+ [db, git_head_revision(db)]
32
+ end
32
33
  end
33
34
 
34
35
  def global_cache_root
@@ -45,13 +46,98 @@ module Pray
45
46
  end
46
47
 
47
48
  def global_git_cache_ready?(global_cache)
48
- File.file?(File.join(global_cache, "HEAD")) || File.directory?(File.join(global_cache, ".git"))
49
+ return false unless File.file?(File.join(global_cache, "HEAD"))
50
+ return false unless File.directory?(File.join(global_cache, "objects"))
51
+ return true if File.file?(File.join(global_cache, READY_MARKER))
52
+
53
+ begin
54
+ git_head_revision(global_cache)
55
+ true
56
+ rescue Error
57
+ false
58
+ end
59
+ end
60
+
61
+ def mark_git_cache_ready(global_cache)
62
+ File.write(File.join(global_cache, READY_MARKER), "1")
49
63
  end
50
64
 
51
65
  def offline_git_source_uncached(clone_url)
52
66
  "git source #{clone_url} is not cached locally and offline mode is enabled"
53
67
  end
54
68
 
69
+ def install_bare_clone(working_directory, clone_url, db)
70
+ FileUtils.mkdir_p(File.dirname(db))
71
+ staging = "#{db}.partial"
72
+ FileUtils.rm_rf(staging) if File.exist?(staging)
73
+ GitClone.clone_bare_git_db(working_directory, clone_url, staging, quiet: false)
74
+ ensure_git_remote_origin(staging, clone_url)
75
+ git_head_revision(staging)
76
+ mark_git_cache_ready(staging)
77
+ if File.exist?(db)
78
+ if global_git_cache_ready?(db)
79
+ FileUtils.rm_rf(staging)
80
+ return
81
+ end
82
+ FileUtils.rm_rf(db)
83
+ end
84
+ FileUtils.mv(staging, db)
85
+ end
86
+
87
+ LOCK_ATTEMPTS = 2_400
88
+ LOCK_RETRY_SECONDS = 0.025
89
+ STALE_LOCK_SECONDS = 600
90
+
91
+ def with_git_cache_lock(db)
92
+ lock_directory = "#{db}.lock.d"
93
+ FileUtils.mkdir_p(File.dirname(lock_directory))
94
+ acquired = false
95
+ LOCK_ATTEMPTS.times do
96
+ Dir.mkdir(lock_directory)
97
+ write_lock_owner!(lock_directory)
98
+ acquired = true
99
+ break
100
+ rescue Errno::EEXIST
101
+ clear_stale_lock!(lock_directory)
102
+ sleep LOCK_RETRY_SECONDS
103
+ end
104
+ unless acquired
105
+ raise Error.resolution(
106
+ "timed out waiting for git cache lock #{lock_directory}. " \
107
+ "If no other pray process is using that cache, remove the lock directory and retry."
108
+ )
109
+ end
110
+
111
+ begin
112
+ yield
113
+ ensure
114
+ FileUtils.rm_rf(lock_directory)
115
+ end
116
+ end
117
+
118
+ def write_lock_owner!(lock_directory)
119
+ File.write(File.join(lock_directory, "owner"), "#{Process.pid}\n#{Time.now.to_i}\n")
120
+ end
121
+
122
+ def clear_stale_lock!(lock_directory)
123
+ age = lock_age_seconds(lock_directory)
124
+ return if age < STALE_LOCK_SECONDS
125
+
126
+ FileUtils.rm_rf(lock_directory)
127
+ end
128
+
129
+ def lock_age_seconds(lock_directory)
130
+ owner = File.join(lock_directory, "owner")
131
+ if File.file?(owner)
132
+ stamp = File.read(owner).lines[1].to_s.strip.to_i
133
+ return Time.now.to_i - stamp if stamp.positive?
134
+ end
135
+ Time.now.to_i - File.mtime(lock_directory).to_i
136
+ rescue Errno::ENOENT
137
+ 0
138
+ end
139
+ private_class_method :write_lock_owner!, :clear_stale_lock!, :lock_age_seconds
140
+
55
141
  def ensure_revision_in_db(db, clone_url, revision, allow_fetch)
56
142
  return if GitRun.try_run_git(db, "cat-file", "-e", revision)
57
143
  unless allow_fetch
@@ -110,6 +196,7 @@ module Pray
110
196
  GitRun.run_git(db, "fetch", "--depth", "1", "origin")
111
197
  end
112
198
  GitRun.run_git(db, "update-ref", "HEAD", "FETCH_HEAD")
199
+ mark_git_cache_ready(db)
113
200
  end
114
201
 
115
202
  def fetch_unshallow(db)
@@ -55,6 +55,7 @@ module Pray
55
55
  end
56
56
 
57
57
  def tree_hash_for_root(root)
58
+ validate_relative_paths!
58
59
  file_bytes = {}
59
60
  files.each do |file|
60
61
  path = File.join(root, file)
@@ -196,7 +197,11 @@ module Pray
196
197
  expect_start!
197
198
  spec = PackageSpec.new
198
199
  while (statement = next_statement)
199
- return spec.canonicalized if statement == "end"
200
+ if statement == "end"
201
+ package = spec.canonicalized
202
+ package.validate_relative_paths!
203
+ return package
204
+ end
200
205
 
201
206
  apply_statement(spec, statement)
202
207
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_safety"
4
+
5
+ module Pray
6
+ class PackageSpec
7
+ def validate_relative_paths!
8
+ files.each { |file| PathSafety.validate_archive_member_path!(file) }
9
+ exports.each_value do |export|
10
+ PathSafety.validate_archive_member_path!(export.path)
11
+ PathSafety.validate_archive_member_path!(export.default_path) if export.default_path
12
+ end
13
+ skills.each_value { |skill| PathSafety.validate_archive_member_path!(skill.path) }
14
+ templates.each_value { |template| PathSafety.validate_archive_member_path!(template.path) }
15
+ self
16
+ end
17
+ end
18
+ end
data/lib/pray/publish.rb CHANGED
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "time"
6
6
  require "tmpdir"
7
+ require_relative "terminal"
7
8
 
8
9
  module Pray
9
10
  RegistryIndex = Struct.new(:spec, :packages) do
@@ -15,11 +16,14 @@ module Pray
15
16
  module Publish
16
17
  module_function
17
18
 
18
- def publish_to_root(project, root, signer: "local", signer_fingerprint: nil)
19
+ def publish_to_root(project, root, signer: "local", signer_fingerprint: nil, dest_name: nil, target: nil)
19
20
  root = File.expand_path(root)
21
+ dest_name ||= root
22
+ target ||= root
20
23
  index = load_registry_index(root)
21
24
  distribution = Distribution.read_settings(root)
22
25
  package_names = index.packages.to_set
26
+ stats = {published: 0, unchanged: 0}
23
27
 
24
28
  project.packages.each do |package|
25
29
  package.spec.require_release_version!
@@ -31,6 +35,8 @@ module Pray
31
35
  if stored_artifact && stored_publish_matches?(stored_artifact, package, existing)
32
36
  package_names << package.declaration.name
33
37
  write_registry_package_metadata(metadata_path, metadata)
38
+ report_unchanged(package, dest_name, target)
39
+ stats[:unchanged] += 1
34
40
  next
35
41
  end
36
42
 
@@ -56,14 +62,20 @@ module Pray
56
62
  metadata.versions << version_entry
57
63
  write_registry_package_metadata(metadata_path, metadata)
58
64
  package_names << package.declaration.name
65
+ report_published(package, dest_name, target, artifact_path)
66
+ stats[:published] += 1
59
67
  end
60
68
 
61
69
  index.packages = package_names.sort
62
70
  write_registry_index(root, index)
71
+ stats
63
72
  end
64
73
 
65
- def publish_to_server(project, server_url, signer: "local", signer_fingerprint: nil)
74
+ def publish_to_server(project, server_url, signer: "local", signer_fingerprint: nil, dest_name: nil, target: nil)
75
+ dest_name ||= server_url
76
+ target ||= server_url
66
77
  distribution = Distribution.fetch_settings(server_url)
78
+ stats = {published: 0, unchanged: 0}
67
79
  project.packages.each do |package|
68
80
  archive_bytes = Archive.build_package_archive_bytes(package)
69
81
  artifact_path = registry_artifact_path(package.declaration.name, package.spec.version)
@@ -99,9 +111,38 @@ module Pray
99
111
  "application/json",
100
112
  JSON.pretty_generate(metadata_to_hash(metadata))
101
113
  )
114
+ report_published(package, dest_name, target, artifact_path)
115
+ stats[:published] += 1
102
116
  end
117
+ stats
103
118
  end
104
119
 
120
+ def report_published(package, dest_name, target, artifact_path)
121
+ Terminal.println_stdout(
122
+ "published #{package.declaration.name} #{package.spec.version} -> #{redact_publish_target(dest_name)} (#{redact_publish_target(target)})"
123
+ )
124
+ Terminal.println_verbose(" artifact #{artifact_path}")
125
+ end
126
+
127
+ def report_unchanged(package, dest_name, target)
128
+ Terminal.println_stdout(
129
+ "unchanged #{package.declaration.name} #{package.spec.version} -> #{redact_publish_target(dest_name)} (#{redact_publish_target(target)})"
130
+ )
131
+ Terminal.println_verbose(" skip reason: stored publish matches")
132
+ end
133
+
134
+ def redact_publish_target(target)
135
+ text = target.to_s
136
+ return text unless text.include?("://")
137
+
138
+ scheme, remainder = text.split("://", 2)
139
+ return text unless remainder&.include?("@")
140
+
141
+ _userinfo, host_and_path = remainder.split("@", 2)
142
+ "#{scheme}://***@#{host_and_path}"
143
+ end
144
+ private_class_method :report_published, :report_unchanged
145
+
105
146
  def published_registry_package_version(package, signer, signer_fingerprint, archive_bytes, artifact_path)
106
147
  RegistryPackageVersion.new(
107
148
  version: package.spec.version,
@@ -114,7 +155,8 @@ module Pray
114
155
  signer: signer,
115
156
  signer_fingerprint: signer_fingerprint,
116
157
  published_at: Time.now.to_i,
117
- signature: Registry.registry_artifact_signature(archive_bytes, package.tree_hash, signer)
158
+ signature: Registry.registry_artifact_signature(archive_bytes, package.tree_hash, signer),
159
+ derived_metadata: DerivedMetadata.from_package_root(package.root, package.spec)
118
160
  )
119
161
  end
120
162
 
@@ -163,6 +205,7 @@ module Pray
163
205
 
164
206
  version_entry.yanked = existing.yanked
165
207
  version_entry.published_at = existing.published_at if stored_artifact
208
+ version_entry.derived_metadata ||= existing.derived_metadata
166
209
  end
167
210
 
168
211
  def load_registry_index(root)
@@ -181,7 +224,7 @@ module Pray
181
224
 
182
225
  def load_registry_package_metadata(path, package_name)
183
226
  if File.exist?(path)
184
- data = JSON.parse(File.read(path))
227
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
185
228
  RegistryPackageMetadata.new(
186
229
  name: data["name"],
187
230
  versions: Array(data["versions"]).map { |entry| Registry.version_from_hash(entry) }
@@ -193,7 +236,7 @@ module Pray
193
236
 
194
237
  def write_registry_package_metadata(path, metadata)
195
238
  FileUtils.mkdir_p(File.dirname(path))
196
- File.write(path, JSON.pretty_generate(metadata_to_hash(metadata)))
239
+ File.write(path, JSON.pretty_generate(metadata_to_hash(metadata)), encoding: "UTF-8")
197
240
  end
198
241
 
199
242
  def metadata_to_hash(metadata)
@@ -217,6 +260,7 @@ module Pray
217
260
  hash["signer_fingerprint"] = entry.signer_fingerprint if entry.signer_fingerprint
218
261
  hash["published_at"] = entry.published_at if entry.published_at
219
262
  hash["signature"] = entry.signature if entry.signature
263
+ hash["derived_metadata"] = entry.derived_metadata if entry.derived_metadata
220
264
  hash
221
265
  end
222
266