metanorma-release 0.2.24 → 0.2.26

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +21 -319
  3. data/README.adoc +319 -92
  4. data/lib/metanorma/release/aggregation_pipeline.rb +128 -45
  5. data/lib/metanorma/release/asset_processor.rb +3 -3
  6. data/lib/metanorma/release/cache_store.rb +1 -0
  7. data/lib/metanorma/release/change_detector.rb +3 -2
  8. data/lib/metanorma/release/cli.rb +28 -4
  9. data/lib/metanorma/release/commands/aggregate.rb +1 -17
  10. data/lib/metanorma/release/commands/package.rb +9 -17
  11. data/lib/metanorma/release/commands/release_command.rb +19 -14
  12. data/lib/metanorma/release/config.rb +16 -7
  13. data/lib/metanorma/release/delta_state.rb +15 -1
  14. data/lib/metanorma/release/dependency_validation.rb +19 -0
  15. data/lib/metanorma/release/document_flattener.rb +173 -0
  16. data/lib/metanorma/release/index.rb +1 -1
  17. data/lib/metanorma/release/interfaces.rb +12 -0
  18. data/lib/metanorma/release/platform/github/manifest_reader.rb +3 -1
  19. data/lib/metanorma/release/platform/github/release_fetcher.rb +10 -14
  20. data/lib/metanorma/release/platform/github/topic_discoverer.rb +1 -1
  21. data/lib/metanorma/release/platform/github.rb +0 -4
  22. data/lib/metanorma/release/platform/local/fetcher.rb +5 -17
  23. data/lib/metanorma/release/platform/null/manifest_reader.rb +15 -0
  24. data/lib/metanorma/release/platform/null/publisher.rb +1 -1
  25. data/lib/metanorma/release/platform/null.rb +2 -0
  26. data/lib/metanorma/release/platform/static_discoverer.rb +19 -0
  27. data/lib/metanorma/release/platform.rb +1 -0
  28. data/lib/metanorma/release/platform_factory.rb +6 -21
  29. data/lib/metanorma/release/publication.rb +23 -161
  30. data/lib/metanorma/release/publication_serializer.rb +59 -0
  31. data/lib/metanorma/release/release_pipeline.rb +7 -15
  32. data/lib/metanorma/release/rxl_extractor.rb +106 -0
  33. data/lib/metanorma/release/site.rb +5 -165
  34. data/lib/metanorma/release/slug_strategy.rb +30 -15
  35. data/lib/metanorma/release/version.rb +1 -1
  36. data/lib/metanorma/release/zip_packager.rb +1 -1
  37. data/lib/metanorma/release.rb +36 -19
  38. metadata +11 -5
@@ -2,17 +2,30 @@
2
2
 
3
3
  module Metanorma
4
4
  module Release
5
- FetchResult = Struct.new(:releases, :etag, :unchanged?, keyword_init: true)
5
+ # failed?: the release listing itself failed (rate limit, network) —
6
+ # distinct from an empty result. A failed listing must never be
7
+ # treated as "all releases deleted".
8
+ FetchResult = Struct.new(:releases, :etag, :unchanged?, :failed?,
9
+ keyword_init: true) do
10
+ def initialize(releases:, etag:, **flags)
11
+ super(releases: releases, etag: etag,
12
+ unchanged?: flags.fetch(:unchanged?, false),
13
+ failed?: flags.fetch(:failed?, false))
14
+ end
15
+ end
6
16
  RepoReport = Struct.new(:releases, :included, :skipped, :reason, :errors,
7
17
  keyword_init: true)
8
18
  RepoError = Struct.new(:tag, :message, keyword_init: true)
19
+ Collision = Struct.new(:path, :claimed_by, keyword_init: true)
9
20
 
10
- class AggregationPipeline # rubocop:disable Metrics/ClassLength
21
+ class AggregationPipeline
11
22
  Dependencies = Struct.new(
12
23
  :discoverer, :fetcher, :manifest_reader,
13
24
  :metadata_filter, :asset_processor, :delta_state,
14
25
  keyword_init: true
15
26
  ) do
27
+ include DependencyValidation
28
+
16
29
  def initialize(**kwargs)
17
30
  super
18
31
  validate_types!
@@ -27,16 +40,6 @@ module Metanorma
27
40
  "manifest_reader")
28
41
  validate_interface!(delta_state, DeltaStateManager, "delta_state")
29
42
  end
30
-
31
- def validate_interface!(obj, mod, name)
32
- return if obj.is_a?(mod) || begin
33
- obj.class.ancestors.include?(mod)
34
- rescue StandardError
35
- false
36
- end
37
-
38
- raise ArgumentError, "#{name} must include #{mod}, got #{obj.class}"
39
- end
40
43
  end
41
44
 
42
45
  Config = Struct.new(
@@ -47,9 +50,14 @@ module Metanorma
47
50
 
48
51
  Result = Struct.new(
49
52
  :publications, :repo_count, :channels_found,
50
- :report, :failed_repos,
53
+ :report, :failed_repos, :collisions,
51
54
  keyword_init: true
52
- )
55
+ ) do
56
+ def initialize(publications:, repo_count:, channels_found:, report:,
57
+ failed_repos:, collisions: [])
58
+ super
59
+ end
60
+ end
53
61
 
54
62
  def initialize(deps)
55
63
  @deps = deps
@@ -58,17 +66,13 @@ module Metanorma
58
66
  def run(config, output_dir)
59
67
  @deps.delta_state.load
60
68
  repos = @deps.discoverer.discover
61
- publications = []
62
- reports = []
63
- failed_repos = []
64
69
 
65
- repos.each do |repo|
66
- repo_docs, report = process_repo(repo, output_dir, config)
67
- publications.concat(repo_docs)
68
- reports << report
69
- rescue StandardError => e
70
- failed_repos << RepoError.new(tag: repo.to_s, message: e.message)
71
- raise if config.fail_on_error
70
+ if config.concurrency > 1
71
+ publications, reports, failed_repos = run_concurrent(repos,
72
+ output_dir, config)
73
+ else
74
+ publications, reports, failed_repos = run_sequential(repos,
75
+ output_dir, config)
72
76
  end
73
77
 
74
78
  @deps.delta_state.save
@@ -79,11 +83,73 @@ module Metanorma
79
83
  channels_found: publications.flat_map(&:channels).uniq.sort,
80
84
  report: reports,
81
85
  failed_repos: failed_repos,
86
+ collisions: detect_collisions(publications),
82
87
  )
83
88
  end
84
89
 
90
+ # Two releases extracting to the same path (same slug from
91
+ # different repos, or same-year editions under flat routing)
92
+ # silently overwrite each other. Surface them so callers can
93
+ # disambiguate in source repos or fail loudly.
94
+ def detect_collisions(publications)
95
+ claims = Hash.new { |h, k| h[k] = [] }
96
+ publications.each do |pub|
97
+ owner = pub.source ? "#{pub.source.repo_key}@#{pub.source.tag}" : pub.slug
98
+ pub.files.each { |file| claims[file.path] << owner }
99
+ end
100
+ claims.select { |_path, owners| owners.uniq.length > 1 }.map do |path, owners|
101
+ Collision.new(path: path, claimed_by: owners.uniq)
102
+ end
103
+ end
104
+
85
105
  private
86
106
 
107
+ def run_sequential(repos, output_dir, config)
108
+ publications = []
109
+ reports = []
110
+ failed_repos = []
111
+
112
+ repos.each do |repo|
113
+ repo_docs, report = process_repo(repo, output_dir, config)
114
+ publications.concat(repo_docs)
115
+ reports << report
116
+ rescue StandardError => e
117
+ failed_repos << RepoError.new(tag: repo.to_s, message: e.message)
118
+ raise if config.fail_on_error
119
+ end
120
+
121
+ [publications, reports, failed_repos]
122
+ end
123
+
124
+ def run_concurrent(repos, output_dir, config)
125
+ publications = []
126
+ reports = []
127
+ failed_repos = []
128
+ mutex = Mutex.new
129
+ threads = repos.each_slice([
130
+ (repos.length.to_f / config.concurrency).ceil, 1
131
+ ].max).map do |batch|
132
+ Thread.new(batch) do |slice|
133
+ slice.each do |repo|
134
+ repo_docs, report = process_repo(repo, output_dir, config)
135
+ mutex.synchronize do
136
+ publications.concat(repo_docs)
137
+ reports << report
138
+ end
139
+ rescue StandardError => e
140
+ mutex.synchronize do
141
+ failed_repos << RepoError.new(tag: repo.to_s,
142
+ message: e.message)
143
+ end
144
+ raise if config.fail_on_error
145
+ end
146
+ end
147
+ end
148
+ threads.each { |t| t.join(300) }
149
+
150
+ [publications, reports, failed_repos]
151
+ end
152
+
87
153
  def process_repo(repo, output_dir, config)
88
154
  repo_key = repo.to_s
89
155
 
@@ -96,6 +162,17 @@ module Metanorma
96
162
  etag = @deps.delta_state.etag(repo_key)
97
163
  fetch_result = @deps.fetcher.fetch(repo, etag: etag)
98
164
 
165
+ if fetch_result.failed?
166
+ # The listing failed (rate limit, network) — this is NOT "the
167
+ # releases are gone". Keep the existing extracted files and
168
+ # delta state untouched so a later run can reconcile.
169
+ return [], RepoReport.new(
170
+ releases: 0, included: 0, skipped: 0,
171
+ reason: "fetch failed — existing state kept",
172
+ errors: [RepoError.new(tag: nil, message: "release listing failed")]
173
+ )
174
+ end
175
+
99
176
  if fetch_result.unchanged?
100
177
  return [], RepoReport.new(releases: 0, included: 0, skipped: 0,
101
178
  reason: "etag unchanged", errors: [])
@@ -116,25 +193,27 @@ module Metanorma
116
193
  tag = release.tag_name
117
194
  current_tags << tag
118
195
 
119
- content_hash = extract_content_hash(release.body)
120
-
121
- if @deps.delta_state.processed?(repo_key, tag, content_hash)
122
- files = @deps.delta_state.release_files(repo_key, tag)
123
- if files.all? { |f| File.exist?(File.join(output_dir, f)) }
124
- publications << build_publication(metadata, files, content_hash,
125
- release, repo)
126
- next
127
- end
196
+ zip_asset = find_zip_asset(release)
197
+ fingerprint = release_fingerprint(release, zip_asset)
198
+ cached_files = @deps.delta_state.release_files(repo_key, tag)
199
+ cached_fingerprint = @deps.delta_state.release_fingerprint(repo_key, tag)
200
+ if fingerprint &&
201
+ cached_files.any? &&
202
+ cached_fingerprint == fingerprint &&
203
+ cached_files.all? { |f| File.exist?(File.join(output_dir, f)) }
204
+ publications << build_publication(metadata, cached_files, release,
205
+ repo)
206
+ next
128
207
  end
129
208
 
130
- zip_asset = find_zip_asset(release)
131
209
  next unless zip_asset
132
210
 
133
211
  result = @deps.asset_processor.process(zip_asset.data, metadata_h)
134
- @deps.delta_state.mark_processed(repo_key, tag, content_hash,
135
- result.files.map(&:path))
212
+ @deps.delta_state.mark_processed(repo_key, tag, nil,
213
+ result.files.map(&:path),
214
+ fingerprint: fingerprint)
136
215
  publications << build_publication(metadata, result.files.map(&:path),
137
- content_hash, release, repo)
216
+ release, repo)
138
217
  rescue StandardError => e
139
218
  errors << RepoError.new(tag: release.tag_name, message: e.message)
140
219
  end
@@ -150,22 +229,26 @@ module Metanorma
150
229
  )]
151
230
  end
152
231
 
153
- def build_publication(metadata, files, _content_hash, release, repo)
232
+ def build_publication(metadata, files, release, repo)
154
233
  metadata.with_files_and_source(files, release, repo)
155
234
  end
156
235
 
157
- def extract_content_hash(body)
158
- return nil if body.nil?
159
-
160
- match = body.match(/^content-hash:([a-f0-9]+)/)
161
- match ? match[1] : nil
162
- end
163
-
164
236
  def find_zip_asset(release)
165
237
  return nil unless release.assets
166
238
 
167
239
  release.assets.find { |a| a.name.end_with?(".zip") }
168
240
  end
241
+
242
+ # Identity of a release's CONTENT from listing data alone (no
243
+ # downloads): a force-recreated release gets a new timestamp and
244
+ # asset, so a fingerprint mismatch means the cached extraction is
245
+ # stale even though the tag (and the on-disk files) still exist.
246
+ def release_fingerprint(release, zip_asset)
247
+ return nil if zip_asset.nil?
248
+
249
+ size = zip_asset.respond_to?(:size) ? zip_asset.size : nil
250
+ [release.published_at, zip_asset.name, size].join("|")
251
+ end
169
252
  end
170
253
  end
171
254
  end
@@ -12,8 +12,6 @@ module Metanorma
12
12
  class AssetProcessor
13
13
  ProcessResult = Struct.new(:files, :channels, keyword_init: true)
14
14
 
15
- CANONICALIZE_PATTERN = /-ed\d+(\.\d+)?-/
16
-
17
15
  def initialize(output_dir:, routing:, canonicalize: true)
18
16
  @output_dir = output_dir
19
17
  @routing = routing
@@ -37,7 +35,9 @@ module Metanorma
37
35
  dest_path = File.join(@output_dir, relative_path)
38
36
 
39
37
  FileUtils.mkdir_p(File.dirname(dest_path))
40
- entry.extract(dest_path) { true }
38
+ entry.extract(File.basename(dest_path),
39
+ destination_directory:
40
+ File.dirname(dest_path)) { true }
41
41
 
42
42
  ext = File.extname(file_name).delete_prefix(".")
43
43
  files << PublicationFile.new(format: ext, name: file_name,
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "json"
4
5
 
5
6
  module Metanorma
@@ -8,12 +8,13 @@ module Metanorma
8
8
  class ContentHashChangeDetector
9
9
  include ChangeDetector
10
10
 
11
- def initialize(previous_releases:)
11
+ def initialize(previous_releases:, output_dir: ".")
12
12
  @previous_releases = previous_releases
13
+ @output_dir = output_dir
13
14
  end
14
15
 
15
16
  def detect(publication, tag, force: false)
16
- current = publication.content_hash
17
+ current = publication.content_hash(from_directory: @output_dir)
17
18
  previous = @previous_releases[tag.to_s]
18
19
  changed = force || previous.nil? || !current.eql?(previous)
19
20
  ChangeResult.new(changed?: changed, current_hash: current,
@@ -11,6 +11,17 @@ module Metanorma
11
11
 
12
12
  class PipelineError < Thor::Error; end
13
13
 
14
+ desc "version", "Print version"
15
+ def version
16
+ puts "metanorma-release #{VERSION}"
17
+ end
18
+ map %w[--version -v] => :version
19
+
20
+ class_option :verbose, type: :boolean, default: false,
21
+ desc: "Verbose output"
22
+ class_option :quiet, type: :boolean, default: false,
23
+ desc: "Suppress warnings"
24
+
14
25
  desc "package", "Package compiled documents"
15
26
  option :output_dir, type: :string, default: "_site",
16
27
  desc: "Compiled docs directory"
@@ -21,6 +32,7 @@ module Metanorma
21
32
  option :config, type: :string, desc: "Config file"
22
33
 
23
34
  def package
35
+ configure_logging
24
36
  config = PackageCommand::Config.new(
25
37
  output_dir: options[:output_dir],
26
38
  dest: options[:dest],
@@ -49,6 +61,7 @@ module Metanorma
49
61
  option :config, type: :string, desc: "Config file"
50
62
 
51
63
  def release
64
+ configure_logging
52
65
  config = ReleaseCommand::Config.new(
53
66
  output_dir: options[:output_dir],
54
67
  platform: options[:platform],
@@ -75,21 +88,24 @@ module Metanorma
75
88
  option :repos, type: :array, desc: "Explicit repo list"
76
89
  option :channels, type: :array, default: [],
77
90
  desc: "Filter channels"
78
- option :output_dir, type: :string, default: "_site/cc",
79
- desc: "Output directory"
91
+ option :output_dir, type: :string,
92
+ desc: "Output directory (default: config file, or _site/cc)"
80
93
  option :file_routing, type: :string,
81
94
  desc: "File routing (by-document|flat|by-format)"
82
95
  option :cache_dir, type: :string, desc: "Cache directory"
83
- option :data_dir, type: :string, desc: "Write flattened documents.json for site generators"
96
+ option :data_dir, type: :string,
97
+ desc: "Write flattened documents.json for site generators"
84
98
  option :include_drafts, type: :boolean, default: false,
85
99
  desc: "Include draft releases"
86
100
  option :concurrency, type: :numeric, default: 4
87
101
  option :min_documents, type: :numeric, default: 0,
88
102
  desc: "Minimum required documents"
89
103
  option :token, type: :string, desc: "Platform auth token"
90
- option :config, type: :string, desc: "Config file (default: metanorma.aggregate.yml)"
104
+ option :config, type: :string,
105
+ desc: "Config file (default: metanorma.aggregate.yml)"
91
106
 
92
107
  def aggregate
108
+ configure_logging
93
109
  config = AggregateCommand.build_config(
94
110
  source: options[:source],
95
111
  organizations: options[:organizations],
@@ -123,6 +139,14 @@ module Metanorma
123
139
 
124
140
  private
125
141
 
142
+ def configure_logging
143
+ if options[:verbose]
144
+ Metanorma::Release.logger.level = Logger::DEBUG
145
+ elsif options[:quiet]
146
+ Metanorma::Release.logger.level = Logger::ERROR
147
+ end
148
+ end
149
+
126
150
  def print_package_result(result, dest)
127
151
  released = result.released
128
152
  puts "Packaged #{released.length} documents → #{dest}/"
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "yaml"
5
4
 
6
5
  module Metanorma
@@ -33,7 +32,6 @@ module Metanorma
33
32
  site.enrich!
34
33
  site.package! if @config.create_zip
35
34
 
36
- stamp_primary_identifiers(index)
37
35
  result
38
36
  end
39
37
 
@@ -74,7 +72,7 @@ module Metanorma
74
72
  topic: cli_options[:topic] || gh["topic"],
75
73
  repos: cli_options[:repos] || file_data["repos"],
76
74
  channels: cli_options[:channels].any? ? cli_options[:channels] : Array(file_data["channels"]),
77
- output_dir: cli_options[:output_dir] || file_data["output_dir"],
75
+ output_dir: cli_options[:output_dir] || file_data["output_dir"] || "_site/cc",
78
76
  file_routing: cli_options[:file_routing] || file_data["file_routing"] || "by-document",
79
77
  cache_dir: cli_options[:cache_dir] || file_data["cache_dir"],
80
78
  data_dir: cli_options[:data_dir] || file_data["data_dir"],
@@ -151,20 +149,6 @@ module Metanorma
151
149
  output_dir: @config.output_dir,
152
150
  )
153
151
  end
154
-
155
- def stamp_primary_identifiers(index)
156
- index.publications.each do |pub|
157
- next unless pub.to_h["bibliographic"]
158
-
159
- ids = pub.to_h.dig("bibliographic", "docidentifier")
160
- next unless ids&.any?
161
-
162
- primary = ids.find { |di| di["primary"] == true } || ids.first
163
- pub.to_h.merge("primary_identifier" => primary["content"])
164
- end
165
- rescue LoadError
166
- warn " (relaton gem not available — bibliography skipped)"
167
- end
168
152
  end
169
153
  end
170
154
  end
@@ -3,6 +3,8 @@
3
3
  module Metanorma
4
4
  module Release
5
5
  class PackageCommand
6
+ extend ConfigLoader
7
+
6
8
  Config = Struct.new(
7
9
  :output_dir, :dest, :manifest, :config_source,
8
10
  keyword_init: true
@@ -13,11 +15,15 @@ module Metanorma
13
15
  end
14
16
 
15
17
  def call
16
- config = load_config
18
+ config = self.class.load_config(
19
+ config_source: @config.config_source,
20
+ manifest: @config.manifest,
21
+ )
17
22
  deps = ReleasePipeline::Dependencies.new(
18
- extractor: Publication,
23
+ extractor: RxlExtractor,
19
24
  filters: [],
20
- change_detector: ContentHashChangeDetector.new(previous_releases: {}),
25
+ change_detector: ContentHashChangeDetector.new(previous_releases: {},
26
+ output_dir: @config.output_dir),
21
27
  packager: ZipPackager.new(output_dir: @config.output_dir),
22
28
  publisher: PlatformFactory.build_publisher("null", {}),
23
29
  slug_registry: SlugRegistry.from_config(config),
@@ -28,27 +34,13 @@ module Metanorma
28
34
 
29
35
  pipeline_config = ReleasePipeline::Config.new(
30
36
  output_dir: @config.output_dir,
31
- manifest_path: @config.manifest,
32
37
  force: false,
33
38
  force_replace_patterns: nil,
34
39
  concurrency: 4,
35
- default_visibility: "public",
36
40
  )
37
41
 
38
42
  ReleasePipeline.new(deps).run(pipeline_config)
39
43
  end
40
-
41
- private
42
-
43
- def load_config
44
- if @config.config_source && File.exist?(@config.config_source)
45
- Metanorma::Release::Config.from_file(@config.config_source)
46
- elsif @config.manifest && File.exist?(@config.manifest)
47
- Metanorma::Release::Config.from_file(@config.manifest)
48
- else
49
- Metanorma::Release::Config.defaults
50
- end
51
- end
52
44
  end
53
45
  end
54
46
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
-
5
3
  module Metanorma
6
4
  module Release
7
5
  class ReleaseCommand
6
+ extend ConfigLoader
7
+
8
8
  Config = Struct.new(
9
9
  :output_dir, :platform, :manifest, :force,
10
10
  :force_replace, :channels, :concurrency, :token, :config_source,
@@ -16,15 +16,20 @@ module Metanorma
16
16
  end
17
17
 
18
18
  def call
19
- config = load_config
19
+ config = self.class.load_config(
20
+ config_source: @config.config_source,
21
+ manifest: @config.manifest,
22
+ )
20
23
  options = { token: @config.token }
21
24
  publisher = PlatformFactory.build_publisher(@config.platform, options)
22
25
  channel_override = Channel.parse_list(@config.channels) if @config.channels
23
26
 
27
+ previous_releases = build_previous_releases
24
28
  deps = ReleasePipeline::Dependencies.new(
25
- extractor: Publication,
29
+ extractor: RxlExtractor,
26
30
  filters: [],
27
- change_detector: ContentHashChangeDetector.new(previous_releases: {}),
31
+ change_detector: ContentHashChangeDetector.new(previous_releases: previous_releases,
32
+ output_dir: @config.output_dir),
28
33
  packager: ZipPackager.new(output_dir: @config.output_dir),
29
34
  publisher: publisher,
30
35
  slug_registry: SlugRegistry.from_config(config),
@@ -35,11 +40,9 @@ module Metanorma
35
40
 
36
41
  pipeline_config = ReleasePipeline::Config.new(
37
42
  output_dir: @config.output_dir,
38
- manifest_path: @config.manifest,
39
43
  force: @config.force,
40
44
  force_replace_patterns: @config.force_replace && !@config.force_replace.empty? ? @config.force_replace : nil,
41
45
  concurrency: @config.concurrency,
42
- default_visibility: "public",
43
46
  )
44
47
 
45
48
  ReleasePipeline.new(deps).run(pipeline_config)
@@ -47,13 +50,15 @@ module Metanorma
47
50
 
48
51
  private
49
52
 
50
- def load_config
51
- if @config.config_source && File.exist?(@config.config_source)
52
- Metanorma::Release::Config.from_file(@config.config_source)
53
- elsif @config.manifest && File.exist?(@config.manifest)
54
- Metanorma::Release::Config.from_file(@config.manifest)
55
- else
56
- Metanorma::Release::Config.defaults
53
+ def build_previous_releases
54
+ pubs = RxlExtractor.discover(@config.output_dir)
55
+ pubs.each_with_object({}) do |pub, map|
56
+ pub.slug
57
+ registry = SlugRegistry.default
58
+ strategy = registry.resolve(SlugStrategy.publisher_from_identifier(pub.identifier))
59
+ tag_info = strategy.compute_tag(pub)
60
+ content_hash = pub.content_hash(from_directory: @config.output_dir)
61
+ map[tag_info[:tag]] = content_hash if content_hash
57
62
  end
58
63
  end
59
64
  end
@@ -60,13 +60,26 @@ module Metanorma
60
60
  end
61
61
  end
62
62
 
63
- # Single routing entry — matches by any combination of pattern, source,
63
+ # Shared config resolution logic for CLI commands.
64
+ module ConfigLoader
65
+ def load_config(config_source:, manifest:)
66
+ if config_source && File.exist?(config_source)
67
+ Config.from_file(config_source)
68
+ elsif manifest && File.exist?(manifest)
69
+ Config.from_file(manifest)
70
+ else
71
+ Config.defaults
72
+ end
73
+ end
74
+ end
75
+
76
+ # Single routing entry — matches by any combination of pattern,
64
77
  # stage, and doctype. An entry with no criteria matches everything (catch-all).
65
- DocumentEntry = Struct.new(:pattern, :source, :stages, :doctypes, :channels, keyword_init: true) do
78
+ DocumentEntry = Struct.new(:pattern, :stages, :doctypes,
79
+ :channels, keyword_init: true) do
66
80
  def initialize(data)
67
81
  super(
68
82
  pattern: data["pattern"],
69
- source: data["source"],
70
83
  stages: Array(data["stage"]).map(&:to_s),
71
84
  doctypes: Array(data["doctype"]).map(&:to_s),
72
85
  channels: Array(data["channels"]).map(&:to_s),
@@ -80,10 +93,6 @@ module Metanorma
80
93
  return false
81
94
  end
82
95
 
83
- if source && !(publication.source_path&.end_with?(source) || false)
84
- return false
85
- end
86
-
87
96
  if !stages.empty? && !stages.include?(publication.stage.to_s)
88
97
  return false
89
98
  end
@@ -29,6 +29,10 @@ module Metanorma
29
29
  raise NotImplementedError, "#{self.class} must implement #release_files"
30
30
  end
31
31
 
32
+ def release_fingerprint(repo_key, tag)
33
+ raise NotImplementedError, "#{self.class} must implement #release_fingerprint"
34
+ end
35
+
32
36
  def mark_processed(repo_key, tag, content_hash, files)
33
37
  raise NotImplementedError,
34
38
  "#{self.class} must implement #mark_processed"
@@ -85,11 +89,20 @@ module Metanorma
85
89
  releases[tag]["files"] || []
86
90
  end
87
91
 
88
- def mark_processed(repo_key, tag, content_hash, files)
92
+ def release_fingerprint(repo_key, tag)
93
+ releases = repo_state(repo_key)["releases"]
94
+ return nil unless releases.key?(tag)
95
+
96
+ releases[tag]["fingerprint"]
97
+ end
98
+
99
+ def mark_processed(repo_key, tag, content_hash, files,
100
+ fingerprint: nil)
89
101
  repo = ensure_repo(repo_key)
90
102
  repo["releases"][tag] = {
91
103
  "content_hash" => content_hash.to_s,
92
104
  "files" => files,
105
+ "fingerprint" => fingerprint,
93
106
  }
94
107
  end
95
108
 
@@ -138,6 +151,7 @@ module Metanorma
138
151
  def set_etag(_repo_key, _etag) = nil
139
152
  def processed?(*_args) = false
140
153
  def release_files(*_args) = []
154
+ def release_fingerprint(*_args) = nil
141
155
  def mark_processed(*_args); end
142
156
  def cleanup_stale(*_args) = 0
143
157
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module DependencyValidation
6
+ private
7
+
8
+ def validate_interface!(obj, mod, name)
9
+ return if obj.is_a?(mod) || begin
10
+ obj.class.ancestors.include?(mod)
11
+ rescue StandardError
12
+ false
13
+ end
14
+
15
+ raise ArgumentError, "#{name} must include #{mod}, got #{obj.class}"
16
+ end
17
+ end
18
+ end
19
+ end