spree_core 6.0.0.rc6 → 6.0.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
  3. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
  4. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
  6. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
  7. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
  8. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
  9. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
  10. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
  11. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
  12. data/app/models/spree/ability.rb +9 -0
  13. data/app/models/spree/maintenance_task.rb +221 -0
  14. data/app/models/spree/maintenance_task_run.rb +78 -0
  15. data/app/models/spree/upgrade_record.rb +62 -0
  16. data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
  17. data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
  18. data/app/services/spree/maintenance_tasks/start.rb +127 -0
  19. data/app/services/spree/seeds/all.rb +22 -0
  20. data/config/locales/en.yml +1 -0
  21. data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
  22. data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
  23. data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
  24. data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
  25. data/lib/spree/core/configuration.rb +6 -0
  26. data/lib/spree/core/engine.rb +10 -1
  27. data/lib/spree/core/permissions/default_catalog.rb +7 -0
  28. data/lib/spree/core/version.rb +1 -1
  29. data/lib/spree/core.rb +16 -1
  30. data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
  31. data/lib/spree/upgrade.rb +189 -0
  32. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +17 -10
  33. data/lib/tasks/maintenance_tasks.rake +42 -0
  34. data/lib/tasks/upgrade.rake +81 -60
  35. metadata +25 -6
  36. data/lib/tasks/capture_methods_migration.rake +0 -30
  37. data/lib/tasks/order_market_backfill.rake +0 -27
@@ -108,7 +108,7 @@ module Spree
108
108
 
109
109
  # Override parent: skip if the model existed before this run. Migrations
110
110
  # are append-only — schema changes get a separate migration:
111
- # pnpm exec spree rails g migration AddFooToBar foo:string
111
+ # pnpm exec spree generate migration AddFooToBar foo:string
112
112
  def create_migration_file
113
113
  if @model_existed_before_run
114
114
  say_status :skip, 'migration (model already exists; add a new migration for schema changes)', :yellow
@@ -295,7 +295,7 @@ module Spree
295
295
  end
296
296
  say " 4. Add `has_many :#{plural_name}` to Spree::Store" if store_scoped?
297
297
  if options[:store] || options[:admin]
298
- say " #{store_scoped? ? 5 : 4}. Run the specs: pnpm exec spree rspec spec/controllers/spree/api/v3/"
298
+ say " #{store_scoped? ? 5 : 4}. Run the specs: pnpm exec spree test spec/controllers/spree/api/v3/"
299
299
  end
300
300
  say ''
301
301
  end
@@ -195,6 +195,12 @@ module Spree
195
195
  # imports
196
196
  preference :large_import_threshold, :integer, default: 500, env: 'SPREE_LARGE_IMPORT_THRESHOLD' # imports with more rows than this skip per-row UI broadcasts and use bulk processing
197
197
 
198
+ # maintenance tasks
199
+ # Turn off to run upgrade steps only from the command line: the dashboard
200
+ # page and its Admin API endpoints are refused. For hosts where the people
201
+ # holding a store's admin role are not the ones operating the installation,
202
+ # such as a platform serving many merchants from one database.
203
+ preference :maintenance_tasks_dashboard_enabled, :boolean, default: true, env: 'SPREE_MAINTENANCE_TASKS_DASHBOARD_ENABLED'
198
204
  end
199
205
  end
200
206
  end
@@ -46,6 +46,7 @@ module Spree
46
46
  :admin_agent_tools,
47
47
  :store_agent_tools,
48
48
  :integrations,
49
+ :maintenance_tasks,
49
50
  :number_generators,
50
51
  :subscribers,
51
52
  :store_authentication_strategies,
@@ -77,7 +78,8 @@ module Spree
77
78
  # Core's defaults are concatenated in after_initialize below.
78
79
  %i[subscribers actor_classes commission_rules delivery_method_rules seller_requirements
79
80
  delivery_rate_providers tax_providers pricing_providers inventory_providers
80
- payout_providers digital_asset_providers delivery_profile_types pickup_point_providers].each do |registry|
81
+ payout_providers digital_asset_providers delivery_profile_types pickup_point_providers
82
+ maintenance_tasks].each do |registry|
81
83
  app.config.spree[registry] = []
82
84
  end
83
85
  app.config.spree.line_item_comparison_hooks = Set.new
@@ -540,6 +542,13 @@ module Spree
540
542
 
541
543
  Rails.application.config.spree.integrations = []
542
544
 
545
+ # Upgrade steps backed by a task class are registered from the manifests
546
+ # that name them, so adding a step never means editing a second list.
547
+ Rails.application.config.spree.maintenance_tasks.concat [
548
+ 'Spree::MaintenanceTasks::UpgradeStep',
549
+ *Spree::Upgrade.steps.filter_map { |step| step['task_class'] }
550
+ ]
551
+
543
552
  Rails.application.config.spree.validators.addresses = [
544
553
  Spree::Addresses::PhoneValidator
545
554
  ]
@@ -201,6 +201,13 @@ module Spree
201
201
  })
202
202
  catalog.register_scope(:integrations, group: :settings, resources: -> { [Spree::Integration] })
203
203
 
204
+ # Sits with the other sensitive access grants rather than under
205
+ # settings: a task can rewrite any data in the install, so out of the
206
+ # box only the admin role holds it (docs/plans/6.0-maintenance-tasks.md).
207
+ catalog.register_scope(:maintenance_tasks, group: :access, resources: -> {
208
+ [Spree::MaintenanceTaskRun, :maintenance_task]
209
+ })
210
+
204
211
  # The CORS allowlist is a credential as much as a key is: an origin on
205
212
  # it may call the cookie-authenticated refresh endpoint from a browser
206
213
  # and read back any signed-in admin's access token. So it sits with
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc6'.freeze
2
+ VERSION = '6.0.0'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -135,7 +135,8 @@ module Spree
135
135
  stock_reservations: :default,
136
136
  tax_identifiers: :default,
137
137
  data_requests: :default,
138
- payouts: :default
138
+ payouts: :default,
139
+ maintenance_tasks: :default
139
140
  ).tap do |queues|
140
141
  # @deprecated The taxons queue was renamed to categories in 6.0; removed in 6.1.
141
142
  queues.define_singleton_method(:taxons) do
@@ -455,6 +456,19 @@ module Spree
455
456
  custom_fields
456
457
  end
457
458
 
459
+ # Registered maintenance task class names — the tasks an operator can run
460
+ # from the dashboard, the Admin API or the CLI
461
+ # (docs/plans/6.0-maintenance-tasks.md). Extensions append their own from an
462
+ # initializer:
463
+ #
464
+ # Spree.maintenance_tasks << 'SpreeReviews::MaintenanceTasks::RecountRatings'
465
+ #
466
+ # Class names rather than classes, so registration is safe at initializer
467
+ # time and survives code reloading in development.
468
+ #
469
+ # @return [Array<String, Class>]
470
+ singleton_class.delegate :maintenance_tasks, :maintenance_tasks=, to: :spree_config
471
+
458
472
  # Registry mapping a numbered resource to the generator that produces its
459
473
  # document numbers. With no entry, the store's `document_number_format`
460
474
  # preference picks between the sequential and random strategies.
@@ -644,6 +658,7 @@ require 'spree/core/version'
644
658
  require 'spree/core/number_generator'
645
659
  require 'spree/number_generators/registry'
646
660
  require 'spree/migrations'
661
+ require 'spree/upgrade'
647
662
  require 'spree/validators'
648
663
  require 'spree/core/engine'
649
664
 
@@ -0,0 +1,7 @@
1
+ FactoryBot.define do
2
+ factory :maintenance_task_run, class: 'Spree::MaintenanceTaskRun' do
3
+ task_name { 'Spree::MaintenanceTasks::BackfillOrderMarkets' }
4
+ status { 'enqueued' }
5
+ initiated_via { 'api' }
6
+ end
7
+ end
@@ -0,0 +1,189 @@
1
+ module Spree
2
+ # Reads the upgrade manifests shipped inside spree_core — the list of data
3
+ # steps an installation must run after `db:migrate` for a given release
4
+ # boundary (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # Extracted from lib/tasks/upgrade.rake so the dashboard and the maintenance
7
+ # task runner can read the same manifests the rake walk does; the rake file
8
+ # keeps the walking and printing.
9
+ module Upgrade
10
+ # The first release whose upgrade steps run as maintenance tasks. Nothing
11
+ # recorded the steps of earlier releases, so the dashboard starts here; the
12
+ # command line still walks every manifest.
13
+ FIRST_TRACKED_RELEASE = '6.0'.freeze
14
+
15
+ # Two-segment "5.5" form of the installed Spree version.
16
+ #
17
+ # @return [String]
18
+ def self.installed_minor_version
19
+ Spree.version.split('.').first(2).join('.')
20
+ end
21
+
22
+ # Root directory containing N_M_to_O_P/manifest.yml files inside the
23
+ # spree_core gem.
24
+ #
25
+ # @return [String]
26
+ def self.manifests_root
27
+ File.expand_path('upgrades', __dir__)
28
+ end
29
+
30
+ # All available manifest directories, parsed into { from:, to:, dir: }.
31
+ # Sorted by `to` ascending, with `from` as a tiebreaker (smallest first)
32
+ # for the rare case where two manifests share a `to` boundary.
33
+ #
34
+ # @return [Array<Hash>]
35
+ def self.available_manifests
36
+ Dir.glob(File.join(manifests_root, '*_to_*')).filter_map do |dir|
37
+ name = File.basename(dir)
38
+ match = name.match(/\A([\d_]+)_to_([\d_]+)\z/)
39
+ next unless match
40
+
41
+ { from: match[1].tr('_', '.'), to: match[2].tr('_', '.'), dir: dir }
42
+ end.sort_by { |manifest| version_parts(manifest[:to]) + version_parts(manifest[:from]) }
43
+ end
44
+
45
+ # Every manifest with its steps loaded, in walk order. Read once per
46
+ # process: the files ship inside the gem and change only with a deploy.
47
+ #
48
+ # @return [Array<Hash>]
49
+ def self.manifests
50
+ @manifests ||= available_manifests.map do |entry|
51
+ YAML.safe_load_file(File.join(entry[:dir], 'manifest.yml')).freeze
52
+ end.freeze
53
+ end
54
+
55
+ # Every step of every manifest, in walk order, each carrying the manifest
56
+ # boundary it belongs to so callers can group without re-reading the files.
57
+ #
58
+ # @return [Array<Hash>]
59
+ def self.steps
60
+ @steps ||= manifests.flat_map do |manifest|
61
+ manifest['steps'].map do |step|
62
+ step.merge('from' => manifest['from'], 'to' => manifest['to'], 'docs' => manifest['docs'])
63
+ end
64
+ end.freeze
65
+ end
66
+
67
+ # Whether a step belongs to a release whose runs are recorded, and so has a
68
+ # place in the dashboard's checklist.
69
+ #
70
+ # @param step [Hash] a manifest step
71
+ # @return [Boolean]
72
+ def self.tracked?(step)
73
+ compare(step['to'], FIRST_TRACKED_RELEASE) >= 0
74
+ end
75
+
76
+ # The task a step runs: its own class, or the shared runner for a step
77
+ # that names a rake task.
78
+ #
79
+ # @param step [Hash] a manifest step
80
+ # @return [String] a task class name
81
+ def self.task_class_name(step)
82
+ step['task_class'] || 'Spree::MaintenanceTasks::UpgradeStep'
83
+ end
84
+
85
+ # The arguments that start a step's task: a rake-backed step is named by
86
+ # its id, so the shared runner knows which rake task to invoke.
87
+ #
88
+ # @param step [Hash] a manifest step
89
+ # @return [Hash]
90
+ def self.task_arguments(step)
91
+ step['task_class'] ? {} : { 'step_id' => step['id'] }
92
+ end
93
+
94
+ # @param step_id [String]
95
+ # @return [Hash, nil]
96
+ def self.find_step(step_id)
97
+ return nil if step_id.blank?
98
+
99
+ steps.find { |step| step['id'] == step_id.to_s }
100
+ end
101
+
102
+ # The boundary this installation has already completed, and therefore the
103
+ # point a pending list starts from.
104
+ #
105
+ # Falls back to the release before the installed one when nothing has been
106
+ # stamped: an installation running 6.0 has, by far most often, come from
107
+ # 5.6, and showing every step of every manifest ever written is worse than
108
+ # showing the one boundary that is almost certainly right. A store that
109
+ # skipped releases runs the earlier steps from the command line, which
110
+ # walks every manifest whatever the boundary.
111
+ #
112
+ # @return [String, nil]
113
+ def self.completed_boundary
114
+ Spree::UpgradeRecord.current_version || previous_boundary
115
+ end
116
+
117
+ # The `to` of the manifest immediately below the installed version.
118
+ #
119
+ # @return [String, nil]
120
+ def self.previous_boundary
121
+ boundaries = manifests.map { |manifest| manifest['from'] }.uniq
122
+ boundaries.select { |version| compare(version, installed_minor_version) < 0 }.
123
+ max_by { |version| version_parts(version) }
124
+ end
125
+
126
+ # Whether an upgrade applies to this installation at all.
127
+ #
128
+ # False for a store installed fresh at its current version: it has no
129
+ # historical data to convert, so neither the outstanding steps nor the
130
+ # history of past releases describes anything real for it. True again
131
+ # once a later release ships steps it has not run.
132
+ #
133
+ # @return [Boolean]
134
+ def self.relevant?
135
+ !Spree::UpgradeRecord.exists? || Spree::UpgradeRecord.upgraded? || pending_steps.any?
136
+ end
137
+
138
+ # Whether a step can run here. An optional step belongs to a gem that may
139
+ # not be installed — a payment gateway, for instance — and is skipped
140
+ # where it is absent rather than offered as work that cannot start.
141
+ #
142
+ # @param step [Hash] a manifest step
143
+ # @return [Boolean]
144
+ def self.available?(step)
145
+ return true unless step['optional']
146
+ return Spree::MaintenanceTask.find_registered(step['task_class']).present? if step['task_class']
147
+
148
+ Spree::MaintenanceTask.new.rake_file_defining(step['task']).present?
149
+ end
150
+
151
+ # Steps this installation still has to run: every manifest above the
152
+ # boundary it has completed, up to and including the installed version.
153
+ #
154
+ # @return [Array<Hash>]
155
+ def self.pending_steps
156
+ boundary = completed_boundary
157
+
158
+ steps.select do |step|
159
+ next false if compare(step['to'], installed_minor_version) > 0
160
+ next false unless available?(step)
161
+ next true if boundary.nil?
162
+
163
+ compare(step['from'], boundary) >= 0
164
+ end
165
+ end
166
+
167
+ # Steps of manifests below the completed boundary — the ones a fresh or
168
+ # already-upgraded installation does not need. When the boundary was
169
+ # assumed rather than recorded the dashboard says so, and points a store
170
+ # that skipped a release at the command line, which still runs them.
171
+ #
172
+ # @return [Array<Hash>]
173
+ def self.superseded_steps
174
+ boundary = completed_boundary
175
+ return [] if boundary.nil?
176
+
177
+ steps.select { |step| compare(step['from'], boundary) < 0 }
178
+ end
179
+
180
+ def self.version_parts(version)
181
+ version.split('.').map { |segment| Integer(segment, 10) rescue 0 }
182
+ end
183
+
184
+ # Compare two dotted-version strings (returns -1, 0, +1).
185
+ def self.compare(first, second)
186
+ version_parts(first) <=> version_parts(second)
187
+ end
188
+ end
189
+ end
@@ -12,8 +12,15 @@
12
12
  # live in the upgrade doc at docs/developer/upgrades/5.6-to-6.0.mdx.
13
13
  #
14
14
  # This manifest is the machine-runnable shape — what `bin/rake spree:upgrade`
15
- # (in production) and `spree upgrade` (in dev) execute. Every step must be a
16
- # rake task and must be idempotent. Re-running the full manifest is safe.
15
+ # (in production) and `spree upgrade` (in dev) execute. Every step must be
16
+ # idempotent; re-running the full manifest is safe.
17
+ #
18
+ # Steps name a `task_class` — a Spree::MaintenanceTask that the walk runs
19
+ # through the maintenance task runner, so a shell walk and a dashboard run
20
+ # leave the same audit record and the same resume behaviour
21
+ # (docs/plans/6.0-maintenance-tasks.md). Released manifests keep their `task`
22
+ # rake names, which have shipped and may be scripted; the walker runs both
23
+ # kinds.
17
24
  ---
18
25
  from: "5.6"
19
26
  to: "6.0"
@@ -151,7 +158,7 @@ steps:
151
158
 
152
159
  - id: migrate_users_to_customers
153
160
  name: "Move users onto the gem-owned Customer/AdminUser models"
154
- task: "spree:upgrade:migrate_users_to_customers"
161
+ task_class: "Spree::MaintenanceTasks::Upgrade::UsersToCustomers"
155
162
  notes: |
156
163
  Copies the legacy Devise user table (default spree_users) into
157
164
  spree_customers, preserving ids so the renamed customer_id foreign keys
@@ -261,7 +268,7 @@ steps:
261
268
 
262
269
  - id: migrate_deliveries
263
270
  name: "Move tracking numbers onto deliveries"
264
- task: "spree:migrate_deliveries"
271
+ task_class: "Spree::MaintenanceTasks::Upgrade::Deliveries"
265
272
  notes: |
266
273
  Every fulfillment carrying a 5.6 tracking number gets one delivery row
267
274
  with that number: the carrier axis (tracking number, carrier, carrier
@@ -322,7 +329,7 @@ steps:
322
329
 
323
330
  - id: migrate_promotion_option_value_rules
324
331
  name: "Rewrite Option Value promotion rules to stable option value ids"
325
- task: "spree:upgrade:migrate_promotion_option_value_rules"
332
+ task_class: "Spree::MaintenanceTasks::Upgrade::PromotionOptionValueRules"
326
333
  notes: |
327
334
  Spree 6.0 stores Option Value promotion rules against Spree::OptionValue
328
335
  ids. Upgrades from 5.6 may still carry join-row ids from
@@ -365,7 +372,7 @@ steps:
365
372
 
366
373
  - id: migrate_returns
367
374
  name: "Move returns onto the new returns models"
368
- task: "spree:upgrade:migrate_returns"
375
+ task_class: "Spree::MaintenanceTasks::Upgrade::Returns"
369
376
  notes: |
370
377
  Converts each ReturnAuthorization into a Spree::Return, or a
371
378
  Spree::Exchange when any of its return items named an exchange variant,
@@ -494,7 +501,7 @@ steps:
494
501
 
495
502
  - id: backfill_order_coupon_codes
496
503
  name: "Backfill coupon codes on placed orders"
497
- task: "spree:backfill_order_coupon_codes"
504
+ task_class: "Spree::MaintenanceTasks::Upgrade::OrderCouponCodes"
498
505
  notes: |
499
506
  spree_orders gains a coupon_code column in Spree 6, matching carts.
500
507
  Historical placed orders applied coupons only through the promotion
@@ -510,7 +517,7 @@ steps:
510
517
 
511
518
  - id: backfill_order_markets
512
519
  name: "Backfill order markets"
513
- task: "spree:backfill_order_markets"
520
+ task_class: "Spree::MaintenanceTasks::Upgrade::BackfillOrderMarkets"
514
521
  notes: |
515
522
  Market is mandatory in Spree 6. Assigns the store's default market to
516
523
  orders that predate markets. Channel needs no step here — the 5.4→5.5
@@ -573,7 +580,7 @@ steps:
573
580
 
574
581
  - id: migrate_capture_methods
575
582
  name: "Convert payment method auto_capture into capture_method"
576
- task: "spree:migrate_capture_methods"
583
+ task_class: "Spree::MaintenanceTasks::Upgrade::CaptureMethods"
577
584
  notes: |
578
585
  Payment methods say when they charge with a capture_method string
579
586
  (checkout, on_dispatch or manual) rather than an auto_capture boolean,
@@ -625,7 +632,7 @@ steps:
625
632
 
626
633
  - id: migrate_stripe_webhook_keys
627
634
  name: "Move Stripe webhook signing secrets onto the gateway"
628
- task: "spree:upgrade:migrate_stripe_webhook_keys"
635
+ task_class: "SpreeStripe::MaintenanceTasks::MigrateWebhookKeys"
629
636
  optional: true
630
637
  notes: |
631
638
  Only runs when spree_stripe is installed; skipped otherwise.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Command-line entry to the same runner the dashboard drives, so a shell run
4
+ # is audited exactly like an operator's (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # bin/rake "spree:maintenance_tasks:perform[backfill_order_markets]"
7
+ # DRY_RUN=1 bin/rake "spree:maintenance_tasks:perform[capture_methods]"
8
+ # ARGS='batch_size=200' bin/rake "spree:maintenance_tasks:perform[deliveries]"
9
+ namespace :spree do
10
+ namespace :maintenance_tasks do
11
+ desc 'Run a maintenance task synchronously'
12
+ task :perform, [:task_name] => :environment do |_task, args|
13
+ task_name = args[:task_name] || ENV['TASK']
14
+ abort ' Pass a task name: rake "spree:maintenance_tasks:perform[Your::Task]"' if task_name.blank?
15
+
16
+ # Either the class name or the short name the dashboard shows.
17
+ lookup = Spree::MaintenanceTask.find_registered(task_name) ? { task_name: task_name } : { api_type: task_name }
18
+
19
+ result = Spree::MaintenanceTasks::Start.call(
20
+ **lookup,
21
+ arguments: Spree::MaintenanceTasks::ArgumentParser.parse(ENV['ARGS']),
22
+ dry_run: ENV['DRY_RUN'] == '1',
23
+ initiated_via: 'cli',
24
+ inline: true
25
+ )
26
+
27
+ abort " #{result.error}" if result.failure?
28
+
29
+ run = result.value.reload
30
+ puts " #{run.task_name}: #{run.status} — #{run.tick_count} processed#{run.dry_run? ? ' (dry run)' : ''}"
31
+ (run.tallies || {}).each { |key, value| puts " #{key}: #{value}" }
32
+
33
+ if run.errored?
34
+ puts " #{run.error_class}: #{run.error_message}"
35
+ abort ' The run failed. Fix the cause, then re-run this task.'
36
+ end
37
+
38
+ # A refused duplicate or a cancellation did not do the work either.
39
+ abort " The run did not finish (#{run.status})." unless run.succeeded?
40
+ end
41
+ end
42
+ end
@@ -33,41 +33,10 @@ namespace :spree do
33
33
  end
34
34
  end
35
35
 
36
+ require 'spree/upgrade'
37
+
36
38
  module Spree
37
39
  module Upgrade
38
- # Two-segment "5.5" form of the installed Spree version.
39
- def self.installed_minor_version
40
- Spree.version.split('.').first(2).join('.')
41
- end
42
-
43
- # Root directory containing N_M_to_O_P/manifest.yml files inside
44
- # the spree_core gem.
45
- def self.manifests_root
46
- File.expand_path('../spree/upgrades', __dir__)
47
- end
48
-
49
- # All available manifest directories, parsed into { from:, to:, dir: }.
50
- # Sorted by `to` ascending, with `from` as a tiebreaker (smallest first)
51
- # for the rare case where two manifests share a `to` boundary.
52
- def self.available_manifests
53
- Dir.glob(File.join(manifests_root, '*_to_*')).filter_map do |dir|
54
- name = File.basename(dir)
55
- match = name.match(/\A([\d_]+)_to_([\d_]+)\z/)
56
- next unless match
57
-
58
- { from: match[1].tr('_', '.'), to: match[2].tr('_', '.'), dir: dir }
59
- end.sort_by { |m| version_parts(m[:to]) + version_parts(m[:from]) }
60
- end
61
-
62
- def self.version_parts(v)
63
- v.split('.').map { |s| Integer(s, 10) rescue 0 }
64
- end
65
-
66
- # Compare two dotted-version strings (returns -1, 0, +1).
67
- def self.compare(a, b)
68
- version_parts(a) <=> version_parts(b)
69
- end
70
-
71
40
  # The runner is a class (not a method) so individual concerns (selection,
72
41
  # rendering, invocation) stay separable and the plan can be inspected
73
42
  # without execution.
@@ -100,13 +69,7 @@ module Spree
100
69
  # Plan mode selects exactly what a real run executes, so a dry run after
101
70
  # bumping the gem lists every backfill the upgrade is about to perform.
102
71
  def eligible_manifests
103
- Spree::Upgrade.available_manifests
104
- .select { |manifest| Spree::Upgrade.compare(manifest[:to], target_version) <= 0 }
105
- .map { |entry| load_manifest_yaml(entry) }
106
- end
107
-
108
- def load_manifest_yaml(entry)
109
- YAML.safe_load_file(File.join(entry[:dir], 'manifest.yml'))
72
+ Spree::Upgrade.manifests.select { |manifest| Spree::Upgrade.compare(manifest['to'], target_version) <= 0 }
110
73
  end
111
74
 
112
75
  def run_full_walk(manifests)
@@ -122,10 +85,22 @@ module Spree
122
85
  end
123
86
  end
124
87
 
88
+ stamp_completed_boundaries(manifests) unless dry_run
89
+
125
90
  puts
126
91
  puts dry_run ? ' (dry run — nothing executed)' : ' Upgrade tasks complete.'
127
92
  end
128
93
 
94
+ # Records the boundaries this walk finished, so a later dashboard visit
95
+ # shows only what is still outstanding rather than every step of every
96
+ # manifest ever written. A partial walk stamps nothing — `invoke` aborts
97
+ # on a failed step, so reaching here means every step succeeded.
98
+ def stamp_completed_boundaries(manifests)
99
+ manifests.each do |manifest|
100
+ Spree::UpgradeRecord.stamp!(manifest['to'], source: 'walk')
101
+ end
102
+ end
103
+
129
104
  # STEP=<id> looks across every eligible manifest; we need exactly one
130
105
  # match. Multiple matches are likely a manifest bug (two manifests
131
106
  # referencing the same step id) but we surface it rather than picking
@@ -162,38 +137,84 @@ module Spree
162
137
  puts
163
138
  puts " Step #{index}/#{total} [#{step['id']}]"
164
139
  puts " #{step['name']}"
165
- puts " > bin/rake #{step['task']}"
140
+ puts " > #{step_command(step)}"
166
141
  return unless step['notes']
167
142
 
168
143
  step['notes'].each_line { |line| puts " #{line.chomp}" }
169
144
  end
170
145
 
171
146
  def print_step_complete(step)
172
- puts " ✓ #{step['task']} done."
147
+ puts " ✓ #{step_label(step)} done."
173
148
  end
174
149
 
150
+ def step_label(step)
151
+ step['task_class'] || step['task']
152
+ end
153
+
154
+ def step_command(step)
155
+ if step['task_class']
156
+ "bin/rake \"spree:maintenance_tasks:perform[#{step['task_class']}]\""
157
+ else
158
+ "bin/rake #{step['task']}"
159
+ end
160
+ end
161
+
162
+ # Both step kinds run through the maintenance task runner, so a shell
163
+ # walk records the same audit rows a dashboard run does: a class-backed
164
+ # step runs its own task, a rake-backed step runs inside UpgradeStep.
165
+ #
166
+ # Returns false when the step was skipped, so the caller can leave the
167
+ # "done" line off a step that never ran.
175
168
  def invoke(step)
176
- task = step.fetch('task')
177
-
178
- # Steps contributed by optional gems (payment gateways, for instance)
179
- # are absent unless that gem is installed. Skipping keeps the upgrade
180
- # runnable on installs that never had the gem, while an install that
181
- # does have it still gets the backfill.
182
- if step['optional'] && !Rake::Task.task_defined?(task)
183
- puts " Skipping #{task} — not installed."
184
- return false
169
+ return false if skip_uninstalled?(step)
170
+
171
+ puts " Running #{step_label(step)}..."
172
+
173
+ result = Spree::MaintenanceTasks::Start.call(
174
+ task_name: Spree::Upgrade.task_class_name(step),
175
+ arguments: step_arguments(step),
176
+ initiated_via: 'cli',
177
+ inline: true
178
+ )
179
+
180
+ abort " #{result.error}" if result.failure?
181
+
182
+ run = result.value.reload
183
+ (run.tallies || {}).each { |key, value| puts " #{key}: #{value}" }
184
+
185
+ if run.errored?
186
+ puts " #{run.error_class}: #{run.error_message}"
187
+ abort ' Step failed. Fix the cause, then re-run this step.'
188
+ end
189
+
190
+ # Anything short of success — cancelled or still running — means
191
+ # the step's data is not converted, and the steps after it depend on it.
192
+ unless run.succeeded?
193
+ puts " The step did not finish (#{run.status})."
194
+ abort ' Re-run this step once it can finish.'
185
195
  end
186
196
 
187
- puts " Running #{task}..."
197
+ true
198
+ end
199
+
200
+ # A rake-backed step is named by id; a class-backed step that reads
201
+ # environment variables gets the ones the operator exported, so
202
+ # `CONFIRM_NO_PEPPER=true bin/rake spree:upgrade` reaches it.
203
+ def step_arguments(step)
204
+ return Spree::Upgrade.task_arguments(step) unless step['task_class']
205
+
206
+ task_class = Spree::MaintenanceTask.find_registered(step['task_class'])
207
+ task_class.respond_to?(:arguments_from_environment) ? task_class.arguments_from_environment : {}
208
+ end
209
+
210
+ # Steps contributed by optional gems (payment gateways, for instance)
211
+ # are absent unless that gem is installed. Skipping keeps the upgrade
212
+ # runnable on installs that never had the gem, while an install that
213
+ # does have it still gets the backfill.
214
+ def skip_uninstalled?(step)
215
+ return false if Spree::Upgrade.available?(step)
188
216
 
189
- # Rake caches invoked tasks in-process; explicit reenable lets a
190
- # single `rake spree:upgrade` run re-invoke aggregators that share
191
- # subtasks across multiple manifests (e.g. two manifests both
192
- # depending on `spree:install:migrations` would otherwise only
193
- # invoke it once).
194
- rake_task = Rake::Task[task]
195
- rake_task.reenable
196
- rake_task.invoke
217
+ puts " Skipping #{step_label(step)} — not installed."
197
218
  true
198
219
  end
199
220
  end