cimas 0.1.4 → 0.3.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.
@@ -0,0 +1,8 @@
1
+ module Cimas
2
+ module Cli
3
+ # A user-facing error: bad options, refused scope, missing config.
4
+ # The executable prints one clean line and exits 1; anything else is
5
+ # an internal error and keeps its backtrace.
6
+ class Error < StandardError; end
7
+ end
8
+ end
@@ -0,0 +1,239 @@
1
+ require "thor"
2
+ require "pathname"
3
+
4
+ module Cimas
5
+ module Cli
6
+ # CLI surface only: declares options/help for each subcommand and
7
+ # delegates to Cimas::Cli::Command (the orchestrator, where all
8
+ # behavior lives). Adding a subcommand = one desc, its
9
+ # method_option/shared_options block, and a one-line action calling
10
+ # run_command. Shared flags are declared exactly once in
11
+ # SHARED_OPTIONS.
12
+ class Runner < Thor
13
+ package_name "cimas"
14
+
15
+ # Without this Thor 1.5 exits 0 on its own errors (unknown
16
+ # command, bad options) — CI would read failure as success.
17
+ def self.exit_on_failure?
18
+ true
19
+ end
20
+
21
+ remove_command :tree
22
+
23
+ class_option :verbose, type: :boolean, aliases: "-v",
24
+ desc: "Run verbosely"
25
+ class_option :dry_run, type: :boolean,
26
+ desc: "Skip destructive/remote operations; print what would be done instead"
27
+
28
+ SHARED_OPTIONS = {
29
+ repos_path: { aliases: "-r", banner: "REPOS_PATH",
30
+ desc: "Repo root dir path" },
31
+ config_path: { aliases: "-f", banner: "CONFIG_FILE_PATH",
32
+ desc: "Config file path" },
33
+ master_path: { aliases: "-d", banner: "CONFIG_MASTER_DIR_PATH",
34
+ desc: "Config master path" },
35
+ groups: { aliases: "-g", banner: "GROUP1,GROUP2",
36
+ desc: "Groups to update" },
37
+ keep_changes: { aliases: "-k", type: :boolean,
38
+ desc: "Don't modify revert changes" },
39
+ }.freeze
40
+
41
+ # Thor option symbol → Command option key for value-carrying
42
+ # flags; the distinct -b/-m spellings per command map onto the
43
+ # command key the orchestrator reads.
44
+ STRING_OPTION_KEYS = {
45
+ push_branch: "push_to_branch",
46
+ commit_message: "commit_message",
47
+ merge_branch: "merge_branch",
48
+ pr_message: "pr_message",
49
+ pr_body: "pr_body",
50
+ pr_body_file: "pr_body_file",
51
+ assignees: "assignees",
52
+ reviewers: "reviewers",
53
+ shell_cmd: "shell_cmd",
54
+ cleanup_branch: "push_to_branch",
55
+ cleanup_branch_prefix: "cleanup_branch_prefix",
56
+ orphan_branch: "push_to_branch",
57
+ orphan_commit_message: "pr_message",
58
+ cleanup_only_targets: "cleanup_only_targets",
59
+ target_repo: "target_repo",
60
+ }.freeze
61
+
62
+ def self.shared_options(*names)
63
+ names.each { |name| method_option name, SHARED_OPTIONS.fetch(name) }
64
+ end
65
+
66
+ desc "setup", "Clone all repos described in the config"
67
+ shared_options :repos_path, :config_path
68
+ def setup
69
+ run_command("setup")
70
+ end
71
+
72
+ desc "pull", "Reset all repos to their configured branch and pull"
73
+ shared_options :repos_path, :config_path, :groups
74
+ def pull
75
+ run_command("pull")
76
+ end
77
+
78
+ desc "sync", "Update CI configurations for all repos described in the config"
79
+ shared_options :repos_path, :config_path, :master_path, :keep_changes, :groups
80
+ def sync
81
+ run_command("sync")
82
+ end
83
+
84
+ desc "diff", "Show diff for all repos"
85
+ shared_options :repos_path, :config_path, :groups
86
+ def diff
87
+ run_command("diff")
88
+ end
89
+
90
+ desc "push", "Push changes to the remote server (requires -g)"
91
+ shared_options :repos_path, :config_path, :keep_changes, :groups
92
+ method_option :push_branch, aliases: "-b", banner: "BRANCH",
93
+ desc: "Branch to push in all repos"
94
+ method_option :commit_message, aliases: "-m", banner: "MESSAGE",
95
+ desc: "Commit message"
96
+ method_option :force_push, type: :boolean,
97
+ desc: "Force push (with commit amend)"
98
+ def push
99
+ run_command("push")
100
+ end
101
+
102
+ desc "open-prs", "Open pull requests on GitHub (requires -g)"
103
+ shared_options :repos_path, :config_path, :groups
104
+ method_option :reviewers, aliases: "-w", banner: "REVIEWERS",
105
+ desc: "A comma-separated list (no spaces around the comma) of GitHub handles to request a review from"
106
+ method_option :merge_branch, aliases: "-b", banner: "BRANCH",
107
+ desc: "PR branch to merge into target"
108
+ method_option :pr_message, aliases: "-m", banner: "MESSAGE",
109
+ desc: "PR title (≤256 chars; what shows on the PR list page)"
110
+ method_option :pr_body, banner: "BODY",
111
+ desc: "Inline PR body (markdown). Mutually exclusive with --body-file."
112
+ method_option :pr_body_file, banner: "PATH",
113
+ desc: "Path to a file whose contents become the PR body (markdown). Preferred for any non-trivial body — shell-escape-safe and version-controllable. Mutually exclusive with --body."
114
+ method_option :assignees, aliases: "-a", banner: "ASSIGNMENTS",
115
+ desc: "A comma-separated list (no spaces around the comma) of GitHub handles to assign to this pull request."
116
+ method_option :supersede_stale, type: :boolean,
117
+ desc: "When opening a PR, detect any pre-existing open PRs on the same repo whose head branch starts with 'cimas-sync-' (i.e. previous wave PRs that never merged), label them 'superseded-by-#N' (where N is the new PR number), and post a comment linking the new PR. Does NOT auto-close the old PRs — the reviewer keeps authority over the close decision. The new PR's body is prepended with a 'Supersedes #X, #Y' line. See metanorma/ci#300 Gap 4."
118
+ method_option :flatten_stale, type: :boolean,
119
+ desc: "Gap 4 full: like --supersede-stale, but ALSO auto-closes the superseded PRs. Implies --supersede-stale. Use when confident the new wave's content strictly supersedes the older waves' (the standard cimas-sync-* case, since every wave regenerates the same files from cimas.yml). Superseded PRs get labelled 'superseded-closed-by-#N', a comment noting auto-closure, and are closed. See metanorma/ci#300 Gap 4 full."
120
+ def open_prs
121
+ run_command("open-prs")
122
+ end
123
+
124
+ desc "for-each", "Run a shell command for each repo (requires -g)"
125
+ shared_options :repos_path, :config_path, :groups
126
+ method_option :shell_cmd, aliases: "-c", banner: "SHELL_CMD",
127
+ desc: "Command to execute"
128
+ def for_each
129
+ run_command("for-each")
130
+ end
131
+
132
+ desc "cleanup-merged-prs", "Delete wave branches whose PR has merged (run after merges land; requires -g)"
133
+ shared_options :repos_path, :config_path, :groups
134
+ method_option :cleanup_branch, aliases: "-b", banner: "BRANCH",
135
+ desc: "Wave branch to clean up (same as the -b push_to_branch used in `cimas push`)"
136
+ def cleanup_merged_prs
137
+ run_command("cleanup-merged-prs")
138
+ end
139
+
140
+ desc "cleanup-closed-prs", "Delete wave branches whose PR was closed without merge (org-wide sweep of cimas-sync-*; requires -g)"
141
+ shared_options :repos_path, :config_path, :groups
142
+ method_option :cleanup_branch_prefix, banner: "PREFIX",
143
+ desc: "Branch-name prefix to sweep (default: cimas-sync-). All closed-not-merged PRs whose head.ref starts with this prefix have their remote branch deleted."
144
+ def cleanup_closed_prs
145
+ run_command("cleanup-closed-prs")
146
+ end
147
+
148
+ desc "cleanup-orphan-files", "Delete files with the Cimas auto-generated header that are no longer in cimas.yml mapping (sync's inverse; -g required with --push-after)"
149
+ shared_options :repos_path, :config_path, :groups
150
+ method_option :orphan_branch, aliases: "-b", banner: "BRANCH",
151
+ desc: "Branch to create with the orphan-file deletions (e.g. cleanup-orphans-2026-07-05). Same shape as `-b` on push."
152
+ method_option :orphan_commit_message, aliases: "-m", banner: "MESSAGE",
153
+ desc: "Commit message for the cleanup commit."
154
+ method_option :cleanup_push_after, type: :boolean,
155
+ desc: "After detecting + staging orphan deletions, commit + force-push the cleanup branch to each repo's remote. Without this flag the deletions are staged locally only (for inspection / manual push)."
156
+ method_option :cleanup_only_targets, banner: "PATH1,PATH2",
157
+ desc: "Narrow the sweep to specific target paths (comma-separated, no spaces). Files at other target paths are left alone even if orphaned. Use to scope a cleanup wave to one class of orphan (e.g. --only-target=.github/workflows/generate.yml for the ci#347 docker-only cleanup)."
158
+ def cleanup_orphan_files
159
+ run_command("cleanup-orphan-files")
160
+ end
161
+
162
+ desc "release-preflight", "Local fail-fast checks before firing a release workflow"
163
+ shared_options :repos_path, :config_path
164
+ method_option :target_repo, banner: "NAME",
165
+ desc: "Target gem repo to preflight (must be in cimas.yml)"
166
+ def release_preflight
167
+ run_command("release-preflight")
168
+ end
169
+
170
+ no_commands do
171
+ def run_command(name)
172
+ Cimas::Cli::Command.new(command_options).execute(name)
173
+ end
174
+
175
+ # Translates Thor's parsed options into the option hash
176
+ # Cimas::Cli::Command consumes: string keys, Pathname paths,
177
+ # comma-split groups. Keys absent here fall back to Command's
178
+ # DEFAULT_CONFIG.
179
+ def command_options
180
+ opts = options
181
+
182
+ result = path_options(opts)
183
+ result.merge!(string_options(opts))
184
+ result.merge!(flag_options(opts))
185
+ result["groups"] = comma_list(opts[:groups]) if opts[:groups]
186
+ if result["pr_body_file"]
187
+ result["pr_body_file"] = existing_file(result["pr_body_file"], flag: "--body-file")
188
+ end
189
+ result
190
+ end
191
+
192
+ def path_options(opts)
193
+ {
194
+ "repos_path" => Pathname.getwd + (opts[:repos_path] || "repos"),
195
+ "config_file_path" => existing_path(opts[:config_path] || "cimas.yml",
196
+ flag: "-f/--config-path"),
197
+ "config_master_path" => opts[:master_path] ? existing_path(opts[:master_path], flag: "-d/--master-path") : Pathname.getwd + "config",
198
+ "dry_run" => opts[:dry_run] == true,
199
+ "verbose" => opts[:verbose] == true,
200
+ }
201
+ end
202
+
203
+ def string_options(opts)
204
+ STRING_OPTION_KEYS.each_with_object({}) do |(thor_key, command_key), result|
205
+ result[command_key] = opts[thor_key] unless opts[thor_key].nil?
206
+ end
207
+ end
208
+
209
+ def flag_options(opts)
210
+ result = {}
211
+ %i[keep_changes force_push supersede_stale flatten_stale cleanup_push_after].each do |key|
212
+ result[key.to_s] = true if opts[key]
213
+ end
214
+ # flatten implies supersede
215
+ result["supersede_stale"] = true if result["flatten_stale"]
216
+ result
217
+ end
218
+
219
+ def existing_path(value, flag:)
220
+ path = Pathname.getwd + value
221
+ raise Cimas::Cli::Error, "#{flag} path is not set or does not exist: #{path}" unless path.exist?
222
+
223
+ path
224
+ end
225
+
226
+ def existing_file(value, flag:)
227
+ file = Pathname.getwd + value
228
+ raise Cimas::Cli::Error, "#{flag} path does not exist: #{file}" unless file.exist?
229
+
230
+ file
231
+ end
232
+
233
+ def comma_list(value)
234
+ value.split(",").map(&:strip).reject(&:empty?)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
data/lib/cimas/cli.rb ADDED
@@ -0,0 +1,7 @@
1
+ module Cimas
2
+ module Cli
3
+ autoload :Command, File.expand_path("cli/command", __dir__)
4
+ autoload :Error, File.expand_path("cli/error", __dir__)
5
+ autoload :Runner, File.expand_path("cli/runner", __dir__)
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ require "octokit"
2
+
3
+ module Cimas
4
+ # Octokit boundary: client construction, remote-to-slug mapping, and
5
+ # the visibility-lookup fallback policy live here; the orchestrator
6
+ # speaks in slugs. Pass `client:` to inject a stand-in for offline
7
+ # specs (production code always omits it).
8
+ class GitHub
9
+ def initialize(token: nil, client: nil)
10
+ @token = token
11
+ @client = client
12
+ end
13
+
14
+ def client
15
+ return @client if @client
16
+
17
+ if @token.nil?
18
+ raise "[ERROR] Please set GITHUB_TOKEN environment variable to use GitHub functions."
19
+ end
20
+ @client = Octokit::Client.new(access_token: @token)
21
+ end
22
+
23
+ # Maps any GitHub remote form (ssh://git@github.com/org/repo,
24
+ # git@github.com:org/repo.git, https://github.com/org/repo.git) to
25
+ # the `org/repo` slug Octokit expects.
26
+ def slug_for(remote)
27
+ match = remote.to_s.match(%r{github\.com[/:](.+?)(?:\.git)?\z})
28
+ unless match
29
+ raise "[ERROR] not a GitHub remote: #{remote.inspect} — cimas only operates on GitHub repositories."
30
+ end
31
+
32
+ match[1]
33
+ end
34
+
35
+ # True when the repo is GitHub-private. Falls back to `true` (no
36
+ # accidental public template picks) when the API is unreachable.
37
+ def fetch_visibility(slug)
38
+ client.repo(slug).private
39
+ rescue Octokit::NotFound
40
+ puts "[WARNING] Cannot fetch visibility for #{slug} (404); defaulting to `private` (safer)."
41
+ true
42
+ rescue StandardError => e
43
+ puts "[WARNING] Visibility fetch failed for #{slug}: #{e.message}; defaulting to `private` (safer)."
44
+ true
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ module Cimas
2
+ # Finds files under a repo working copy that cimas generated (they
3
+ # carry the generated header) but that the repo's current `files:`
4
+ # mapping no longer lists — `sync`'s inverse. Pure logic: no config,
5
+ # git, or GitHub access.
6
+ module OrphanFiles
7
+ # Bytes read per file when checking for the header — covers the
8
+ # two-line header plus any leading shebang or comment; shorter
9
+ # files read to EOF without error.
10
+ HEADER_READ_BYTES = 500
11
+
12
+ def self.find(repo_dir, mapped_targets, only_targets = nil)
13
+ orphans = []
14
+ Dir.glob(File.join(repo_dir, '**', '*'), File::FNM_DOTMATCH).each do |path|
15
+ next unless File.file?(path)
16
+ next if path.include?('/.git/') || path.end_with?('/.git')
17
+
18
+ head = read_head(path)
19
+ next unless head&.include?(Cimas::GENERATED_HEADER_MARKER)
20
+
21
+ rel_path = path.sub("#{repo_dir}/", '')
22
+ next if only_targets && !only_targets.include?(rel_path)
23
+
24
+ orphans << rel_path unless mapped_targets.include?(rel_path)
25
+ end
26
+ orphans
27
+ end
28
+
29
+ def self.read_head(path)
30
+ File.read(path, HEADER_READ_BYTES, encoding: 'UTF-8')
31
+ rescue ArgumentError, EncodingError, SystemCallError
32
+ # Binary, malformed encoding, or unreadable — cimas only writes
33
+ # text files, so these cannot be cimas-managed.
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ module Cimas
2
+ # Value object over one `patches:` entry in cimas.yml — an in-place
3
+ # regex find/replace applied to files already present in each target
4
+ # repo (unlike `files:` sync, which replaces whole files). See the
5
+ # Patches section of README.adoc.
6
+ class Patch
7
+ attr_reader :name, :globs, :find_regexp, :replacement, :group_names
8
+
9
+ def initialize(name, attributes)
10
+ @name = name
11
+ @globs = Array(attributes["files"])
12
+ @find_regexp = Regexp.new(attributes["find"])
13
+ @replacement = attributes["replace"]
14
+ @group_names = attributes["groups"] || []
15
+ end
16
+
17
+ def matches?(content)
18
+ find_regexp.match?(content)
19
+ end
20
+
21
+ def apply(content)
22
+ content.gsub(find_regexp, replacement)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,140 @@
1
+ module Cimas
2
+ # Local maintainer-side preflight that mirrors the GHA preflight job in
3
+ # `metanorma/ci/.github/workflows/rubygems-release.yml`. Runs against a
4
+ # single repo's workspace clone before the maintainer fires the
5
+ # `workflow_dispatch` to actually start the release chain. Catches the
6
+ # cheap, deterministic failure modes (bundle resolve, gemspec errors,
7
+ # missing credentials, already-published version) in ~30 sec locally,
8
+ # so the maintainer never commits to the 2+ hour chain on a release
9
+ # that was going to fail.
10
+ #
11
+ # Companion to the GHA-side preflight introduced in metanorma/ci PR #313.
12
+ # Both protect against the same class of failures; the GHA preflight
13
+ # protects every release attempt automatically, this one protects the
14
+ # maintainer who runs it before firing.
15
+ #
16
+ # `runner:` injects the process boundary for offline specs (production
17
+ # omits it and uses the default shell runner).
18
+ class ReleasePreflight
19
+ # Default process boundary: real shell + filesystem credential check.
20
+ class ShellRunner
21
+ def system!(*args)
22
+ system(*args)
23
+ end
24
+
25
+ def capture(cmd)
26
+ `#{cmd}`
27
+ end
28
+
29
+ def credentials_present?
30
+ File.exist?(File.expand_path("~/.gem/credentials"))
31
+ end
32
+
33
+ def api_key_present?
34
+ ENV["RUBYGEMS_API_KEY"] && !ENV["RUBYGEMS_API_KEY"].empty?
35
+ end
36
+ end
37
+
38
+ def initialize(command, runner: nil)
39
+ @command = command
40
+ @runner = runner || ShellRunner.new
41
+ end
42
+
43
+ def run
44
+ repo_name = @command.config["target_repo"]
45
+
46
+ repo = @command.repo_by_name(repo_name)
47
+ if repo.nil?
48
+ raise "[ERROR] #{repo_name} is not in cimas.yml. Run with --config-path pointing at the correct config."
49
+ end
50
+
51
+ repo_dir = File.join(@command.repos_path, repo_name)
52
+ unless File.exist?(repo_dir) && File.exist?(File.join(repo_dir, ".git"))
53
+ raise "[ERROR] #{repo_name} is not present in #{@command.repos_path}. Run `cimas setup` first."
54
+ end
55
+
56
+ puts "=== cimas release-preflight: #{repo_name} ==="
57
+ puts " workspace: #{repo_dir}"
58
+ puts ""
59
+
60
+ failures = []
61
+
62
+ Dir.chdir(repo_dir) do
63
+ puts "[1/4] Fresh bundle install (Gemfile.lock removed first)..."
64
+ File.delete("Gemfile.lock") if File.exist?("Gemfile.lock")
65
+ unless @runner.system!("bundle install --jobs 4 --retry 3")
66
+ failures << "bundle install"
67
+ puts " ✗ bundle install failed"
68
+ else
69
+ puts " ✓ bundle install succeeded"
70
+ end
71
+ puts ""
72
+
73
+ puts "[2/4] Gem build (validates gemspec)..."
74
+ gemspec_file = Dir["*.gemspec"].first
75
+ if gemspec_file.nil?
76
+ puts " ⚠️ No .gemspec file found in repo root; skipping gem build check"
77
+ else
78
+ if @runner.system!("gem build #{gemspec_file}")
79
+ puts " ✓ gem build succeeded"
80
+ Dir["*.gem"].each { |f| File.delete(f) }
81
+ else
82
+ failures << "gem build"
83
+ puts " ✗ gem build failed"
84
+ end
85
+ end
86
+ puts ""
87
+
88
+ puts "[3/4] Verify publish credentials available..."
89
+ if @runner.credentials_present?
90
+ puts " ✓ ~/.gem/credentials present (API-key publish path viable)"
91
+ elsif @runner.api_key_present?
92
+ puts " ✓ RUBYGEMS_API_KEY env var present"
93
+ else
94
+ puts " ⚠️ No ~/.gem/credentials and no RUBYGEMS_API_KEY env var."
95
+ puts " OIDC Trusted Publishing may still work in CI, but local `gem push` will fail."
96
+ puts " Configure ~/.gem/credentials if you intend to publish from this machine."
97
+ end
98
+ puts ""
99
+
100
+ puts "[4/4] Version awareness..."
101
+ gem_spec = gemspec_file ? Gem::Specification.load(gemspec_file) : nil
102
+ if gem_spec
103
+ gem_name = gem_spec.name
104
+ gem_version = gem_spec.version.to_s
105
+ remote_list = @runner.capture(
106
+ "gem list --remote --exact --version \"#{gem_version}\" \"#{gem_name}\" 2>/dev/null",
107
+ )
108
+ if remote_list.include?("#{gem_name} (#{gem_version})")
109
+ puts " ℹ️ #{gem_name} #{gem_version} is already on rubygems.org"
110
+ puts " With next_version=skip, the idempotent guard will skip the actual gem push."
111
+ puts " If you intended to ship NEW code, fire with next_version=patch (or major/minor)."
112
+ else
113
+ puts " ✓ #{gem_name} #{gem_version} is NOT yet on rubygems — clean to publish"
114
+ latest_list = @runner.capture(
115
+ "gem list --remote --exact \"#{gem_name}\" 2>/dev/null",
116
+ )
117
+ if latest_match = latest_list.match(/#{Regexp.escape(gem_name)} \(([0-9.]+)/)
118
+ puts " Latest published: #{gem_name} #{latest_match[1]}"
119
+ else
120
+ puts " (No previous public version found.)"
121
+ end
122
+ end
123
+ else
124
+ puts " (skipped — no gemspec to identify)"
125
+ end
126
+ puts ""
127
+ end
128
+
129
+ puts "=== Result ==="
130
+ if failures.empty?
131
+ puts "✓ All preflight checks passed for #{repo_name}."
132
+ puts " Safe to fire: gh workflow run release.yml --repo metanorma/#{repo_name} --field next_version=patch"
133
+ else
134
+ puts "✗ Preflight FAILED for #{repo_name}: #{failures.join(', ')}"
135
+ puts " Do NOT fire the release workflow until the failures above are fixed."
136
+ exit 1
137
+ end
138
+ end
139
+ end
140
+ end
@@ -1,25 +1,33 @@
1
1
  module Cimas
2
+ # Value object over one `repositories:` entry in cimas.yml. Always built
3
+ # with real attributes — unknown repo names are the caller's problem
4
+ # (see `Cli::Command#repo_by_name`, which returns nil for them).
2
5
  class Repository
3
- attr_reader :name, :remote, :branch, :files
6
+ attr_reader :name, :remote, :branch, :files, :binding, :with_values
4
7
 
5
8
  def initialize(name, attributes = {})
6
- init_from_attributes(name, attributes)
7
- end
8
-
9
- def nil?
10
- remote == nil && branch == nil
11
- end
12
-
13
- private
14
-
15
- def init_from_attributes(name, attributes)
16
- if attributes
17
- @name = name
9
+ @name = name
18
10
 
19
- @remote = attributes.fetch("remote", nil)
20
- @branch = attributes.fetch("branch", nil)
21
- @files = attributes.fetch("files", nil)
22
- end
11
+ @remote = attributes.fetch("remote", nil)
12
+ @branch = attributes.fetch("branch", nil)
13
+ # `files:` in cimas.yml is typically a Hash (`local_path: template_path`),
14
+ # but some entries use `files: []` (a YAML empty Array) to signal
15
+ # "no sync mappings — track the repo but don't touch any file."
16
+ # Normalise both shapes to a Hash so downstream `#files.keys` works.
17
+ raw_files = attributes.fetch("files", nil)
18
+ @files = raw_files.is_a?(Array) ? {} : (raw_files || {})
19
+ # Hash#dig doesn't accept a block — the `{ {} }` in the prior
20
+ # version was silently ignored, so absent `template: binding:`
21
+ # yielded `nil`. Explicit `|| {}` keeps the type stable (always a
22
+ # Hash); the sole consumer at `Cli::Command#sync` wraps it in an
23
+ # OpenStruct where nil and {} behave identically, so this is a
24
+ # correctness-not-behaviour fix.
25
+ @binding = attributes.dig("template", "binding") || {}
26
+ # `with:` is a per-repo top-level hash rendered into ERB templates
27
+ # as `with_values`. Semantically intended for reusable-workflow
28
+ # `with:` block inputs (per metanorma/ci#300 Gap 1) but the values
29
+ # are just a Hash — usable for any parametric rendering.
30
+ @with_values = attributes.fetch("with", {}) || {}
23
31
  end
24
32
  end
25
33
  end
data/lib/cimas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Cimas
2
- VERSION = "0.1.4"
2
+ VERSION = "0.3.0"
3
3
  end