kettle-family 1.2.29 → 1.2.30

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a59d676b17391390f1887d2dce0aeaf28da50217dd0e31fd36ee6a44cac34c46
4
- data.tar.gz: 22667d4505dc471ac6b8ea0cb10d97c636316ea97103e2b4b411a18aefaf3155
3
+ metadata.gz: 945e285ec99d1d336abdf5a51c05135c99485cafeb1b47a65b81ab72818a6786
4
+ data.tar.gz: 29bd40256a7b84edaf06dd9adc4f0d21db43a470b07e71b17dc3295e23f9fe4e
5
5
  SHA512:
6
- metadata.gz: 24f2c25f781d82b4fefc119b3ff04a07ff5fcc95396f624c8f42dbb33fd4f0fd5d51c66089ac8cd5e740e3df18d645c4c35cac837f73d3a3710b477a06ca4745
7
- data.tar.gz: cf0e8f529773c0d8550f19e824dd572c716085eb07d3ac13e5938e8d5c8fff69a20862e17be6252bc1f8ae5212e064017836ea666ffcb7a87d56ea88b69e4bc0
6
+ metadata.gz: 6a25b8669590d61a7a2ecd48fc64bf7d32d7d6090fcea810b13673534827f6609f700ecbeb0ef8b5dded509c4de135f385323a034a236bfd092a9d789c93d178
7
+ data.tar.gz: '0508285a2bc3905833dffe7e0387909613f0ff669d9e600f640d1a052ab53f06fb67d6a7bc64a4bfbae1b8175f4bf5efc268f22e012fe6978802cbc01b4da55b'
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,51 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.2.30] - 2026-08-04
34
+
35
+ - TAG: [v1.2.30][1.2.30t]
36
+ - COVERAGE: 94.10% -- 4944/5254 lines in 32 files
37
+ - BRANCH COVERAGE: 76.51% -- 1964/2567 branches in 32 files
38
+ - 29.86% documented
39
+
40
+ ### Added
41
+
42
+ - Emit per-member NDJSON event tapes while deriving release state.
43
+
44
+ - Render plain state analysis as stable per-member TTY progress rows before the final release-state table.
45
+
46
+ ### Fixed
47
+
48
+ - Anchor TTY progress redraws and reserve a terminal column to prevent stale or wrapped rows.
49
+
50
+ - Recover template autostashes when generated lockfiles are uncommitted.
51
+
52
+ - Keep release secret-provider notifications on the dedicated TTY line.
53
+
54
+ - Recover autostashes when generated lockfiles cause staged merge conflicts
55
+
56
+ - Recognize nested monorepo lockfiles during template autostash recovery
57
+
58
+ - TTY release progress now anchors redraws at column one so out-of-band notifications cannot concatenate with event tape rows.
59
+
60
+ - Keep transient progress notifications on a dedicated line above the event tape with independent row accounting.
61
+
62
+ - Preserve inherited local-path dependency environments during template bootstrap and lockfile recovery.
63
+
64
+ - Keep secret-provider notifications off the TTY event tape in verbose and debug modes.
65
+
66
+ - Normalize release-state event detail keys before writing NDJSON tapes.
67
+
68
+ - Forward --events so state analysis is streamed as NDJSON instead of falling back to the text report.
69
+
70
+ - Separate state analysis progress from the final release-state report on TTY output.
71
+
72
+ - Restore all generated lockfiles during monorepo template autostash recovery.
73
+
74
+ - Recover nested monorepo autostashes from the repository root.
75
+
76
+ - Restore generated Gemfile.lock files between branch-matrix test and lint targets.
77
+
33
78
  ## [1.2.29] - 2026-08-03
34
79
 
35
80
  - TAG: [v1.2.29][1.2.29t]
@@ -1742,7 +1787,9 @@ Please file a bug if you notice a violation of semantic versioning.
1742
1787
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
1743
1788
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
1744
1789
 
1745
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.29...HEAD
1790
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.30...HEAD
1791
+ [1.2.30]: https://github.com/kettle-dev/kettle-family/compare/v1.2.29...v1.2.30
1792
+ [1.2.30t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.30
1746
1793
  [1.2.29]: https://github.com/kettle-dev/kettle-family/compare/v1.2.28...v1.2.29
1747
1794
  [1.2.29t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.29
1748
1795
  [1.2.28]: https://github.com/kettle-dev/kettle-family/compare/v1.2.27...v1.2.28
data/README.md CHANGED
@@ -828,7 +828,7 @@ Thanks for RTFM. ☺️
828
828
  [📌gitmoji]: https://gitmoji.dev
829
829
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
830
830
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
831
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.063-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
831
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-5.254-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
832
832
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
833
833
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
834
834
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -21,6 +21,7 @@ module Kettle
21
21
  option :config, value: {type: String, usage: "PATH"}, desc: "Family config path"
22
22
  option :json, desc: "Print JSON report to stdout"
23
23
  option :report, value: {type: String, usage: "PATH"}, desc: "Write JSON report to PATH"
24
+ option :events, desc: "Stream release-state analysis as NDJSON"
24
25
 
25
26
  def self.call(argv, out: $stdout, err: $stderr)
26
27
  main(argv, stdout: out, stderr: err)
@@ -32,6 +33,7 @@ module Kettle
32
33
  base.option :config, value: {type: String, usage: "PATH"}, desc: "Family config path"
33
34
  base.option :json, desc: "Print JSON report to stdout"
34
35
  base.option :report, value: {type: String, usage: "PATH"}, desc: "Write JSON report to PATH"
36
+ base.option :events, desc: "Stream release-state analysis as NDJSON"
35
37
  end
36
38
  end
37
39
 
@@ -136,6 +138,7 @@ module Kettle
136
138
  exclude: options[:exclude],
137
139
  start_at: options[:start_at],
138
140
  json: truthy_option?(:json),
141
+ events: truthy_option?(:events),
139
142
  report: options[:report],
140
143
  execute: truthy_option?(:execute),
141
144
  debug: truthy_option?(:debug),
@@ -613,7 +616,11 @@ module Kettle
613
616
  def run_command(command, options)
614
617
  report = build_report(command, options)
615
618
  write_report(report, options)
616
- stdout.puts(options[:json] ? report.to_json : report.to_text)
619
+ if options[:events] && command == "release-state"
620
+ stdout.puts(JSON.generate("event_version" => 1, "type" => "summary", "report" => report.to_h))
621
+ else
622
+ stdout.puts(options[:json] ? report.to_json : report.to_text)
623
+ end
617
624
  report.success? ? 0 : 1
618
625
  rescue Error, OptionParser::ParseError => error
619
626
  stderr.puts("kettle-family: #{error.message}")
@@ -638,6 +645,7 @@ module Kettle
638
645
  else
639
646
  Orderer.new(members: members, mode: config.order_mode, hints: config.order_hints).ordered
640
647
  end
648
+ state_event_tape = release_state_event_tape(command: command, config: config, options: options)
641
649
  release_state_results = release_state_results_for_selection(config: config, members: ordered, only: effective_only)
642
650
  selected = Selection.new(members: ordered, release_state_results: release_state_results).apply(only: effective_only, exclude: options[:exclude], start_at: start_at.member)
643
651
  result_members = selected
@@ -645,7 +653,22 @@ module Kettle
645
653
  display_selected_members = display_members_for(command: command, config: config, members: selected, selected_members: selected)
646
654
  print_execution_intent(command: command, config: config, members: display_selected_members, options: options, start_at: start_at)
647
655
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
648
- results = command_results(command: command, config: config, members: result_members, options: options, start_at: start_at)
656
+ state_progress = release_state_progress(command: command, members: result_members, options: options)
657
+ state_progress&.start
658
+ state_event_handler = release_state_event_handler(
659
+ event_tape: state_event_tape,
660
+ progress: state_progress,
661
+ members: result_members
662
+ )
663
+ results = command_results(
664
+ command: command,
665
+ config: config,
666
+ members: result_members,
667
+ options: options,
668
+ start_at: start_at,
669
+ state_event_handler: state_event_handler
670
+ )
671
+ state_progress&.finish
649
672
  elapsed_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
650
673
  Report.new(
651
674
  family_name: config.family_name,
@@ -661,17 +684,18 @@ module Kettle
661
684
  command: command,
662
685
  results: results,
663
686
  warnings: discovery.warnings,
687
+ event_log_dirs: state_event_tape ? [state_event_tape.directory] : [],
664
688
  elapsed_seconds: elapsed_seconds
665
689
  )
666
690
  end
667
691
 
668
692
  StartAt = Struct.new(:member, :branch)
669
693
 
670
- def command_results(command:, config:, members:, options:, start_at:)
694
+ def command_results(command:, config:, members:, options:, start_at:, state_event_handler: nil)
671
695
  return branch_target_command_results(command: command, config: config, members: members, options: options, start_at: start_at) if branch_target_command?(command, config)
672
696
  return member_local_branch_target_command_results(command: command, config: config, members: members, options: options, start_at: start_at) if member_local_branch_target_command?(command, config, members)
673
697
 
674
- command_results_for_current_branch(command: command, config: config, members: members, options: options, start_at: start_at)
698
+ command_results_for_current_branch(command: command, config: config, members: members, options: options, start_at: start_at, state_event_handler: state_event_handler)
675
699
  end
676
700
 
677
701
  def release_state_results_for_selection(config:, members:, only:)
@@ -712,14 +736,14 @@ module Kettle
712
736
  members
713
737
  end
714
738
 
715
- def command_results_for_current_branch(command:, config:, members:, options:, start_at: StartAt.new(nil, nil))
739
+ def command_results_for_current_branch(command:, config:, members:, options:, start_at: StartAt.new(nil, nil), state_event_handler: nil)
716
740
  return mise_trust_results(config: config, members: members) if command == "mise-trust"
717
741
  return bump_version_results(members: members, options: options, phase: command) if %w[bump bump-version].include?(command)
718
742
  return add_changelog_results(members: members, options: options) if command == "add-changelog"
719
743
  return clean_unreleased_results(config: config, members: members, options: options) if command == "clean-unreleased"
720
744
  return reconcile_release_results(config: config, members: members, options: options) if command == "reconcile-releases"
721
745
  return branch_lane_results(config: config, members: members) if command == "branch-lanes"
722
- return release_state_results(config: config, members: members) if command == "release-state"
746
+ return release_state_results(config: config, members: members, event_handler: state_event_handler) if command == "release-state"
723
747
  return install_results(config: config, members: members, options: options) if command == "install"
724
748
  return [] unless WORKFLOW_COMMANDS.include?(command)
725
749
 
@@ -777,6 +801,49 @@ module Kettle
777
801
  stdout
778
802
  end
779
803
 
804
+ def release_state_progress(command:, members:, options:)
805
+ return nil unless command == "release-state"
806
+ return nil if options[:events] || options[:json]
807
+ return nil unless stdout.respond_to?(:tty?) && stdout.tty?
808
+
809
+ WorkflowProgress.new(
810
+ io: stdout,
811
+ label: "release state",
812
+ total: members.length,
813
+ jobs: [options[:jobs].to_i, 1].max,
814
+ members: members,
815
+ heading: "release state #{members.length} member#{"s" unless members.length == 1}:"
816
+ )
817
+ end
818
+
819
+ def release_state_event_handler(event_tape:, progress:, members:)
820
+ return nil unless event_tape || progress
821
+
822
+ members_by_name = members.to_h { |member| [member.name, member] }
823
+ lambda do |event|
824
+ event_tape&.call(event)
825
+ next unless progress
826
+
827
+ member = members_by_name[event["member"]]
828
+ next unless member
829
+
830
+ action = event["action"].to_s
831
+ status = event["status"].to_s
832
+ case action
833
+ when "member_start"
834
+ progress.start_member(member, total: 5, status: "release_state")
835
+ when "member_complete"
836
+ progress.finish_member(member, success: status == "ok", status: status)
837
+ when "changelog_command", "computed_booleans", "git_state", "github_release", "transfer_changelog"
838
+ if status == "running"
839
+ progress.update(member, status: action)
840
+ elsif status == "ok" || status == "failed"
841
+ progress.advance(member, status: action, success: status == "ok")
842
+ end
843
+ end
844
+ end
845
+ end
846
+
780
847
  def print_execution_intent(command:, config:, members:, options:, start_at:)
781
848
  return unless command == "release"
782
849
  return unless options[:execute]
@@ -1022,8 +1089,16 @@ module Kettle
1022
1089
  LocalInstall.new(config: config, members: members, execute: options[:execute], jobs: options[:jobs]).results
1023
1090
  end
1024
1091
 
1025
- def release_state_results(config:, members:)
1026
- ReleaseStateCheck.new(config: config, members: members).results
1092
+ def release_state_results(config:, members:, event_handler: nil)
1093
+ ReleaseStateCheck.new(config: config, members: members).results(event_handler: event_handler)
1094
+ end
1095
+
1096
+ def release_state_event_tape(command:, config:, options:)
1097
+ return unless command == "release-state"
1098
+
1099
+ tape = ReleaseStateEventTape.new(root: config.root, stream: options[:events] ? stdout : nil)
1100
+ stderr.puts("release state event tapes: #{tape.directory}") unless options[:events]
1101
+ tape
1027
1102
  end
1028
1103
 
1029
1104
  def release_mode(command:, options:)
@@ -28,16 +28,16 @@ module Kettle
28
28
  @transfer_changelog_total_count = TRANSFER_CHANGELOG_TOTAL_UNSET
29
29
  end
30
30
 
31
- def results
32
- return branch_results unless release_target_branches.empty?
33
- return members.map { |member| check_shared_changelog_member_or_local(member) } if shared_changelog?
31
+ def results(event_handler: nil)
32
+ return branch_results(event_handler: event_handler) unless release_target_branches.empty?
33
+ return members.map { |member| check_shared_changelog_member_or_local(member, event_handler: event_handler) } if shared_changelog?
34
34
 
35
35
  members.each_with_object([]) do |member, memo|
36
- member_branch_results = member_local_branch_results(member)
36
+ member_branch_results = member_local_branch_results(member, event_handler: event_handler)
37
37
  if member_branch_results
38
38
  memo.concat(member_branch_results)
39
39
  else
40
- memo << check_member(member)
40
+ memo << check_member(member, event_handler: event_handler)
41
41
  end
42
42
  end
43
43
  end
@@ -46,63 +46,87 @@ module Kettle
46
46
 
47
47
  attr_reader :members, :config
48
48
 
49
- def branch_results
49
+ def branch_results(event_handler: nil)
50
50
  root = git_root
51
51
  selected_names = members.map(&:name)
52
52
  release_target_branches.each_with_object([]) do |branch, memo|
53
53
  with_branch_worktree(root: root, branch: branch) do |worktree_root|
54
54
  branch_members = discover_branch_members(worktree_root: worktree_root, selected_names: selected_names)
55
55
  if shared_changelog?
56
- memo.concat(branch_members.map { |member| check_shared_changelog_member_or_local(member, branch: branch) })
56
+ memo.concat(branch_members.map { |member| check_shared_changelog_member_or_local(member, branch: branch, event_handler: event_handler) })
57
57
  next
58
58
  end
59
59
 
60
- memo.concat(branch_members.map { |member| check_member(member, branch: branch) })
60
+ memo.concat(branch_members.map { |member| check_member(member, branch: branch, event_handler: event_handler) })
61
61
  end
62
62
  rescue Error => error
63
63
  memo << error_result(branch: branch, error: error)
64
64
  end
65
65
  end
66
66
 
67
- def check_member(member, branch: nil)
67
+ def check_member(member, branch: nil, event_handler: nil)
68
68
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
69
  command = release_state_command
70
+ emit_event(event_handler, member: member, branch: branch, action: "member_start", status: "running", command: command)
71
+ emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: "running", command: command)
70
72
  stdout, stderr, status = Open3.capture3(release_state_env(member), *command, chdir: release_state_workdir(member))
71
73
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
72
74
  success = status.success?
75
+ emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
73
76
  state = success ? JSON.parse(stdout) : {}
77
+ emit_event(event_handler, member: member, branch: branch, action: "computed_booleans", status: "running") if success
74
78
  state = branch_filtered_state(member, state, branch) if success && branch
75
79
  state = state_with_computed_booleans(state) if success
80
+ emit_event(event_handler, member: member, branch: branch, action: "computed_booleans", status: "ok") if success
81
+ emit_event(event_handler, member: member, branch: branch, action: "git_state", status: "running") if success
76
82
  state = enrich_git_state(member.root, state, branch: branch) if success
83
+ emit_event(event_handler, member: member, branch: branch, action: "git_state", status: "ok") if success
84
+ emit_event(event_handler, member: member, branch: branch, action: "github_release", status: "running") if success
77
85
  state = enrich_github_release(member.root, state, branch: branch) if success
86
+ emit_event(event_handler, member: member, branch: branch, action: "github_release", status: "ok") if success
87
+ emit_event(event_handler, member: member, branch: branch, action: "transfer_changelog", status: "running") if success
78
88
  state = enrich_transfer_changelog_lag(member.root, state) if success
79
- result(member: member, command: command, stdout: stdout, stderr: stderr, status: status.exitstatus, elapsed: elapsed, success: success, state: state, branch: branch)
89
+ emit_event(event_handler, member: member, branch: branch, action: "transfer_changelog", status: "ok") if success
90
+ result = result(member: member, command: command, stdout: stdout, stderr: stderr, status: status.exitstatus, elapsed: elapsed, success: success, state: state, branch: branch)
91
+ emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
92
+ result
80
93
  rescue JSON::ParserError => error
81
- result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
94
+ result = result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
95
+ emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: "failed", elapsed_seconds: elapsed || 0.0, status_code: 1, reason: result.reason)
96
+ result
82
97
  end
83
98
 
84
99
  def release_state_command
85
100
  [RbConfig.ruby, "-S", "kettle-changelog", "--release-state", "--json"]
86
101
  end
87
102
 
88
- def check_shared_changelog_member_or_local(member, branch: nil)
89
- return check_member(member, branch: branch) if member_local_changelog?(member)
103
+ def check_shared_changelog_member_or_local(member, branch: nil, event_handler: nil)
104
+ return check_member(member, branch: branch, event_handler: event_handler) if member_local_changelog?(member)
90
105
 
91
- check_shared_changelog_member(member, branch: branch)
106
+ check_shared_changelog_member(member, branch: branch, event_handler: event_handler)
92
107
  end
93
108
 
94
- def check_shared_changelog_member(member, branch: nil)
109
+ def check_shared_changelog_member(member, branch: nil, event_handler: nil)
95
110
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
111
  command = release_state_command
112
+ emit_event(event_handler, member: member, branch: branch, action: "member_start", status: "running", command: command)
113
+ emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: "running", command: command)
97
114
  stdout, stderr, status = Open3.capture3(shared_changelog_env(member), *command, chdir: config.root)
98
115
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
99
116
  success = status.success?
117
+ emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
100
118
  state = success ? JSON.parse(stdout) : {}
101
119
  state = branch_filtered_state(member, state, branch) if success && branch
120
+ emit_event(event_handler, member: member, branch: branch, action: "git_state", status: "running") if success
102
121
  state = enrich_git_state(member.root, state, branch: branch) if success
122
+ emit_event(event_handler, member: member, branch: branch, action: "git_state", status: "ok") if success
123
+ emit_event(event_handler, member: member, branch: branch, action: "github_release", status: "running") if success
103
124
  state = enrich_github_release(member.root, state, branch: branch) if success
125
+ emit_event(event_handler, member: member, branch: branch, action: "github_release", status: "ok") if success
126
+ emit_event(event_handler, member: member, branch: branch, action: "transfer_changelog", status: "running") if success
104
127
  state = enrich_transfer_changelog_lag(member.root, state) if success
105
- result(
128
+ emit_event(event_handler, member: member, branch: branch, action: "transfer_changelog", status: "ok") if success
129
+ result = result(
106
130
  member: member,
107
131
  command: command,
108
132
  stdout: stdout,
@@ -113,10 +137,14 @@ module Kettle
113
137
  state: state,
114
138
  branch: branch
115
139
  )
140
+ emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
141
+ result
116
142
  rescue JSON::ParserError => error
117
- result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
143
+ result = result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
144
+ emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: "failed", elapsed_seconds: elapsed || 0.0, status_code: 1, reason: result.reason)
145
+ result
118
146
  rescue Error => error
119
- result(
147
+ result = result(
120
148
  member: member,
121
149
  command: command || release_state_command,
122
150
  stdout: "",
@@ -128,6 +156,8 @@ module Kettle
128
156
  reason: "release state check failed",
129
157
  branch: branch
130
158
  )
159
+ emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: "failed", elapsed_seconds: elapsed || 0.0, status_code: 1, reason: result.reason)
160
+ result
131
161
  end
132
162
 
133
163
  def release_state_workdir(member)
@@ -626,11 +656,24 @@ module Kettle
626
656
  config.release_target_branches
627
657
  end
628
658
 
629
- def member_local_branch_results(member)
659
+ def member_local_branch_results(member, event_handler: nil)
630
660
  member_config = member_local_release_config(member)
631
661
  return unless member_config
632
662
 
633
- self.class.new(config: member_config, members: [member]).results
663
+ self.class.new(config: member_config, members: [member]).results(event_handler: event_handler)
664
+ end
665
+
666
+ def emit_event(event_handler, member:, branch:, action:, status:, **details)
667
+ return unless event_handler
668
+
669
+ event_handler.call({
670
+ "member" => member.name.to_s,
671
+ "branch" => branch,
672
+ "action" => action,
673
+ "status" => status
674
+ }.merge(details.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }))
675
+ rescue
676
+ nil
634
677
  end
635
678
 
636
679
  def member_local_release_config(member)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Kettle
8
+ module Family
9
+ class ReleaseStateEventTape
10
+ attr_reader :directory
11
+
12
+ def initialize(root:, stream: nil, clock: nil, wall_clock: nil)
13
+ @directory = File.join(root, "tmp", "kettle-family", "release-state-#{Time.now.strftime("%Y%m%d-%H%M%S")}-#{Process.pid}")
14
+ FileUtils.mkdir_p(@directory)
15
+ @stream = stream
16
+ @clock = clock || lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+ @wall_clock = wall_clock || lambda { Time.now.utc.iso8601 }
18
+ @started_at = @clock.call
19
+ @sequence_by_member = Hash.new(0)
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def call(event)
24
+ payload = event.merge(
25
+ "event_version" => 1,
26
+ "type" => "release_state",
27
+ "timestamp" => @wall_clock.call,
28
+ "elapsed_seconds" => @clock.call - @started_at
29
+ )
30
+ member = payload.fetch("member", "family").to_s
31
+ @mutex.synchronize do
32
+ @sequence_by_member[member] += 1
33
+ payload["sequence"] = @sequence_by_member.fetch(member)
34
+ line = JSON.generate(payload)
35
+ File.open(path_for(member), "a") { |file| file.puts(line) }
36
+ @stream.puts(line) if @stream
37
+ @stream.flush if @stream&.respond_to?(:flush)
38
+ end
39
+ payload
40
+ rescue
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def path_for(member)
47
+ filename = member.gsub(/[^A-Za-z0-9_.-]+/, "_")
48
+ File.join(@directory, "#{filename}.ndjson")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -30,9 +30,9 @@ module Kettle
30
30
  up
31
31
  ].freeze
32
32
 
33
- attr_reader :family_name, :family_mode, :order_mode, :members, :selected_members, :config_path, :command, :results, :branch_lanes, :release_target_branches, :member_release_target_branches, :release_mode, :warnings
33
+ attr_reader :family_name, :family_mode, :order_mode, :members, :selected_members, :config_path, :command, :results, :branch_lanes, :release_target_branches, :member_release_target_branches, :release_mode, :warnings, :event_log_dirs
34
34
 
35
- def initialize(family_name:, order_mode:, members:, selected_members:, config_path:, family_mode: nil, branch_lanes: {}, release_target_branches: [], member_release_target_branches: {}, release_mode: nil, command: nil, results: [], warnings: [], elapsed_seconds: nil)
35
+ def initialize(family_name:, order_mode:, members:, selected_members:, config_path:, family_mode: nil, branch_lanes: {}, release_target_branches: [], member_release_target_branches: {}, release_mode: nil, command: nil, results: [], warnings: [], event_log_dirs: [], elapsed_seconds: nil)
36
36
  @family_name = family_name
37
37
  @family_mode = family_mode
38
38
  @order_mode = order_mode
@@ -46,6 +46,7 @@ module Kettle
46
46
  @member_release_target_branches = member_release_target_branches
47
47
  @release_mode = release_mode
48
48
  @warnings = warnings
49
+ @event_log_dirs = event_log_dirs
49
50
  @elapsed_seconds = elapsed_seconds
50
51
  end
51
52
 
@@ -62,6 +63,7 @@ module Kettle
62
63
  "member_release_target_branches" => member_release_target_branches,
63
64
  "release_mode" => release_mode,
64
65
  "warnings" => warnings,
66
+ "event_log_dirs" => event_log_dirs,
65
67
  "command" => command,
66
68
  "results" => results.map(&:to_h),
67
69
  "summary" => summary,
@@ -135,6 +137,7 @@ module Kettle
135
137
  lines << "summary:"
136
138
  lines << " outcome: #{data.fetch("outcome")}"
137
139
  lines << " elapsed: #{format_elapsed(data.fetch("elapsed_seconds"))}"
140
+ lines << " events: #{event_log_dirs.join(", ")}" unless event_log_dirs.empty?
138
141
  lines << " logs: #{data.fetch("release_log_dirs").join(", ")}" unless data.fetch("release_log_dirs").empty?
139
142
  lines << " selected: #{data.fetch("selected_count")}"
140
143
  lines << " results: #{data.fetch("result_count")}"
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "1.2.29"
8
+ VERSION = "1.2.30"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -238,21 +238,78 @@ module Kettle
238
238
  phase: "template_autostash_restore",
239
239
  command: ["git", "stash", "pop", stash.fetch(:ref)]
240
240
  )
241
- next restore if restore.ok? || !template_generated_lockfile_only_conflict?(stash.fetch(:member))
241
+ next restore if restore.ok?
242
+
243
+ if template_generated_lockfile_restore_refusal?(stash.fetch(:member), restore)
244
+ next runner.call(
245
+ member: stash.fetch(:member),
246
+ phase: "template_autostash_generated_lockfile_recovery",
247
+ command: template_generated_lockfile_restore_command(stash)
248
+ )
249
+ end
250
+
251
+ next restore if !template_generated_lockfile_only_conflict?(stash.fetch(:member))
242
252
 
243
253
  runner.call(
244
254
  member: stash.fetch(:member),
245
255
  phase: "template_autostash_generated_lockfile_recovery",
246
256
  command: [
247
257
  "sh", "-lc",
248
- "paths=$(git diff --name-only --diff-filter=U); " \
249
- "git checkout --ours -- $paths && git add -- $paths && " \
258
+ "set -eu; cd \"$(git rev-parse --show-toplevel)\"; " \
259
+ "paths=$(git diff --name-only --diff-filter=U); " \
260
+ "for path in $paths; do " \
261
+ "case \"$path\" in " \
262
+ "Gemfile.lock|*/Gemfile.lock|Appraisal.root.gemfile.lock|*/Appraisal.root.gemfile.lock|.structuredmerge/kettle-jem.lock|*/.structuredmerge/kettle-jem.lock) " \
263
+ "git restore --ours -- \"$path\"; git add -- \"$path\" ;; " \
264
+ "*) printf 'unresolved non-generated conflict: %s\\n' \"$path\" >&2; exit 1 ;; " \
265
+ "esac; " \
266
+ "done; " \
267
+ "test -z \"$(git diff --name-only --diff-filter=U)\"; " \
250
268
  "git stash drop #{Shellwords.escape(stash.fetch(:ref))}"
251
269
  ]
252
270
  )
253
271
  end
254
272
  end
255
273
 
274
+ def template_generated_lockfile_restore_refusal?(member, restore)
275
+ output = [restore.stdout, restore.stderr].compact.join("\n")
276
+ return false unless output.include?("would be overwritten by merge")
277
+
278
+ dirty_paths = GitStatus.dirty_paths(member.root)
279
+ dirty_paths.any? && dirty_paths.all? { |path| template_generated_lockfile_path?(path) }
280
+ end
281
+
282
+ def template_generated_lockfile_restore_command(stash)
283
+ member = stash.fetch(:member)
284
+ generated_paths = GitStatus.dirty_paths(member.root).filter_map do |status_line|
285
+ path = status_line.to_s.sub(/\A.../, "").strip
286
+ path if template_generated_lockfile_path?(status_line)
287
+ end.uniq
288
+ reset_commands = generated_paths.map do |path|
289
+ escaped_path = Shellwords.escape(path)
290
+ "if git ls-files --error-unmatch #{escaped_path} >/dev/null 2>&1; then " \
291
+ "git restore --source=HEAD --staged --worktree -- #{escaped_path}; " \
292
+ "else rm -f -- #{escaped_path}; fi"
293
+ end
294
+
295
+ [
296
+ "sh",
297
+ "-lc",
298
+ "set -eu; cd \"$(git rev-parse --show-toplevel)\"; " \
299
+ "#{reset_commands.join("; ")}; git stash pop #{Shellwords.escape(stash.fetch(:ref))}"
300
+ ]
301
+ end
302
+
303
+ def template_generated_lockfile_path?(status_line)
304
+ path = status_line.to_s.sub(/\A.../, "").strip
305
+ [
306
+ "Gemfile.lock",
307
+ "Appraisal.root.gemfile.lock",
308
+ ".structuredmerge/kettle-jem.lock"
309
+ ].include?(path) ||
310
+ path.end_with?("/Gemfile.lock", "/Appraisal.root.gemfile.lock", "/.structuredmerge/kettle-jem.lock")
311
+ end
312
+
256
313
  def rollback_failed_template_worktrees(stashes, workflow_results, runner:)
257
314
  failed_members = workflow_results.reject(&:ok?).map(&:member_name).compact.uniq
258
315
  stashes.filter_map do |stash|
@@ -276,7 +333,7 @@ module Kettle
276
333
  def template_generated_lockfile_only_conflict?(member)
277
334
  stdout, _stderr, status = Open3.capture3("git", "diff", "--name-only", "--diff-filter=U", chdir: member.root)
278
335
  conflicts = stdout.lines.map(&:strip).reject(&:empty?).sort
279
- status.success? && !conflicts.empty? && conflicts.all? { |path| ["Gemfile.lock", ".structuredmerge/kettle-jem.lock"].include?(path) }
336
+ status.success? && !conflicts.empty? && conflicts.all? { |path| template_generated_lockfile_path?(" M #{path}") }
280
337
  end
281
338
 
282
339
  def template_branch_sync_results(branch_members, runner:)
@@ -561,12 +618,12 @@ module Kettle
561
618
  def template_bootstrap_dependency_env(member)
562
619
  env = release_lockfile_local_path_env_overrides(member).merge(workflow_env)
563
620
  env.each_key do |key|
564
- env[key] = "false" if key.end_with?("_DEV") && !env_overrides.key?(key)
621
+ env[key] = "false" if key.end_with?("_DEV") && !local_path_env_requested?(key)
565
622
  end
566
623
  env["K_JEM_TEMPLATING"] = "false"
567
624
  env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"] = "true"
568
625
  family_env_name = config.family_local_path_env_name
569
- env[family_env_name] = "false" if family_env_name && !env_overrides.key?(family_env_name)
626
+ env[family_env_name] = "false" if family_env_name && !local_path_env_requested?(family_env_name)
570
627
  env
571
628
  end
572
629
 
@@ -632,6 +689,7 @@ module Kettle
632
689
 
633
690
  branch_members = rediscovered_selected_members(selected_names)
634
691
  branch_members = members if branch_members.empty?
692
+ dirty_paths_before = branch_generated_lockfile_dirt(branch_members)
635
693
  if command == "template" && execute
636
694
  memo.concat(template_branch_sync_results(branch_members, runner: runner))
637
695
  break memo unless memo.last&.ok?
@@ -639,6 +697,16 @@ module Kettle
639
697
  branch_results = current_branch_results(branch_members)
640
698
  tag_branch_results(branch_results, branch)
641
699
  memo.concat(branch_results)
700
+
701
+ if branch_generated_lockfile_cleanup_required?
702
+ cleanup_branch_generated_lockfiles(
703
+ branch_members: branch_members,
704
+ dirty_paths_before: dirty_paths_before,
705
+ runner: runner,
706
+ memo: memo,
707
+ branch: branch
708
+ )
709
+ end
642
710
  break memo unless memo.last&.ok?
643
711
 
644
712
  commit_normalized_lockfiles(branch_members: branch_members, runner: runner, memo: memo)
@@ -661,6 +729,57 @@ module Kettle
661
729
  end
662
730
  end
663
731
 
732
+ def branch_generated_lockfile_cleanup_required?
733
+ execute && %w[test lint].include?(command)
734
+ end
735
+
736
+ def branch_generated_lockfile_dirt(branch_members)
737
+ branch_members.each_with_object({}) do |member, memo|
738
+ root = git_root_for(member)
739
+ memo[root] ||= GitStatus.dirty_paths(root)
740
+ end
741
+ end
742
+
743
+ def cleanup_branch_generated_lockfiles(branch_members:, dirty_paths_before:, runner:, memo:, branch:)
744
+ processed_roots = []
745
+ branch_members.each do |member|
746
+ root = git_root_for(member)
747
+ next if processed_roots.include?(root)
748
+
749
+ processed_roots << root
750
+ before = dirty_paths_before.fetch(root, []).map { |path| normalized_dirty_path(path) }
751
+ generated_paths = GitStatus.dirty_paths(root).filter_map do |status_line|
752
+ path = normalized_dirty_path(status_line)
753
+ path unless before.include?(path) || !template_generated_lockfile_path?(status_line)
754
+ end.uniq
755
+ next if generated_paths.empty?
756
+
757
+ result = runner.call(
758
+ member: member,
759
+ phase: "branch_generated_lockfile_recovery",
760
+ command: branch_generated_lockfile_restore_command(generated_paths)
761
+ )
762
+ result.branch = branch if result.respond_to?(:branch=)
763
+ memo << result
764
+ break unless result.ok?
765
+ end
766
+ end
767
+
768
+ def normalized_dirty_path(status_line)
769
+ status_line.to_s.sub(/\A.../, "").strip
770
+ end
771
+
772
+ def branch_generated_lockfile_restore_command(paths)
773
+ reset_commands = paths.map do |path|
774
+ escaped_path = Shellwords.escape(path)
775
+ "if git ls-files --error-unmatch #{escaped_path} >/dev/null 2>&1; then " \
776
+ "git restore --source=HEAD --staged --worktree -- #{escaped_path}; " \
777
+ "else rm -f -- #{escaped_path}; fi"
778
+ end
779
+
780
+ ["sh", "-lc", "set -eu; #{reset_commands.join("; ")}"]
781
+ end
782
+
664
783
  def release_preflight_results
665
784
  phases = release_preflight_phases
666
785
  progress = start_release_preflight_progress(phases)
@@ -1082,15 +1201,10 @@ module Kettle
1082
1201
  return unless progress
1083
1202
 
1084
1203
  member = release_progress_member_for(member_name)
1204
+ if progress_io && suppress_release_secret_provider_event?(event, progress: progress)
1205
+ return
1206
+ end
1085
1207
  if progress_io
1086
- if event["type"] == "secret_provider"
1087
- case event["action"].to_s
1088
- when "prompt_request", "manual_prompt"
1089
- progress.notification(secret_provider_notification_label(event))
1090
- when "prompt_response"
1091
- progress.notification("")
1092
- end
1093
- end
1094
1208
  if verbose || debug
1095
1209
  emit_release_event_progress(member, event)
1096
1210
  elsif progress.tty?
@@ -2113,7 +2227,9 @@ module Kettle
2113
2227
  def workflow_family_local_path_env
2114
2228
  return {} if implicit_single_member_template_root?
2115
2229
 
2116
- config.family_local_path_env
2230
+ config.family_local_path_env.each_with_object({}) do |(name, default), env|
2231
+ env[name] = ENV.key?(name) ? ENV.fetch(name) : default
2232
+ end
2117
2233
  end
2118
2234
 
2119
2235
  def implicit_single_member_template_root?
@@ -2372,7 +2488,9 @@ module Kettle
2372
2488
  next false unless event
2373
2489
 
2374
2490
  if progress_io
2375
- if verbose || debug
2491
+ if suppress_release_secret_provider_event?(event, progress: progress)
2492
+ next true
2493
+ elsif verbose || debug
2376
2494
  emit_release_event_progress(member, event)
2377
2495
  elsif progress&.tty?
2378
2496
  emit_release_event_status(member, event, progress: progress)
@@ -2382,6 +2500,26 @@ module Kettle
2382
2500
  end
2383
2501
  end
2384
2502
 
2503
+ def suppress_release_secret_provider_event?(event, progress:)
2504
+ return false unless event["type"] == "secret_provider"
2505
+ return false unless progress
2506
+
2507
+ case event["action"].to_s
2508
+ when "prompt_request", "manual_prompt"
2509
+ progress.notification(secret_provider_notification_label(event))
2510
+ when "prompt_response"
2511
+ progress.notification("")
2512
+ end
2513
+
2514
+ # Prompt and provider status are transient operator notifications, not
2515
+ # tape events. Keeping them out of the member row prevents external
2516
+ # prompt output from competing with the fixed-width event tape.
2517
+ # A TTY always has a dedicated notification line. Prompt/provider
2518
+ # events must never fall through to the member-row event tape, even
2519
+ # when verbose or debug output is enabled.
2520
+ progress.tty?
2521
+ end
2522
+
2385
2523
  def parse_template_event(line)
2386
2524
  payload = JSON.parse(line.to_s)
2387
2525
  (payload.is_a?(Hash) && payload["event_version"]) ? payload : nil
@@ -2822,7 +2960,16 @@ module Kettle
2822
2960
  end
2823
2961
 
2824
2962
  def template_lockfile_recovery_env(member)
2825
- release_lockfile_env(member).merge("K_JEM_TEMPLATING" => "false")
2963
+ env = release_lockfile_env(member).merge(workflow_env)
2964
+ ENV.each do |key, value|
2965
+ env[key] = value if key.end_with?("_DEV", "_LOCAL") && local_path_env_requested?(key)
2966
+ end
2967
+ env.merge!(env_overrides)
2968
+ env.merge!(
2969
+ "K_JEM_TEMPLATING" => "false",
2970
+ "BUNDLE_DISABLE_CHECKSUM_VALIDATION" => "true"
2971
+ )
2972
+ env
2826
2973
  end
2827
2974
 
2828
2975
  def normalize_lockfiles_command(member:, phase:, skip_checksum_option: false)
@@ -3027,10 +3174,14 @@ module Kettle
3027
3174
  def template_prepare_env
3028
3175
  env = workflow_env
3029
3176
  family_env_name = config.family_local_path_env_name
3030
- env[family_env_name] = "false" if family_env_name && !env_overrides.key?(family_env_name)
3177
+ env[family_env_name] = "false" if family_env_name && !local_path_env_requested?(family_env_name)
3031
3178
  env
3032
3179
  end
3033
3180
 
3181
+ def local_path_env_requested?(name)
3182
+ env_overrides.key?(name) || ENV.key?(name)
3183
+ end
3184
+
3034
3185
  def template_thread_worker_budget
3035
3186
  member_jobs = template_jobs(members)
3036
3187
  [1, (Etc.nprocessors / member_jobs) - 1].max
@@ -36,7 +36,8 @@ module Kettle
36
36
  @mutex = Mutex.new
37
37
  @tty = @enabled && io.respond_to?(:tty?) && io.tty?
38
38
  @tty_rendered = false
39
- @tty_rows = 0
39
+ @tty_block_rows = 0
40
+ @tty_tape_rows = 0
40
41
  end
41
42
 
42
43
  def start
@@ -122,6 +123,21 @@ module Kettle
122
123
  end
123
124
  end
124
125
 
126
+ # Finish a TTY progress block before the caller prints a second report.
127
+ # The blank line keeps the final report physically separate from the
128
+ # redraw block instead of making the terminal cursor its first row.
129
+ def finish
130
+ return unless @enabled
131
+
132
+ synchronize do
133
+ next if @stopped
134
+
135
+ render_tty_block if @tty && @started && !@line_order.empty?
136
+ write_line("") if @tty && @started
137
+ @stopped = true
138
+ end
139
+ end
140
+
125
141
  def tty?
126
142
  @tty
127
143
  end
@@ -174,15 +190,31 @@ module Kettle
174
190
  return if rows.empty?
175
191
 
176
192
  output = +""
177
- output << "\e[#{@tty_rows}A" if @tty_rendered && @tty_rows.positive?
178
- output << "\e[1G\e[2K#{truncate_display(@notification, terminal_width)}\n"
179
- rows.each do |line|
180
- output << "\e[1G\e[2K#{line}\n"
193
+ if @tty_rendered && @tty_block_rows.positive?
194
+ # Restore the bottom-of-block cursor before moving to the first row.
195
+ # This keeps unrelated prompt output from changing the redraw anchor.
196
+ output << "\e[u\e[#{@tty_block_rows}A"
181
197
  end
198
+ # The notification line is deliberately separate from the event tape.
199
+ # It is transient operator guidance, not a member row or an event.
200
+ output << render_tty_notification_line
201
+ output << render_tty_tape_rows(rows)
202
+ # LF moves down without necessarily returning to column 1. Reset the
203
+ # anchor explicitly so external prompt output starts on a fresh line.
204
+ output << "\e[1G\e[s"
182
205
  @io.write(output)
183
206
  @io.flush if @io.respond_to?(:flush)
184
207
  @tty_rendered = true
185
- @tty_rows = rows.length + 1
208
+ @tty_tape_rows = rows.length
209
+ @tty_block_rows = @tty_tape_rows + 1
210
+ end
211
+
212
+ def render_tty_notification_line
213
+ "\e[1G\e[2K#{truncate_display(@notification, render_width)}\n"
214
+ end
215
+
216
+ def render_tty_tape_rows(rows)
217
+ rows.map { |line| "\e[1G\e[2K#{line}\n" }.join
186
218
  end
187
219
 
188
220
  def tty_line(member_name)
@@ -278,7 +310,15 @@ module Kettle
278
310
  end
279
311
 
280
312
  def status_width
281
- [terminal_width - MEMBER_WIDTH - 1 - PROGRESS_WIDTH - 1 - ELAPSED_WIDTH - 1 - EVENT_WIDTH - 1, MIN_STATUS_WIDTH].max
313
+ available = [render_width - MEMBER_WIDTH - 1 - PROGRESS_WIDTH - 1 - ELAPSED_WIDTH - 1 - EVENT_WIDTH - 1, 1].max
314
+ [available, MIN_STATUS_WIDTH].max.clamp(1, available)
315
+ end
316
+
317
+ def render_width
318
+ # Leave the last terminal column unused. Writing into it can trigger
319
+ # an automatic wrap before the explicit newline, which invalidates the
320
+ # fixed physical-row cursor accounting used by the redrawer.
321
+ [terminal_width - 1, 1].max
282
322
  end
283
323
 
284
324
  def terminal_width
data/lib/kettle/family.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "family/branch_lane_audit"
11
11
  require_relative "family/config"
12
12
  require_relative "family/secrets"
13
13
  require_relative "family/branch_target_config"
14
+ require_relative "family/release_state_event_tape"
14
15
  require_relative "family/release_state_check"
15
16
  require_relative "family/release_reconciler"
16
17
  require_relative "family/unreleased_gem_cleanup"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-family
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.29
4
+ version: 1.2.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -342,6 +342,7 @@ files:
342
342
  - lib/kettle/family/readiness_check.rb
343
343
  - lib/kettle/family/release_reconciler.rb
344
344
  - lib/kettle/family/release_state_check.rb
345
+ - lib/kettle/family/release_state_event_tape.rb
345
346
  - lib/kettle/family/release_state_result.rb
346
347
  - lib/kettle/family/release_waves.rb
347
348
  - lib/kettle/family/report.rb
@@ -359,10 +360,10 @@ licenses:
359
360
  - AGPL-3.0-only
360
361
  metadata:
361
362
  homepage_uri: https://kettle-family.galtzo.com
362
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.29
363
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.29/CHANGELOG.md
363
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.30
364
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.30/CHANGELOG.md
364
365
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
365
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.29
366
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.30
366
367
  funding_uri: https://github.com/sponsors/pboling
367
368
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
368
369
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file