cimas 0.1.3 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/rake.yml +18 -0
- data/.github/workflows/release.yml +31 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +29 -0
- data/CLAUDE.md +56 -0
- data/README.adoc +467 -14
- data/bin/console +1 -1
- data/bin/rspec +29 -0
- data/cimas.gemspec +4 -5
- data/exe/cimas +9 -201
- data/lib/cimas/cli/command.rb +837 -245
- data/lib/cimas/cli/error.rb +8 -0
- data/lib/cimas/cli/runner.rb +239 -0
- data/lib/cimas/cli.rb +7 -0
- data/lib/cimas/github.rb +47 -0
- data/lib/cimas/orphan_files.rb +37 -0
- data/lib/cimas/patch.rb +25 -0
- data/lib/cimas/release_preflight.rb +140 -0
- data/lib/cimas/repository.rb +33 -0
- data/lib/cimas/version.rb +1 -1
- data/lib/cimas/working_copy.rb +136 -0
- data/lib/cimas.rb +29 -2
- data/plans/cimas-revival-and-release-workflow-realignment.md +2158 -0
- metadata +40 -12
- data/Gemfile.lock +0 -86
|
@@ -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
data/lib/cimas/github.rb
ADDED
|
@@ -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
|
data/lib/cimas/patch.rb
ADDED
|
@@ -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
|
|
@@ -0,0 +1,33 @@
|
|
|
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).
|
|
5
|
+
class Repository
|
|
6
|
+
attr_reader :name, :remote, :branch, :files, :binding, :with_values
|
|
7
|
+
|
|
8
|
+
def initialize(name, attributes = {})
|
|
9
|
+
@name = name
|
|
10
|
+
|
|
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", {}) || {}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/cimas/version.rb
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "git"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Cimas
|
|
5
|
+
# Deep module over one repository's working copy: every git-gem call,
|
|
6
|
+
# exception rescue, and porcelain/CLI parsing the orchestrator needs
|
|
7
|
+
# lives behind ~10 domain verbs. Subcommands then read as wave prose
|
|
8
|
+
# instead of git incantations.
|
|
9
|
+
class WorkingCopy
|
|
10
|
+
def self.open(dir)
|
|
11
|
+
new(Git.open(dir))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.clone(remote, name, path:)
|
|
15
|
+
Git.clone(remote, name, path: path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(git)
|
|
19
|
+
@git = git
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def head_sha
|
|
23
|
+
@git.object("HEAD").sha
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def remote_name
|
|
27
|
+
@git.remotes.first.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True when the working copy has any uncommitted change.
|
|
31
|
+
def drift?
|
|
32
|
+
out, = Open3.capture3("git", "-C", dir, "status", "--porcelain")
|
|
33
|
+
!out.strip.empty?
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# checkout + reset_hard + clean: the "start from a pristine branch"
|
|
39
|
+
# preamble of sync and cleanup-orphan-files.
|
|
40
|
+
def reset_clean(branch, include_untracked: false)
|
|
41
|
+
checkout(branch)
|
|
42
|
+
@git.reset_hard(branch)
|
|
43
|
+
@git.clean(force: true, d: include_untracked)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stage(*paths)
|
|
47
|
+
paths.each { |path| @git.add(path) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remove(*paths)
|
|
51
|
+
paths.each { |path| @git.remove(path) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# True when nothing is staged or modified — push skips the
|
|
55
|
+
# commit in this case.
|
|
56
|
+
def clean?
|
|
57
|
+
status = @git.status
|
|
58
|
+
status.changed.empty? && status.added.empty? && status.deleted.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def commit_all(message)
|
|
62
|
+
@git.commit_all(message)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def commit(message)
|
|
66
|
+
@git.commit(message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# push's keep_changes=false preamble: stand on the base branch,
|
|
70
|
+
# soft-reset onto it, and discard an existing branch of the given
|
|
71
|
+
# name so it can be provisioned fresh.
|
|
72
|
+
def reset_onto(base, discard_branch: nil)
|
|
73
|
+
checkout(base)
|
|
74
|
+
@git.reset(base)
|
|
75
|
+
@git.branch(discard_branch).delete if discard_branch && @git.is_branch?(discard_branch)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Switch to a branch; :fresh discards an existing branch of the same
|
|
79
|
+
# name first (cleanup-orphan-files' shape).
|
|
80
|
+
def switch_branch(name, fresh: false)
|
|
81
|
+
@git.branch(name).delete if fresh && @git.is_branch?(name)
|
|
82
|
+
@git.branch(name).checkout
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# pull's dance: fetch origin, best-effort reset (a fresh clone may
|
|
86
|
+
# not hold the branch ref yet), checkout, pull.
|
|
87
|
+
def fetch_reset_pull(branch)
|
|
88
|
+
@git.remote("origin").fetch
|
|
89
|
+
begin
|
|
90
|
+
@git.reset_hard(branch)
|
|
91
|
+
rescue Git::Error
|
|
92
|
+
# reset can fail when the branch ref is absent on a fresh
|
|
93
|
+
# clone; checkout/pull below still applies.
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
checkout(branch)
|
|
97
|
+
@git.pull("origin", branch)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns :pushed, :behind_remote (non-force push behind the remote
|
|
101
|
+
# tip), or [:rejected, error]. The caller decides presentation.
|
|
102
|
+
# "Updates were rejected" covers both the classic git phrasing
|
|
103
|
+
# ("tip of your current branch is behind") and the modern one ("the
|
|
104
|
+
# remote contains work that you do not have locally").
|
|
105
|
+
def push(branch, force: false, remote: nil)
|
|
106
|
+
@git.push(remote || remote_name, branch, force: force)
|
|
107
|
+
:pushed
|
|
108
|
+
rescue Git::GitExecuteError, Git::FailedError => e
|
|
109
|
+
if e.message.match?(/Updates were rejected/)
|
|
110
|
+
:behind_remote
|
|
111
|
+
else
|
|
112
|
+
[:rejected, e]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def diff_patch
|
|
117
|
+
@git.diff.patch
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def each_staged_change
|
|
121
|
+
@git.status.changed.each do |file, status|
|
|
122
|
+
yield(file, status.blob(:index).contents)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def checkout(branch)
|
|
129
|
+
@git.checkout(branch)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dir
|
|
133
|
+
@git.dir.to_s
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|