metanorma-release 0.2.1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +504 -0
- data/CHANGELOG.md +15 -0
- data/PROMPT.md +282 -0
- data/README.adoc +430 -0
- data/Rakefile +8 -0
- data/exe/mn-release +6 -0
- data/lib/metanorma/release/aggregation_interfaces.rb +33 -0
- data/lib/metanorma/release/aggregation_pipeline.rb +155 -0
- data/lib/metanorma/release/asset_processor.rb +58 -0
- data/lib/metanorma/release/cache_store.rb +86 -0
- data/lib/metanorma/release/change_detector.rb +20 -0
- data/lib/metanorma/release/channel.rb +64 -0
- data/lib/metanorma/release/channel_audience.rb +24 -0
- data/lib/metanorma/release/channel_config.rb +55 -0
- data/lib/metanorma/release/channel_filter.rb +26 -0
- data/lib/metanorma/release/channel_manifest.rb +192 -0
- data/lib/metanorma/release/channel_registry.rb +60 -0
- data/lib/metanorma/release/cli.rb +129 -0
- data/lib/metanorma/release/commands/aggregate.rb +126 -0
- data/lib/metanorma/release/commands/package.rb +46 -0
- data/lib/metanorma/release/commands/publish.rb +51 -0
- data/lib/metanorma/release/config_fetcher.rb +11 -0
- data/lib/metanorma/release/config_locator.rb +37 -0
- data/lib/metanorma/release/config_resolver.rb +37 -0
- data/lib/metanorma/release/content_hash.rb +51 -0
- data/lib/metanorma/release/delta_state.rb +108 -0
- data/lib/metanorma/release/document_id.rb +45 -0
- data/lib/metanorma/release/document_index.rb +183 -0
- data/lib/metanorma/release/document_metadata.rb +39 -0
- data/lib/metanorma/release/document_stage.rb +86 -0
- data/lib/metanorma/release/document_type.rb +55 -0
- data/lib/metanorma/release/document_version.rb +50 -0
- data/lib/metanorma/release/file_routing.rb +51 -0
- data/lib/metanorma/release/interfaces.rb +47 -0
- data/lib/metanorma/release/naming_strategy.rb +158 -0
- data/lib/metanorma/release/platform/github/config_fetcher.rb +40 -0
- data/lib/metanorma/release/platform/github/manifest_reader.rb +32 -0
- data/lib/metanorma/release/platform/github/publisher.rb +73 -0
- data/lib/metanorma/release/platform/github/release_fetcher.rb +52 -0
- data/lib/metanorma/release/platform/github/topic_discoverer.rb +29 -0
- data/lib/metanorma/release/platform/github.rb +25 -0
- data/lib/metanorma/release/platform/local/config_fetcher.rb +20 -0
- data/lib/metanorma/release/platform/local/directory_discoverer.rb +26 -0
- data/lib/metanorma/release/platform/local/fetcher.rb +76 -0
- data/lib/metanorma/release/platform/local/publisher.rb +44 -0
- data/lib/metanorma/release/platform/local.rb +14 -0
- data/lib/metanorma/release/platform/null/publisher.rb +17 -0
- data/lib/metanorma/release/platform/null.rb +11 -0
- data/lib/metanorma/release/platform.rb +11 -0
- data/lib/metanorma/release/platform_factory.rb +78 -0
- data/lib/metanorma/release/rake_tasks.rb +71 -0
- data/lib/metanorma/release/relaton_enricher.rb +138 -0
- data/lib/metanorma/release/release_metadata.rb +79 -0
- data/lib/metanorma/release/release_pipeline.rb +115 -0
- data/lib/metanorma/release/release_tag.rb +49 -0
- data/lib/metanorma/release/repo_ref.rb +34 -0
- data/lib/metanorma/release/rxl_extractor.rb +115 -0
- data/lib/metanorma/release/stage_filter.rb +18 -0
- data/lib/metanorma/release/version.rb +7 -0
- data/lib/metanorma/release/zip_packager.rb +37 -0
- data/lib/metanorma/release.rb +116 -0
- metadata +156 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
module CacheStore
|
|
8
|
+
def get(key)
|
|
9
|
+
raise NotImplementedError, "#{self.class} must implement #get"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def set(key, value)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #set"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def delete(key)
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def clear
|
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #clear"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def keys
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #keys"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class FileCacheStore
|
|
30
|
+
include CacheStore
|
|
31
|
+
|
|
32
|
+
def initialize(directory)
|
|
33
|
+
@directory = directory
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get(key)
|
|
37
|
+
path = file_path(key)
|
|
38
|
+
return nil unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
File.read(path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set(key, value)
|
|
44
|
+
FileUtils.mkdir_p(@directory)
|
|
45
|
+
File.write(file_path(key), value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delete(key)
|
|
49
|
+
path = file_path(key)
|
|
50
|
+
File.delete(path) if File.exist?(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear
|
|
54
|
+
return unless Dir.exist?(@directory)
|
|
55
|
+
|
|
56
|
+
Dir.glob(File.join(@directory, '*')).each do |f|
|
|
57
|
+
File.delete(f) if File.file?(f)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def keys
|
|
62
|
+
return [] unless Dir.exist?(@directory)
|
|
63
|
+
|
|
64
|
+
Dir.glob(File.join(@directory, '*')).select { |f| File.file?(f) }
|
|
65
|
+
.map { |f| File.basename(f) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def file_path(key)
|
|
71
|
+
sanitized = key.gsub(/[^a-zA-Z0-9._-]/, '_')
|
|
72
|
+
File.join(@directory, sanitized)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class NullCacheStore
|
|
77
|
+
include CacheStore
|
|
78
|
+
|
|
79
|
+
def get(_key) = nil
|
|
80
|
+
def set(_key, _value) = nil
|
|
81
|
+
def delete(_key) = nil
|
|
82
|
+
def clear = nil
|
|
83
|
+
def keys = []
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class ContentHashChangeDetector
|
|
6
|
+
include ChangeDetector
|
|
7
|
+
|
|
8
|
+
def initialize(previous_releases:)
|
|
9
|
+
@previous_releases = previous_releases
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def detect(metadata, tag, force: false)
|
|
13
|
+
current = ContentHash.of_directory(metadata.output_dir, base: metadata.file_base_name)
|
|
14
|
+
previous = @previous_releases[tag.to_s]
|
|
15
|
+
changed = force || previous.nil? || !current.eql?(previous)
|
|
16
|
+
ChangeResult.new(changed?: changed, current_hash: current, previous_hash: previous)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class Channel
|
|
6
|
+
def self.parse(channel_string)
|
|
7
|
+
parts = channel_string.to_s.strip.split('/', 2)
|
|
8
|
+
if ChannelAudience.values.include?(parts[0])
|
|
9
|
+
new(audience: parts[0], category: parts[1] || 'default')
|
|
10
|
+
else
|
|
11
|
+
new(audience: ChannelAudience::PUBLIC, category: parts[0])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.parse_list(strings)
|
|
16
|
+
(strings || []).map { |s| parse(s) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.public(category)
|
|
20
|
+
new(audience: ChannelAudience::PUBLIC, category: category)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.members(category)
|
|
24
|
+
new(audience: ChannelAudience::MEMBERS, category: category)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.internal(category)
|
|
28
|
+
new(audience: ChannelAudience::INTERNAL, category: category)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :audience, :category
|
|
32
|
+
|
|
33
|
+
def initialize(audience:, category:)
|
|
34
|
+
@audience = audience
|
|
35
|
+
@category = category
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_s
|
|
40
|
+
"#{audience}/#{category}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def public?
|
|
44
|
+
audience == ChannelAudience::PUBLIC
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def members?
|
|
48
|
+
audience == ChannelAudience::MEMBERS
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def matches?(filter_channels)
|
|
52
|
+
filter_channels.any? { |c| eql?(Channel.parse(c)) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def eql?(other)
|
|
56
|
+
other.is_a?(self.class) && audience == other.audience && category == other.category
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hash
|
|
60
|
+
[audience, category].hash
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
module ChannelAudience
|
|
6
|
+
PUBLIC = 'public'
|
|
7
|
+
MEMBERS = 'members'
|
|
8
|
+
INTERNAL = 'internal'
|
|
9
|
+
|
|
10
|
+
ALL = [PUBLIC, MEMBERS, INTERNAL].freeze
|
|
11
|
+
|
|
12
|
+
def self.values
|
|
13
|
+
ALL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_string(raw)
|
|
17
|
+
normalized = raw.to_s.downcase.strip
|
|
18
|
+
return normalized if ALL.include?(normalized)
|
|
19
|
+
|
|
20
|
+
raise ArgumentError, "Unknown audience: #{raw.inspect}. Expected one of: #{ALL.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
class ChannelConfig
|
|
8
|
+
def self.from_yaml(yaml_string)
|
|
9
|
+
data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
|
|
10
|
+
raise ArgumentError, 'Invalid channel config YAML' unless data.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
registry = ChannelRegistry.from_yaml(yaml_string)
|
|
13
|
+
defaults = data['defaults'] || {}
|
|
14
|
+
default_visibility = defaults['visibility'] || 'public'
|
|
15
|
+
default_channels = parse_channels(defaults['channels'])
|
|
16
|
+
|
|
17
|
+
new(registry: registry, default_visibility: default_visibility,
|
|
18
|
+
default_channels: default_channels)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.from_file(path)
|
|
22
|
+
if File.directory?(path)
|
|
23
|
+
channels_yml = File.join(path, 'channels.yml')
|
|
24
|
+
raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(channels_yml)
|
|
25
|
+
|
|
26
|
+
return from_file(channels_yml)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(path)
|
|
30
|
+
|
|
31
|
+
from_yaml(File.read(path))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.empty
|
|
35
|
+
new(registry: ChannelRegistry.all_allowed,
|
|
36
|
+
default_visibility: 'public', default_channels: [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(registry:, default_visibility:, default_channels:)
|
|
40
|
+
@registry = registry
|
|
41
|
+
@default_visibility = default_visibility
|
|
42
|
+
@default_channels = default_channels.freeze
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :registry, :default_visibility, :default_channels
|
|
47
|
+
|
|
48
|
+
def self.parse_channels(list)
|
|
49
|
+
return [] unless list
|
|
50
|
+
|
|
51
|
+
list.map { |c| Channel.parse(c.to_s) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class ChannelFilter
|
|
6
|
+
def initialize(channels)
|
|
7
|
+
@channels = channels.map { |c| Channel.parse(c) }
|
|
8
|
+
@all = @channels.empty?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def matches?(release_metadata)
|
|
12
|
+
return true if @all
|
|
13
|
+
|
|
14
|
+
release_channels = (release_metadata['channels'] || []).map { |c| Channel.parse(c) }
|
|
15
|
+
release_channels.any? { |rc| @channels.any? { |fc| fc.eql?(rc) } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def overlaps?(manifest_channels)
|
|
19
|
+
return true if @all
|
|
20
|
+
|
|
21
|
+
parsed = manifest_channels.map { |c| Channel.parse(c) }
|
|
22
|
+
parsed.any? { |mc| @channels.any? { |fc| fc.eql?(mc) } }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
class DocumentReleasePolicy
|
|
8
|
+
def self.from_defaults(visibility, channels)
|
|
9
|
+
ch = build_channels(visibility, channels)
|
|
10
|
+
is_released = visibility != 'private' || !ch.empty?
|
|
11
|
+
new(release: is_released, channels: ch, stage_allow_list: nil)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.from_entry(entry)
|
|
15
|
+
ch = build_channels(entry.visibility, entry.channels)
|
|
16
|
+
new(release: true, channels: ch, stage_allow_list: entry.stages_set)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.not_released
|
|
20
|
+
new(release: false, channels: [].freeze, stage_allow_list: nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(release:, channels:, stage_allow_list:)
|
|
24
|
+
@release = release
|
|
25
|
+
@channels = channels.freeze
|
|
26
|
+
@stage_allow_list = stage_allow_list
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def release?
|
|
31
|
+
@release
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :channels, :stage_allow_list
|
|
35
|
+
|
|
36
|
+
def self.build_channels(visibility, explicit_channels)
|
|
37
|
+
return explicit_channels if explicit_channels && !explicit_channels.empty?
|
|
38
|
+
|
|
39
|
+
case visibility
|
|
40
|
+
when 'public' then [Channel.public('default')]
|
|
41
|
+
when 'members' then [Channel.members('default')]
|
|
42
|
+
else [].freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class ManifestEntry
|
|
48
|
+
attr_reader :source, :pattern, :visibility, :channels, :stages
|
|
49
|
+
|
|
50
|
+
def initialize(source:, pattern:, visibility:, channels:, stages:)
|
|
51
|
+
@source = source
|
|
52
|
+
@pattern = pattern
|
|
53
|
+
@visibility = visibility
|
|
54
|
+
@channels = channels
|
|
55
|
+
@stages = stages
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def match_priority
|
|
60
|
+
return 100 if source
|
|
61
|
+
return 50 + pattern.to_s.length if pattern
|
|
62
|
+
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stages_set
|
|
67
|
+
return nil if stages.nil? || stages.empty?
|
|
68
|
+
|
|
69
|
+
Set.new(stages.map(&:downcase))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class ChannelManifest
|
|
74
|
+
def self.parse(yaml_hash)
|
|
75
|
+
defaults = yaml_hash['defaults'] || {}
|
|
76
|
+
default_visibility = defaults['visibility'] || 'public'
|
|
77
|
+
default_channels = parse_channels(defaults['channels'])
|
|
78
|
+
entries = parse_entries(yaml_hash['documents'] || [])
|
|
79
|
+
config_source = yaml_hash['config']
|
|
80
|
+
|
|
81
|
+
new(entries: entries, default_visibility: default_visibility,
|
|
82
|
+
default_channels: default_channels, explicit: true, config_source: config_source)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.from_yaml(yaml_string)
|
|
86
|
+
yaml = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
|
|
87
|
+
raise ArgumentError, 'Manifest YAML is empty' unless yaml.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
parse(yaml)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.from_file(path)
|
|
93
|
+
raise ArgumentError, "Manifest file not found: #{path}" unless File.exist?(path)
|
|
94
|
+
|
|
95
|
+
from_yaml(File.read(path))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.all_public
|
|
99
|
+
new(entries: [], default_visibility: 'public',
|
|
100
|
+
default_channels: [Channel.public('default')], explicit: false, config_source: nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.all_private
|
|
104
|
+
new(entries: [], default_visibility: 'private',
|
|
105
|
+
default_channels: [], explicit: false, config_source: nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def initialize(entries:, default_visibility:, default_channels:, explicit:, config_source: nil)
|
|
109
|
+
@entries = entries
|
|
110
|
+
@default_visibility = default_visibility
|
|
111
|
+
@default_channels = default_channels.freeze
|
|
112
|
+
@explicit = explicit
|
|
113
|
+
@config_source = config_source
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def resolve(document)
|
|
118
|
+
return default_policy unless @explicit
|
|
119
|
+
|
|
120
|
+
entry = find_best_match(document)
|
|
121
|
+
return DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels) unless entry
|
|
122
|
+
|
|
123
|
+
DocumentReleasePolicy.from_entry(entry)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def list_all
|
|
127
|
+
@entries
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def all_channels
|
|
131
|
+
(@default_channels + @entries.flat_map(&:channels)).uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def explicit?
|
|
135
|
+
@explicit
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
attr_reader :config_source
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def default_policy
|
|
143
|
+
DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def find_best_match(document)
|
|
147
|
+
source = extract_source(document)
|
|
148
|
+
matches = @entries.select { |e| entry_matches?(e, source) }
|
|
149
|
+
return nil if matches.empty?
|
|
150
|
+
|
|
151
|
+
matches.max_by(&:match_priority)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def entry_matches?(entry, source)
|
|
155
|
+
return false unless source
|
|
156
|
+
return true if entry.source && entry.source == source
|
|
157
|
+
return true if entry.pattern && File.fnmatch?(entry.pattern, source)
|
|
158
|
+
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def extract_source(document)
|
|
163
|
+
document['source_path']
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.parse_channels(channel_list)
|
|
167
|
+
return [] unless channel_list
|
|
168
|
+
|
|
169
|
+
channel_list.map { |c| Channel.parse(c.to_s) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.parse_entries(documents)
|
|
173
|
+
documents.map do |doc|
|
|
174
|
+
validate_entry!(doc)
|
|
175
|
+
ManifestEntry.new(
|
|
176
|
+
source: doc['source'],
|
|
177
|
+
pattern: doc['pattern'],
|
|
178
|
+
visibility: doc['visibility'],
|
|
179
|
+
channels: parse_channels(doc['channels']),
|
|
180
|
+
stages: doc['stages']
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.validate_entry!(doc)
|
|
186
|
+
return unless doc['source']&.include?('..')
|
|
187
|
+
|
|
188
|
+
raise ArgumentError, "Path traversal detected in manifest source: #{doc['source']}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
class ChannelRegistry
|
|
8
|
+
def self.from_yaml(yaml_string)
|
|
9
|
+
data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
|
|
10
|
+
raise ArgumentError, 'Invalid channel registry YAML' unless data.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
channels = parse_channel_list(data['channels'])
|
|
13
|
+
new(channels: channels)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_file(path)
|
|
17
|
+
raise ArgumentError, "Channel registry file not found: #{path}" unless File.exist?(path)
|
|
18
|
+
|
|
19
|
+
from_yaml(File.read(path))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.all_allowed
|
|
23
|
+
new(channels: [])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(channels:)
|
|
27
|
+
@channels = channels.freeze
|
|
28
|
+
@channel_set = channels.to_set
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def valid?(channel)
|
|
33
|
+
return true if @channels.empty?
|
|
34
|
+
|
|
35
|
+
@channel_set.include?(channel)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def include?(channel_or_string)
|
|
39
|
+
valid?(Channel.parse(channel_or_string.to_s))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :channels
|
|
43
|
+
|
|
44
|
+
def empty?
|
|
45
|
+
@channels.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.parse_channel_list(list)
|
|
49
|
+
return [] unless list
|
|
50
|
+
|
|
51
|
+
list.filter_map do |entry|
|
|
52
|
+
case entry
|
|
53
|
+
when Hash then Channel.parse(entry['name'].to_s)
|
|
54
|
+
when String then Channel.parse(entry)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
module CLI
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def run(argv)
|
|
11
|
+
command = argv.shift
|
|
12
|
+
case command
|
|
13
|
+
when 'package' then run_package(argv)
|
|
14
|
+
when 'publish' then run_publish(argv)
|
|
15
|
+
when 'aggregate' then run_aggregate(argv)
|
|
16
|
+
when nil
|
|
17
|
+
warn 'Usage: mn-release <package|publish|aggregate> [options]'
|
|
18
|
+
exit 2
|
|
19
|
+
else
|
|
20
|
+
warn "Unknown command: #{command}"
|
|
21
|
+
exit 2
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_package(argv)
|
|
26
|
+
config = parse_package_args(argv)
|
|
27
|
+
result = PackageCommand.new(config).call
|
|
28
|
+
print_package_result(result, config.dest)
|
|
29
|
+
exit(result.failed.empty? ? 0 : 1)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run_publish(argv)
|
|
33
|
+
config = parse_publish_args(argv)
|
|
34
|
+
result = PublishCommand.new(config).call
|
|
35
|
+
print_publish_result(result)
|
|
36
|
+
exit(result.failed.empty? ? 0 : 1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_aggregate(argv)
|
|
40
|
+
config = parse_aggregate_args(argv)
|
|
41
|
+
result = AggregateCommand.new(config).call
|
|
42
|
+
print_aggregate_result(result)
|
|
43
|
+
if config.min_documents.positive? && result.documents.length < config.min_documents
|
|
44
|
+
warn "Error: Found #{result.documents.length} documents, minimum is #{config.min_documents}"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
exit(result.failed_repos.empty? ? 0 : 1)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_package_args(argv)
|
|
54
|
+
options = { output_dir: '_site', dest: 'dist', manifest: 'metanorma.release.yml',
|
|
55
|
+
config_source: nil }
|
|
56
|
+
OptionParser.new do |opts|
|
|
57
|
+
opts.banner = 'Usage: mn-release package [options]'
|
|
58
|
+
opts.on('--output-dir DIR', 'Compiled docs directory') { |v| options[:output_dir] = v }
|
|
59
|
+
opts.on('--dest DIR', 'Destination for packages') { |v| options[:dest] = v }
|
|
60
|
+
opts.on('--manifest FILE', 'Release manifest file') { |v| options[:manifest] = v }
|
|
61
|
+
opts.on('--config SOURCE', 'Channel config (file path or platform ref)') { |v| options[:config_source] = v }
|
|
62
|
+
end.parse!(argv)
|
|
63
|
+
PackageCommand::Config.new(**options)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_publish_args(argv)
|
|
67
|
+
options = { output_dir: '_site', platform: 'github', manifest: 'metanorma.release.yml',
|
|
68
|
+
force: false, force_replace: [], channels: nil, concurrency: 4, token: nil,
|
|
69
|
+
config_source: nil }
|
|
70
|
+
OptionParser.new do |opts|
|
|
71
|
+
opts.banner = 'Usage: mn-release publish [options]'
|
|
72
|
+
opts.on('--platform NAME', 'github|local') { |v| options[:platform] = v }
|
|
73
|
+
opts.on('--output-dir DIR', 'Compiled docs directory') { |v| options[:output_dir] = v }
|
|
74
|
+
opts.on('--manifest FILE', 'Release manifest file') { |v| options[:manifest] = v }
|
|
75
|
+
opts.on('--force', 'Force release even if unchanged') { |v| options[:force] = v }
|
|
76
|
+
opts.on('--force-replace PAT', 'Glob patterns for force-replace') { |v| options[:force_replace] << v }
|
|
77
|
+
opts.on('--channels CHANS', 'Override channels (comma-separated)') { |v| options[:channels] = v.split(',') }
|
|
78
|
+
opts.on('--concurrency N', Integer) { |v| options[:concurrency] = v }
|
|
79
|
+
opts.on('--token TOKEN', 'Platform auth token') { |v| options[:token] = v }
|
|
80
|
+
opts.on('--config SOURCE', 'Channel config (file path or platform ref)') { |v| options[:config_source] = v }
|
|
81
|
+
end.parse!(argv)
|
|
82
|
+
PublishCommand::Config.new(**options)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_aggregate_args(argv)
|
|
86
|
+
options = { source: 'github', organizations: [], topic: 'metanorma-release',
|
|
87
|
+
repos: nil, channels: [], stages: [], output_dir: '_site/cc',
|
|
88
|
+
file_routing: 'by-document', cache_dir: nil,
|
|
89
|
+
include_drafts: false, concurrency: 4, min_documents: 0, token: nil }
|
|
90
|
+
OptionParser.new do |opts|
|
|
91
|
+
opts.banner = 'Usage: mn-release aggregate [options]'
|
|
92
|
+
opts.on('--source SOURCE', 'github|local:PATH') { |v| options[:source] = v }
|
|
93
|
+
opts.on('--organizations ORGS', 'Comma-separated org list') { |v| options[:organizations] = v.split(',') }
|
|
94
|
+
opts.on('--topic TOPIC', 'Repository topic') { |v| options[:topic] = v }
|
|
95
|
+
opts.on('--repos REPOS', 'Explicit repo list (comma-separated)') { |v| options[:repos] = v.split(',') }
|
|
96
|
+
opts.on('--channels CHANS', 'Filter channels (comma-separated)') { |v| options[:channels] = v.split(',') }
|
|
97
|
+
opts.on('--stages STAGES', 'Filter stages (comma-separated)') { |v| options[:stages] = v.split(',') }
|
|
98
|
+
opts.on('--output-dir DIR', 'Output directory') { |v| options[:output_dir] = v }
|
|
99
|
+
opts.on('--file-routing MODE', 'by-document|flat|by-format') { |v| options[:file_routing] = v }
|
|
100
|
+
opts.on('--cache-dir DIR', 'Cache directory') { |v| options[:cache_dir] = v }
|
|
101
|
+
opts.on('--[no-]include-drafts', 'Include draft releases') { |v| options[:include_drafts] = v }
|
|
102
|
+
opts.on('--concurrency N', Integer) { |v| options[:concurrency] = v }
|
|
103
|
+
opts.on('--min-documents N', Integer) { |v| options[:min_documents] = v }
|
|
104
|
+
opts.on('--token TOKEN', 'Platform auth token') { |v| options[:token] = v }
|
|
105
|
+
end.parse!(argv)
|
|
106
|
+
AggregateCommand::Config.new(**options)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def print_package_result(result, dest)
|
|
110
|
+
released = result.released
|
|
111
|
+
puts "Packaged #{released.length} documents → #{dest}/"
|
|
112
|
+
released.each { |doc| puts " #{doc.id}" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def print_publish_result(result)
|
|
116
|
+
puts "Released #{result.released.length}, skipped #{result.skipped.length}, failed #{result.failed.length}"
|
|
117
|
+
result.released.each { |doc| puts " RELEASED: #{doc.id} (#{doc.version.tag_component})" }
|
|
118
|
+
result.skipped.each { |doc| puts " SKIPPED: #{doc.id} (unchanged)" }
|
|
119
|
+
result.failed.each { |f| puts " FAILED: #{f[:document].id} - #{f[:error]}" }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def print_aggregate_result(result)
|
|
123
|
+
puts "Aggregated #{result.documents.length} documents from #{result.repo_count} repos"
|
|
124
|
+
puts "Index: #{result.channels_found.join(', ')}" unless result.channels_found.empty?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|