From 16681e0f20e1f8584e11439953c8d59b322571f5 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Fri, 1 Sep 2023 17:47:07 +0200
Subject: [PATCH 1/2] Add admin notifications for new Mastodon versions
 (#26582)

---
 .../admin/software_updates_controller.rb      |  18 ++
 .../components/critical_update_banner.tsx     |  26 +++
 .../mastodon/features/home_timeline/index.jsx |  14 +-
 app/javascript/mastodon/initial_state.js      |   2 +
 app/javascript/mastodon/locales/en.json       |   3 +
 app/javascript/styles/mastodon/admin.scss     |   5 +
 .../styles/mastodon/components.scss           |  18 +-
 app/javascript/styles/mastodon/tables.scss    |   5 +
 app/lib/admin/system_check.rb                 |   1 +
 .../system_check/software_version_check.rb    |  27 +++
 app/mailers/admin_mailer.rb                   |  16 ++
 app/models/software_update.rb                 |  40 +++++
 app/models/user_settings.rb                   |   1 +
 app/policies/software_update_policy.rb        |   7 +
 app/presenters/initial_state_presenter.rb     |   6 +-
 app/serializers/initial_state_serializer.rb   |   2 +
 app/services/software_update_check_service.rb |  82 +++++++++
 .../admin/software_updates/index.html.haml    |  29 ++++
 .../new_critical_software_updates.text.erb    |   5 +
 .../new_software_updates.text.erb             |   5 +
 .../preferences/notifications/show.html.haml  |   6 +-
 .../software_update_check_scheduler.rb        |  11 ++
 config/locales/en.yml                         |  25 +++
 config/locales/simple_form.en.yml             |   6 +
 config/navigation.rb                          |   3 +
 config/routes/admin.rb                        |   2 +
 config/sidekiq.yml                            |   4 +
 .../20230822081029_create_software_updates.rb |  16 ++
 db/schema.rb                                  |  12 +-
 lib/mastodon/version.rb                       |   4 +
 lib/tasks/mastodon.rake                       |   4 +
 .../fabricators/software_update_fabricator.rb |   7 +
 spec/features/admin/software_updates_spec.rb  |  23 +++
 .../software_version_check_spec.rb            | 133 +++++++++++++++
 spec/mailers/admin_mailer_spec.rb             |  42 +++++
 spec/models/software_update_spec.rb           |  87 ++++++++++
 spec/policies/software_update_policy_spec.rb  |  25 +++
 .../software_update_check_service_spec.rb     | 158 ++++++++++++++++++
 .../software_update_check_scheduler_spec.rb   |  20 +++
 39 files changed, 892 insertions(+), 8 deletions(-)
 create mode 100644 app/controllers/admin/software_updates_controller.rb
 create mode 100644 app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx
 create mode 100644 app/lib/admin/system_check/software_version_check.rb
 create mode 100644 app/models/software_update.rb
 create mode 100644 app/policies/software_update_policy.rb
 create mode 100644 app/services/software_update_check_service.rb
 create mode 100644 app/views/admin/software_updates/index.html.haml
 create mode 100644 app/views/admin_mailer/new_critical_software_updates.text.erb
 create mode 100644 app/views/admin_mailer/new_software_updates.text.erb
 create mode 100644 app/workers/scheduler/software_update_check_scheduler.rb
 create mode 100644 db/migrate/20230822081029_create_software_updates.rb
 create mode 100644 spec/fabricators/software_update_fabricator.rb
 create mode 100644 spec/features/admin/software_updates_spec.rb
 create mode 100644 spec/lib/admin/system_check/software_version_check_spec.rb
 create mode 100644 spec/models/software_update_spec.rb
 create mode 100644 spec/policies/software_update_policy_spec.rb
 create mode 100644 spec/services/software_update_check_service_spec.rb
 create mode 100644 spec/workers/scheduler/software_update_check_scheduler_spec.rb

diff --git a/app/controllers/admin/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb
new file mode 100644
index 0000000000..52d8cb41e6
--- /dev/null
+++ b/app/controllers/admin/software_updates_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Admin
+  class SoftwareUpdatesController < BaseController
+    before_action :check_enabled!
+
+    def index
+      authorize :software_update, :index?
+      @software_updates = SoftwareUpdate.all.sort_by(&:gem_version)
+    end
+
+    private
+
+    def check_enabled!
+      not_found unless SoftwareUpdate.check_enabled?
+    end
+  end
+end
diff --git a/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx
new file mode 100644
index 0000000000..d0dd2b6acd
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx
@@ -0,0 +1,26 @@
+import { FormattedMessage } from 'react-intl';
+
+export const CriticalUpdateBanner = () => (
+  <div className='warning-banner'>
+    <div className='warning-banner__message'>
+      <h1>
+        <FormattedMessage
+          id='home.pending_critical_update.title'
+          defaultMessage='Critical security update available!'
+        />
+      </h1>
+      <p>
+        <FormattedMessage
+          id='home.pending_critical_update.body'
+          defaultMessage='Please update your Mastodon server as soon as possible!'
+        />{' '}
+        <a href='/admin/software_updates'>
+          <FormattedMessage
+            id='home.pending_critical_update.link'
+            defaultMessage='See updates'
+          />
+        </a>
+      </p>
+    </div>
+  </div>
+);
diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx
index 1cd6edd7aa..8ff0377946 100644
--- a/app/javascript/mastodon/features/home_timeline/index.jsx
+++ b/app/javascript/mastodon/features/home_timeline/index.jsx
@@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
 import { IconWithBadge } from 'mastodon/components/icon_with_badge';
 import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
 import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
-import { me } from 'mastodon/initial_state';
+import { me, criticalUpdatesPending } from 'mastodon/initial_state';
 
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { expandHomeTimeline } from '../../actions/timelines';
@@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
 import StatusListContainer from '../ui/containers/status_list_container';
 
 import { ColumnSettings } from './components/column_settings';
+import { CriticalUpdateBanner } from './components/critical_update_banner';
 import { ExplorePrompt } from './components/explore_prompt';
 
 const messages = defineMessages({
@@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent {
     const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
     const pinned = !!columnId;
     const { signedIn } = this.context.identity;
+    const banners = [];
 
-    let announcementsButton, banner;
+    let announcementsButton;
 
     if (hasAnnouncements) {
       announcementsButton = (
@@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent {
       );
     }
 
+    if (criticalUpdatesPending) {
+      banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
+    }
+
     if (tooSlow) {
-      banner = <ExplorePrompt />;
+      banners.push(<ExplorePrompt key='explore-prompt' />);
     }
 
     return (
@@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent {
 
         {signedIn ? (
           <StatusListContainer
-            prepend={banner}
+            prepend={banners}
             alwaysPrepend
             trackScroll={!pinned}
             scrollKey={`home_timeline-${columnId}`}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 85792a4ea4..11cd2a1673 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -87,6 +87,7 @@
  * @typedef InitialState
  * @property {Record<string, Account>} accounts
  * @property {InitialStateLanguage[]} languages
+ * @property {boolean=} critical_updates_pending
  * @property {InitialStateMeta} meta
  */
 
@@ -140,6 +141,7 @@ export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
 export const version = getMeta('version');
 export const languages = initialState?.languages;
+export const criticalUpdatesPending = initialState?.critical_updates_pending;
 // @ts-expect-error
 export const statusPageUrl = getMeta('status_page_url');
 export const sso_redirect = getMeta('sso_redirect');
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 90bb9616f0..13cddba723 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -310,6 +310,9 @@
   "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
   "home.explore_prompt.title": "This is your home base within Mastodon.",
   "home.hide_announcements": "Hide announcements",
+  "home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
+  "home.pending_critical_update.link": "See updates",
+  "home.pending_critical_update.title": "Critical security update available!",
   "home.show_announcements": "Show announcements",
   "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
   "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index bbb6ffdff7..a65f35e7b1 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -143,6 +143,11 @@ $content-width: 840px;
         }
       }
 
+      .warning a {
+        color: $gold-star;
+        font-weight: 700;
+      }
+
       .simple-navigation-active-leaf a {
         color: $primary-text-color;
         background-color: $ui-highlight-color;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f61cd059fe..10083a2a32 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -8860,7 +8860,8 @@ noscript {
   }
 }
 
-.dismissable-banner {
+.dismissable-banner,
+.warning-banner {
   position: relative;
   margin: 10px;
   margin-bottom: 5px;
@@ -8938,6 +8939,21 @@ noscript {
   }
 }
 
+.warning-banner {
+  border: 1px solid $warning-red;
+  background: rgba($warning-red, 0.15);
+
+  &__message {
+    h1 {
+      color: $warning-red;
+    }
+
+    a {
+      color: $primary-text-color;
+    }
+  }
+}
+
 .image {
   position: relative;
   overflow: hidden;
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index 38cfc87271..dd5b483ec4 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -12,6 +12,11 @@
     border-top: 1px solid $ui-base-color;
     text-align: start;
     background: darken($ui-base-color, 4%);
+
+    &.critical {
+      font-weight: 700;
+      color: $gold-star;
+    }
   }
 
   & > thead > tr > th {
diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb
index 89dfcef9f1..25c88341a4 100644
--- a/app/lib/admin/system_check.rb
+++ b/app/lib/admin/system_check.rb
@@ -2,6 +2,7 @@
 
 class Admin::SystemCheck
   ACTIVE_CHECKS = [
+    Admin::SystemCheck::SoftwareVersionCheck,
     Admin::SystemCheck::MediaPrivacyCheck,
     Admin::SystemCheck::DatabaseSchemaCheck,
     Admin::SystemCheck::SidekiqProcessCheck,
diff --git a/app/lib/admin/system_check/software_version_check.rb b/app/lib/admin/system_check/software_version_check.rb
new file mode 100644
index 0000000000..e142feddf0
--- /dev/null
+++ b/app/lib/admin/system_check/software_version_check.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck
+  include RoutingHelper
+
+  def skip?
+    !current_user.can?(:view_devops) || !SoftwareUpdate.check_enabled?
+  end
+
+  def pass?
+    software_updates.empty?
+  end
+
+  def message
+    if software_updates.any?(&:urgent?)
+      Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true)
+    else
+      Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path)
+    end
+  end
+
+  private
+
+  def software_updates
+    @software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? }
+  end
+end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index 5baf9b38a5..990b92c337 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -45,6 +45,22 @@ class AdminMailer < ApplicationMailer
     end
   end
 
+  def new_software_updates
+    locale_for_account(@me) do
+      mail subject: default_i18n_subject(instance: @instance)
+    end
+  end
+
+  def new_critical_software_updates
+    headers['Priority'] = 'urgent'
+    headers['X-Priority'] = '1'
+    headers['Importance'] = 'high'
+
+    locale_for_account(@me) do
+      mail subject: default_i18n_subject(instance: @instance)
+    end
+  end
+
   private
 
   def process_params
diff --git a/app/models/software_update.rb b/app/models/software_update.rb
new file mode 100644
index 0000000000..cb3a6df2ae
--- /dev/null
+++ b/app/models/software_update.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: software_updates
+#
+#  id            :bigint(8)        not null, primary key
+#  version       :string           not null
+#  urgent        :boolean          default(FALSE), not null
+#  type          :integer          default("patch"), not null
+#  release_notes :string           default(""), not null
+#  created_at    :datetime         not null
+#  updated_at    :datetime         not null
+#
+
+class SoftwareUpdate < ApplicationRecord
+  self.inheritance_column = nil
+
+  enum type: { patch: 0, minor: 1, major: 2 }, _suffix: :type
+
+  def gem_version
+    Gem::Version.new(version)
+  end
+
+  class << self
+    def check_enabled?
+      ENV['UPDATE_CHECK_URL'] != ''
+    end
+
+    def pending_to_a
+      return [] unless check_enabled?
+
+      all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version }
+    end
+
+    def urgent_pending?
+      pending_to_a.any?(&:urgent?)
+    end
+  end
+end
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
index 678467c75d..030cbec4d8 100644
--- a/app/models/user_settings.rb
+++ b/app/models/user_settings.rb
@@ -44,6 +44,7 @@ class UserSettings
     setting :pending_account, default: true
     setting :trends, default: true
     setting :appeal, default: true
+    setting :software_updates, default: 'critical', in: %w(none critical patch all)
   end
 
   namespace :interactions do
diff --git a/app/policies/software_update_policy.rb b/app/policies/software_update_policy.rb
new file mode 100644
index 0000000000..dcb565814f
--- /dev/null
+++ b/app/policies/software_update_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class SoftwareUpdatePolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_devops)
+  end
+end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index b87cff51e1..222cc8566c 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -3,9 +3,13 @@
 class InitialStatePresenter < ActiveModelSerializers::Model
   attributes :settings, :push_subscription, :token,
              :current_account, :admin, :owner, :text, :visibility,
-             :disabled_account, :moved_to_account
+             :disabled_account, :moved_to_account, :critical_updates_pending
 
   def role
     current_account&.user_role
   end
+
+  def critical_updates_pending
+    role&.can?(:view_devops) && SoftwareUpdate.urgent_pending?
+  end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 9660c941d0..56d45c588e 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -7,6 +7,8 @@ class InitialStateSerializer < ActiveModel::Serializer
              :media_attachments, :settings,
              :languages
 
+  attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
+
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
   has_one :role, serializer: REST::RoleSerializer
 
diff --git a/app/services/software_update_check_service.rb b/app/services/software_update_check_service.rb
new file mode 100644
index 0000000000..49b92f104d
--- /dev/null
+++ b/app/services/software_update_check_service.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+class SoftwareUpdateCheckService < BaseService
+  def call
+    clean_outdated_updates!
+    return unless SoftwareUpdate.check_enabled?
+
+    process_update_notices!(fetch_update_notices)
+  end
+
+  private
+
+  def clean_outdated_updates!
+    SoftwareUpdate.find_each do |software_update|
+      software_update.delete if Mastodon::Version.gem_version >= software_update.gem_version
+    rescue ArgumentError
+      software_update.delete
+    end
+  end
+
+  def fetch_update_notices
+    Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res|
+      return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200
+    end
+  rescue HTTP::Error, OpenSSL::SSL::SSLError, Oj::ParseError
+    nil
+  end
+
+  def api_url
+    ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check')
+  end
+
+  def version
+    @version ||= Mastodon::Version.to_s.split('+')[0]
+  end
+
+  def process_update_notices!(update_notices)
+    return if update_notices.blank? || update_notices['updatesAvailable'].blank?
+
+    # Clear notices that are not listed by the update server anymore
+    SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].pluck('version')).delete_all
+
+    # Check if any of the notices is new, and issue notifications
+    known_versions = SoftwareUpdate.where(version: update_notices['updatesAvailable'].pluck('version')).pluck(:version)
+    new_update_notices = update_notices['updatesAvailable'].filter { |notice| known_versions.exclude?(notice['version']) }
+    return if new_update_notices.blank?
+
+    new_updates = new_update_notices.map do |notice|
+      SoftwareUpdate.create!(version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes'])
+    end
+
+    notify_devops!(new_updates)
+  end
+
+  def should_notify_user?(user, urgent_version, patch_version)
+    case user.settings['notification_emails.software_updates']
+    when 'none'
+      false
+    when 'critical'
+      urgent_version
+    when 'patch'
+      urgent_version || patch_version
+    when 'all'
+      true
+    end
+  end
+
+  def notify_devops!(new_updates)
+    has_new_urgent_version = new_updates.any?(&:urgent?)
+    has_new_patch_version  = new_updates.any?(&:patch_type?)
+
+    User.those_who_can(:view_devops).includes(:account).find_each do |user|
+      next unless should_notify_user?(user, has_new_urgent_version, has_new_patch_version)
+
+      if has_new_urgent_version
+        AdminMailer.with(recipient: user.account).new_critical_software_updates.deliver_later
+      else
+        AdminMailer.with(recipient: user.account).new_software_updates.deliver_later
+      end
+    end
+  end
+end
diff --git a/app/views/admin/software_updates/index.html.haml b/app/views/admin/software_updates/index.html.haml
new file mode 100644
index 0000000000..7a223ee07b
--- /dev/null
+++ b/app/views/admin/software_updates/index.html.haml
@@ -0,0 +1,29 @@
+- content_for :page_title do
+  = t('admin.software_updates.title')
+
+.simple_form
+  %p.lead
+    = t('admin.software_updates.description')
+    = link_to t('admin.software_updates.documentation_link'), 'https://docs.joinmastodon.org/admin/upgrading/#automated_checks', target: '_new'
+
+%hr.spacer
+
+- unless @software_updates.empty?
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('admin.software_updates.version')
+          %th= t('admin.software_updates.type')
+          %th
+          %th
+      %tbody
+        - @software_updates.each do |update|
+          %tr
+            %td= update.version
+            %td= t("admin.software_updates.types.#{update.type}")
+            - if update.urgent?
+              %td.critical= t("admin.software_updates.critical_update")
+            - else
+              %td
+            %td= table_link_to 'link', t('admin.software_updates.release_notes'), update.release_notes
diff --git a/app/views/admin_mailer/new_critical_software_updates.text.erb b/app/views/admin_mailer/new_critical_software_updates.text.erb
new file mode 100644
index 0000000000..c901bc50f7
--- /dev/null
+++ b/app/views/admin_mailer/new_critical_software_updates.text.erb
@@ -0,0 +1,5 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_critical_software_updates.body') %>
+
+<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %>
diff --git a/app/views/admin_mailer/new_software_updates.text.erb b/app/views/admin_mailer/new_software_updates.text.erb
new file mode 100644
index 0000000000..2fc4d1a5f2
--- /dev/null
+++ b/app/views/admin_mailer/new_software_updates.text.erb
@@ -0,0 +1,5 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_software_updates.body') %>
+
+<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %>
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index 0913bda9ae..5cc101069c 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -22,7 +22,7 @@
     .fields-group
       = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails')
 
-    - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies)
+    - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops))
       %h4= t 'notifications.administration_emails'
 
       .fields-group
@@ -31,6 +31,10 @@
         = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
         = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
 
+      - if SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)
+        .fields-group
+          = ff.input :'notification_emails.software_updates', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.software_updates.label'), collection: %w(none critical patch all), label_method: ->(setting) { I18n.t("simple_form.labels.notification_emails.software_updates.#{setting}") }, include_blank: false, hint: false
+
   %h4= t 'notifications.other_settings'
 
   .fields-group
diff --git a/app/workers/scheduler/software_update_check_scheduler.rb b/app/workers/scheduler/software_update_check_scheduler.rb
new file mode 100644
index 0000000000..c732bdedc0
--- /dev/null
+++ b/app/workers/scheduler/software_update_check_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::SoftwareUpdateCheckScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.hour.to_i
+
+  def perform
+    SoftwareUpdateCheckService.new.call
+  end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 693155d6ef..71e5fb843e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -309,6 +309,7 @@ en:
       unpublish: Unpublish
       unpublished_msg: Announcement successfully unpublished!
       updated_msg: Announcement successfully updated!
+    critical_update_pending: Critical update pending
     custom_emojis:
       assign_category: Assign category
       by_domain: Domain
@@ -779,6 +780,18 @@ en:
     site_uploads:
       delete: Delete uploaded file
       destroyed_msg: Site upload successfully deleted!
+    software_updates:
+      critical_update: Critical — please update quickly
+      description: It is recommended to keep your Mastodon installation up to date to benefit from the latest fixes and features. Moreover, it is sometimes critical to update Mastodon in a timely manner to avoid security issues. For these reasons, Mastodon checks for updates every 30 minutes, and will notify you according to your e-mail notification preferences.
+      documentation_link: Learn more
+      release_notes: Release notes
+      title: Available updates
+      type: Type
+      types:
+        major: Major release
+        minor: Minor release
+        patch: Patch release — bugfixes and easy to apply changes
+      version: Version
     statuses:
       account: Author
       application: Application
@@ -843,6 +856,12 @@ en:
         message_html: You haven't defined any server rules.
       sidekiq_process_check:
         message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration
+      software_version_critical_check:
+        action: See available updates
+        message_html: A critical Mastodon update is available, please update as quickly as possible.
+      software_version_patch_check:
+        action: See available updates
+        message_html: A bugfix Mastodon update is available.
       upload_check_privacy_error:
         action: Check here for more information
         message_html: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>"
@@ -956,6 +975,9 @@ en:
       body: "%{target} is appealing a moderation decision by %{action_taken_by} from %{date}, which was %{type}. They wrote:"
       next_steps: You can approve the appeal to undo the moderation decision, or ignore it.
       subject: "%{username} is appealing a moderation decision on %{instance}"
+    new_critical_software_updates:
+      body: New critical versions of Mastodon have been released, you may want to update as soon as possible!
+      subject: Critical Mastodon updates are available for %{instance}!
     new_pending_account:
       body: The details of the new account are below. You can approve or reject this application.
       subject: New account up for review on %{instance} (%{username})
@@ -963,6 +985,9 @@ en:
       body: "%{reporter} has reported %{target}"
       body_remote: Someone from %{domain} has reported %{target}
       subject: New report for %{instance} (#%{id})
+    new_software_updates:
+      body: New Mastodon versions have been released, you may want to update!
+      subject: New Mastodon versions are available for %{instance}!
     new_trends:
       body: 'The following items need a review before they can be displayed publicly:'
       new_trending_links:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index b1297606bc..0b718c5b65 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -291,6 +291,12 @@ en:
         pending_account: New account needs review
         reblog: Someone boosted your post
         report: New report is submitted
+        software_updates:
+          all: Notify on all updates
+          critical: Notify on critical updates only
+          label: A new Mastodon version is available
+          none: Never notify of updates (not recommended)
+          patch: Notify on bugfix updates
         trending_tag: New trend requires review
       rule:
         text: Rule
diff --git a/config/navigation.rb b/config/navigation.rb
index f608c2eea7..e86c695a98 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -3,6 +3,9 @@
 SimpleNavigation::Configuration.run do |navigation|
   navigation.items do |n|
     n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path
+
+    n.item :software_updates, safe_join([fa_icon('exclamation-circle fw'), t('admin.critical_update_pending')]), admin_software_updates_path, if: -> { ENV['UPDATE_CHECK_URL'] != '' && current_user.can?(:view_devops) && SoftwareUpdate.urgent_pending? }, html: { class: 'warning' }
+
     n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? }, highlights_on: %r{/settings/profile|/settings/featured_tags|/settings/verification|/settings/privacy}
 
     n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s|
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 4573878ede..207cb0580d 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -201,4 +201,6 @@ namespace :admin do
       end
     end
   end
+
+  resources :software_updates, only: [:index]
 end
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 12c45c22a1..f1ba5651dd 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -58,3 +58,7 @@
       interval: 1 minute
       class: Scheduler::SuspendedUserCleanupScheduler
       queue: scheduler
+    software_update_check_scheduler:
+      interval: 30 minutes
+      class: Scheduler::SoftwareUpdateCheckScheduler
+      queue: scheduler
diff --git a/db/migrate/20230822081029_create_software_updates.rb b/db/migrate/20230822081029_create_software_updates.rb
new file mode 100644
index 0000000000..146d5d3037
--- /dev/null
+++ b/db/migrate/20230822081029_create_software_updates.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateSoftwareUpdates < ActiveRecord::Migration[7.0]
+  def change
+    create_table :software_updates do |t|
+      t.string :version, null: false
+      t.boolean :urgent, default: false, null: false
+      t.integer :type, default: 0, null: false
+      t.string :release_notes, default: '', null: false
+
+      t.timestamps
+    end
+
+    add_index :software_updates, :version, unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8b758fc7df..c861069420 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do
+ActiveRecord::Schema[7.0].define(version: 2023_08_22_081029) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
 
@@ -903,6 +903,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do
     t.index ["var"], name: "index_site_uploads_on_var", unique: true
   end
 
+  create_table "software_updates", force: :cascade do |t|
+    t.string "version", null: false
+    t.boolean "urgent", default: false, null: false
+    t.integer "type", default: 0, null: false
+    t.string "release_notes", default: "", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["version"], name: "index_software_updates_on_version", unique: true
+  end
+
   create_table "status_edits", force: :cascade do |t|
     t.bigint "status_id", null: false
     t.bigint "account_id"
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index c542d5d49a..65f90f93fd 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -39,6 +39,10 @@ module Mastodon
       components.join
     end
 
+    def gem_version
+      @gem_version ||= Gem::Version.new(to_s.split('+')[0])
+    end
+
     def repository
       ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon')
     end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 010caaf8ea..f68d1cf1f8 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -424,6 +424,10 @@ namespace :mastodon do
         end
       end
 
+      prompt.say "\n"
+
+      env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true)
+
       prompt.say "\n"
       prompt.say 'This configuration will be written to .env.production'
 
diff --git a/spec/fabricators/software_update_fabricator.rb b/spec/fabricators/software_update_fabricator.rb
new file mode 100644
index 0000000000..622fff66e8
--- /dev/null
+++ b/spec/fabricators/software_update_fabricator.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Fabricator(:software_update) do
+  version '99.99.99'
+  urgent false
+  type 'patch'
+end
diff --git a/spec/features/admin/software_updates_spec.rb b/spec/features/admin/software_updates_spec.rb
new file mode 100644
index 0000000000..4a635d1a79
--- /dev/null
+++ b/spec/features/admin/software_updates_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'finding software updates through the admin interface' do
+  before do
+    Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true, release_notes: 'https://github.com/mastodon/mastodon/releases/v99')
+
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Owner')), scope: :user
+  end
+
+  it 'shows a link to the software updates page, which links to release notes' do
+    visit settings_profile_path
+    click_on I18n.t('admin.critical_update_pending')
+
+    expect(page).to have_title(I18n.t('admin.software_updates.title'))
+
+    expect(page).to have_content('99.99.99')
+
+    click_on I18n.t('admin.software_updates.release_notes')
+    expect(page).to have_current_path('https://github.com/mastodon/mastodon/releases/v99', url: true)
+  end
+end
diff --git a/spec/lib/admin/system_check/software_version_check_spec.rb b/spec/lib/admin/system_check/software_version_check_spec.rb
new file mode 100644
index 0000000000..de4335fc51
--- /dev/null
+++ b/spec/lib/admin/system_check/software_version_check_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::SystemCheck::SoftwareVersionCheck do
+  include RoutingHelper
+
+  subject(:check) { described_class.new(user) }
+
+  let(:user) { Fabricate(:user) }
+
+  describe 'skip?' do
+    context 'when user cannot view devops' do
+      before { allow(user).to receive(:can?).with(:view_devops).and_return(false) }
+
+      it 'returns true' do
+        expect(check.skip?).to be true
+      end
+    end
+
+    context 'when user can view devops' do
+      before { allow(user).to receive(:can?).with(:view_devops).and_return(true) }
+
+      it 'returns false' do
+        expect(check.skip?).to be false
+      end
+
+      context 'when checks are disabled' do
+        around do |example|
+          ClimateControl.modify UPDATE_CHECK_URL: '' do
+            example.run
+          end
+        end
+
+        it 'returns true' do
+          expect(check.skip?).to be true
+        end
+      end
+    end
+  end
+
+  describe 'pass?' do
+    context 'when there is no known update' do
+      it 'returns true' do
+        expect(check.pass?).to be true
+      end
+    end
+
+    context 'when there is a non-urgent major release' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: false)
+      end
+
+      it 'returns true' do
+        expect(check.pass?).to be true
+      end
+    end
+
+    context 'when there is an urgent major release' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true)
+      end
+
+      it 'returns false' do
+        expect(check.pass?).to be false
+      end
+    end
+
+    context 'when there is an urgent minor release' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'minor', urgent: true)
+      end
+
+      it 'returns false' do
+        expect(check.pass?).to be false
+      end
+    end
+
+    context 'when there is an urgent patch release' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true)
+      end
+
+      it 'returns false' do
+        expect(check.pass?).to be false
+      end
+    end
+
+    context 'when there is a non-urgent patch release' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false)
+      end
+
+      it 'returns false' do
+        expect(check.pass?).to be false
+      end
+    end
+  end
+
+  describe 'message' do
+    context 'when there is a non-urgent patch release pending' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false)
+      end
+
+      it 'sends class name symbol to message instance' do
+        allow(Admin::SystemCheck::Message).to receive(:new)
+          .with(:software_version_patch_check, anything, anything)
+
+        check.message
+
+        expect(Admin::SystemCheck::Message).to have_received(:new)
+          .with(:software_version_patch_check, nil, admin_software_updates_path)
+      end
+    end
+
+    context 'when there is an urgent patch release pending' do
+      before do
+        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true)
+      end
+
+      it 'sends class name symbol to message instance' do
+        allow(Admin::SystemCheck::Message).to receive(:new)
+          .with(:software_version_critical_check, anything, anything, anything)
+
+        check.message
+
+        expect(Admin::SystemCheck::Message).to have_received(:new)
+          .with(:software_version_critical_check, nil, admin_software_updates_path, true)
+      end
+    end
+  end
+end
diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb
index 9123804a48..423dce88ab 100644
--- a/spec/mailers/admin_mailer_spec.rb
+++ b/spec/mailers/admin_mailer_spec.rb
@@ -85,4 +85,46 @@ RSpec.describe AdminMailer do
       expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly'
     end
   end
+
+  describe '.new_software_updates' do
+    let(:recipient) { Fabricate(:account, username: 'Bob') }
+    let(:mail) { described_class.with(recipient: recipient).new_software_updates }
+
+    before do
+      recipient.user.update(locale: :en)
+    end
+
+    it 'renders the headers' do
+      expect(mail.subject).to eq('New Mastodon versions are available for cb6e6126.ngrok.io!')
+      expect(mail.to).to eq [recipient.user_email]
+      expect(mail.from).to eq ['notifications@localhost']
+    end
+
+    it 'renders the body' do
+      expect(mail.body.encoded).to match 'New Mastodon versions have been released, you may want to update!'
+    end
+  end
+
+  describe '.new_critical_software_updates' do
+    let(:recipient) { Fabricate(:account, username: 'Bob') }
+    let(:mail) { described_class.with(recipient: recipient).new_critical_software_updates }
+
+    before do
+      recipient.user.update(locale: :en)
+    end
+
+    it 'renders the headers', :aggregate_failures do
+      expect(mail.subject).to eq('Critical Mastodon updates are available for cb6e6126.ngrok.io!')
+      expect(mail.to).to eq [recipient.user_email]
+      expect(mail.from).to eq ['notifications@localhost']
+
+      expect(mail['Importance'].value).to eq 'high'
+      expect(mail['Priority'].value).to eq 'urgent'
+      expect(mail['X-Priority'].value).to eq '1'
+    end
+
+    it 'renders the body' do
+      expect(mail.body.encoded).to match 'New critical versions of Mastodon have been released, you may want to update as soon as possible!'
+    end
+  end
 end
diff --git a/spec/models/software_update_spec.rb b/spec/models/software_update_spec.rb
new file mode 100644
index 0000000000..0a494b0c4c
--- /dev/null
+++ b/spec/models/software_update_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe SoftwareUpdate do
+  describe '.pending_to_a' do
+    before do
+      allow(Mastodon::Version).to receive(:gem_version).and_return(Gem::Version.new(mastodon_version))
+
+      Fabricate(:software_update, version: '3.4.42', type: 'patch', urgent: true)
+      Fabricate(:software_update, version: '3.5.0', type: 'minor', urgent: false)
+      Fabricate(:software_update, version: '4.2.0', type: 'major', urgent: false)
+    end
+
+    context 'when the Mastodon version is an outdated release' do
+      let(:mastodon_version) { '3.4.0' }
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('3.4.42', '3.5.0', '4.2.0')
+      end
+    end
+
+    context 'when the Mastodon version is more recent than anything last returned by the server' do
+      let(:mastodon_version) { '5.0.0' }
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to eq []
+      end
+    end
+
+    context 'when the Mastodon version is an outdated nightly' do
+      let(:mastodon_version) { '4.3.0-nightly.2023-09-10' }
+
+      before do
+        Fabricate(:software_update, version: '4.3.0-nightly.2023-09-12', type: 'major', urgent: true)
+      end
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-nightly.2023-09-12')
+      end
+    end
+
+    context 'when the Mastodon version is a very outdated nightly' do
+      let(:mastodon_version) { '4.2.0-nightly.2023-07-10' }
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.2.0')
+      end
+    end
+
+    context 'when the Mastodon version is an outdated dev version' do
+      let(:mastodon_version) { '4.3.0-0.dev.0' }
+
+      before do
+        Fabricate(:software_update, version: '4.3.0-0.dev.2', type: 'major', urgent: true)
+      end
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-0.dev.2')
+      end
+    end
+
+    context 'when the Mastodon version is an outdated beta version' do
+      let(:mastodon_version) { '4.3.0-beta1' }
+
+      before do
+        Fabricate(:software_update, version: '4.3.0-beta2', type: 'major', urgent: true)
+      end
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-beta2')
+      end
+    end
+
+    context 'when the Mastodon version is an outdated beta version and there is a rc' do
+      let(:mastodon_version) { '4.3.0-beta1' }
+
+      before do
+        Fabricate(:software_update, version: '4.3.0-rc1', type: 'major', urgent: true)
+      end
+
+      it 'returns the expected versions' do
+        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-rc1')
+      end
+    end
+  end
+end
diff --git a/spec/policies/software_update_policy_spec.rb b/spec/policies/software_update_policy_spec.rb
new file mode 100644
index 0000000000..e19ba61612
--- /dev/null
+++ b/spec/policies/software_update_policy_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'pundit/rspec'
+
+RSpec.describe SoftwareUpdatePolicy do
+  subject { described_class }
+
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Owner')).account }
+  let(:john)    { Fabricate(:account) }
+
+  permissions :index? do
+    context 'when owner' do
+      it 'permits' do
+        expect(subject).to permit(admin, SoftwareUpdate)
+      end
+    end
+
+    context 'when not owner' do
+      it 'denies' do
+        expect(subject).to_not permit(john, SoftwareUpdate)
+      end
+    end
+  end
+end
diff --git a/spec/services/software_update_check_service_spec.rb b/spec/services/software_update_check_service_spec.rb
new file mode 100644
index 0000000000..c8821348ac
--- /dev/null
+++ b/spec/services/software_update_check_service_spec.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe SoftwareUpdateCheckService, type: :service do
+  subject { described_class.new }
+
+  shared_examples 'when the feature is enabled' do
+    let(:full_update_check_url) { "#{update_check_url}?version=#{Mastodon::Version.to_s.split('+')[0]}" }
+
+    let(:devops_role)     { Fabricate(:user_role, name: 'DevOps', permissions: UserRole::FLAGS[:view_devops]) }
+    let(:owner_user)      { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) }
+    let(:old_devops_user) { Fabricate(:user) }
+    let(:none_user)       { Fabricate(:user, role: devops_role) }
+    let(:patch_user)      { Fabricate(:user, role: devops_role) }
+    let(:critical_user)   { Fabricate(:user, role: devops_role) }
+
+    around do |example|
+      queue_adapter = ActiveJob::Base.queue_adapter
+      ActiveJob::Base.queue_adapter = :test
+
+      example.run
+
+      ActiveJob::Base.queue_adapter = queue_adapter
+    end
+
+    before do
+      Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false)
+      Fabricate(:software_update, version: '42.13.12', type: 'major', urgent: false)
+
+      owner_user.settings.update('notification_emails.software_updates': 'all')
+      owner_user.save!
+
+      old_devops_user.settings.update('notification_emails.software_updates': 'all')
+      old_devops_user.save!
+
+      none_user.settings.update('notification_emails.software_updates': 'none')
+      none_user.save!
+
+      patch_user.settings.update('notification_emails.software_updates': 'patch')
+      patch_user.save!
+
+      critical_user.settings.update('notification_emails.software_updates': 'critical')
+      critical_user.save!
+    end
+
+    context 'when the update server errors out' do
+      before do
+        stub_request(:get, full_update_check_url).to_return(status: 404)
+      end
+
+      it 'deletes outdated update records but keeps valid update records' do
+        expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['42.13.12'])
+      end
+    end
+
+    context 'when the server returns new versions' do
+      let(:server_json) do
+        {
+          updatesAvailable: [
+            {
+              version: '4.2.1',
+              urgent: false,
+              type: 'patch',
+              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.2.1',
+            },
+            {
+              version: '4.3.0',
+              urgent: false,
+              type: 'minor',
+              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.3.0',
+            },
+            {
+              version: '5.0.0',
+              urgent: false,
+              type: 'minor',
+              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0',
+            },
+          ],
+        }
+      end
+
+      before do
+        stub_request(:get, full_update_check_url).to_return(body: Oj.dump(server_json))
+      end
+
+      it 'updates the list of known updates' do
+        expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['4.2.1', '4.3.0', '5.0.0'])
+      end
+
+      context 'when no update is urgent' do
+        it 'sends e-mail notifications according to settings', :aggregate_failures do
+          expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_software_updates)
+            .with(hash_including(params: { recipient: owner_user.account })).once
+            .and(have_enqueued_mail(AdminMailer, :new_software_updates).with(hash_including(params: { recipient: patch_user.account })).once)
+            .and(have_enqueued_mail.at_most(2))
+        end
+      end
+
+      context 'when an update is urgent' do
+        let(:server_json) do
+          {
+            updatesAvailable: [
+              {
+                version: '5.0.0',
+                urgent: true,
+                type: 'minor',
+                releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0',
+              },
+            ],
+          }
+        end
+
+        it 'sends e-mail notifications according to settings', :aggregate_failures do
+          expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_critical_software_updates)
+            .with(hash_including(params: { recipient: owner_user.account })).once
+            .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: patch_user.account })).once)
+            .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: critical_user.account })).once)
+            .and(have_enqueued_mail.at_most(3))
+        end
+      end
+    end
+  end
+
+  context 'when update checking is disabled' do
+    around do |example|
+      ClimateControl.modify UPDATE_CHECK_URL: '' do
+        example.run
+      end
+    end
+
+    before do
+      Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false)
+    end
+
+    it 'deletes outdated update records' do
+      expect { subject.call }.to change(SoftwareUpdate, :count).from(1).to(0)
+    end
+  end
+
+  context 'when using the default update checking API' do
+    let(:update_check_url) { 'https://api.joinmastodon.org/update-check' }
+
+    it_behaves_like 'when the feature is enabled'
+  end
+
+  context 'when using a custom update check URL' do
+    let(:update_check_url) { 'https://api.example.com/update_check' }
+
+    around do |example|
+      ClimateControl.modify UPDATE_CHECK_URL: 'https://api.example.com/update_check' do
+        example.run
+      end
+    end
+
+    it_behaves_like 'when the feature is enabled'
+  end
+end
diff --git a/spec/workers/scheduler/software_update_check_scheduler_spec.rb b/spec/workers/scheduler/software_update_check_scheduler_spec.rb
new file mode 100644
index 0000000000..f596c0a1ec
--- /dev/null
+++ b/spec/workers/scheduler/software_update_check_scheduler_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Scheduler::SoftwareUpdateCheckScheduler do
+  subject { described_class.new }
+
+  describe 'perform' do
+    let(:service_double) { instance_double(SoftwareUpdateCheckService, call: nil) }
+
+    before do
+      allow(SoftwareUpdateCheckService).to receive(:new).and_return(service_double)
+    end
+
+    it 'calls SoftwareUpdateCheckService' do
+      subject.perform
+      expect(service_double).to have_received(:call)
+    end
+  end
+end

From 05093266e6e3c54f9096da9cdcdafdc83703c578 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Sat, 2 Sep 2023 09:02:44 +0200
Subject: [PATCH 2/2] Fix some video encoding failing due to uneven dimensions
 (#26766)

---
 app/models/media_attachment.rb | 1 +
 1 file changed, 1 insertion(+)

diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 984f4252a1..f0b072e23f 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -102,6 +102,7 @@ class MediaAttachment < ApplicationRecord
         'preset' => 'veryfast',
         'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
         'pix_fmt' => 'yuv420p', # Ensure color space for cross-browser compatibility
+        'vf' => 'crop=floor(iw/2)*2:floor(ih/2)*2', # h264 requires width and height to be even. Crop instead of scale to avoid blurring
         'c:v' => 'h264',
         'c:a' => 'aac',
         'b:a' => '192k',