diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml
index d2c3eea197..8adfcda841 100644
--- a/.github/workflows/build-nightly.yml
+++ b/.github/workflows/build-nightly.yml
@@ -11,6 +11,7 @@ permissions:
 jobs:
   compute-suffix:
     runs-on: ubuntu-latest
+    if: github.repository == 'mastodon/mastodon'
     steps:
       - id: version_vars
         env:
diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml
index dc6fd874d1..dbd19a8d03 100644
--- a/.github/workflows/crowdin-download.yml
+++ b/.github/workflows/crowdin-download.yml
@@ -11,6 +11,7 @@ permissions:
 jobs:
   download-translations:
     runs-on: ubuntu-latest
+    if: github.repository == 'mastodon/mastodon'
 
     steps:
       - name: Checkout
diff --git a/Gemfile b/Gemfile
index 449b0a9203..6cfdcfa5cd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -106,6 +106,9 @@ group :test do
   # Used to split testing into chunks in CI
   gem 'rspec_chunked', '~> 0.6'
 
+  # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
+  gem 'rspec-github', '~> 2.4', require: false
+
   # RSpec progress bar formatter
   gem 'fuubar', '~> 2.5'
 
diff --git a/Gemfile.lock b/Gemfile.lock
index ce21bc0a20..29cdc8aa4d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -513,7 +513,7 @@ GEM
       premailer (~> 1.7, >= 1.7.9)
     private_address_check (0.5.0)
     public_suffix (5.0.3)
-    puma (6.3.1)
+    puma (6.4.0)
       nio4r (~> 2.0)
     pundit (2.3.0)
       activesupport (>= 3.0.0)
@@ -602,6 +602,8 @@ GEM
     rspec-expectations (3.12.3)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.12.0)
+    rspec-github (2.4.0)
+      rspec-core (~> 3.0)
     rspec-mocks (3.12.5)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.12.0)
@@ -634,11 +636,11 @@ GEM
       unicode-display_width (>= 2.4.0, < 3.0)
     rubocop-ast (1.29.0)
       parser (>= 3.2.1.0)
-    rubocop-capybara (2.18.0)
+    rubocop-capybara (2.19.0)
       rubocop (~> 1.41)
     rubocop-factory_bot (2.23.1)
       rubocop (~> 1.33)
-    rubocop-performance (1.19.0)
+    rubocop-performance (1.19.1)
       rubocop (>= 1.7.0, < 2.0)
       rubocop-ast (>= 0.4.0)
     rubocop-rails (2.20.2)
@@ -885,6 +887,7 @@ DEPENDENCIES
   redis (~> 4.5)
   redis-namespace (~> 1.10)
   rqrcode (~> 2.2)
+  rspec-github (~> 2.4)
   rspec-rails (~> 6.0)
   rspec-sidekiq (~> 4.0)
   rspec_chunked (~> 0.6)
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
index dfbfa98f5e..2525df7793 100644
--- a/app/javascript/mastodon/features/compose/components/search.jsx
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -64,7 +64,7 @@ class Search extends PureComponent {
     { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
     { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
     { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
-    { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
+    { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
   ];
 
   setRef = c => {
diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb
index a45ae3d09b..927495eace 100644
--- a/app/lib/search_query_transformer.rb
+++ b/app/lib/search_query_transformer.rb
@@ -58,6 +58,8 @@ class SearchQueryTransformer < Parslet::Transform
       case @flags['in']
       when 'library'
         [StatusesIndex]
+      when 'public'
+        [PublicStatusesIndex]
       else
         [PublicStatusesIndex, StatusesIndex]
       end
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 02ce23a075..9f85ccb6a4 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -33,30 +33,6 @@
       ],
       "note": ""
     },
-    {
-      "warning_type": "Denial of Service",
-      "warning_code": 76,
-      "fingerprint": "7b6abba5699755348e7ee82a4694bfbf574b41c7cce2d0db0f7c11ae3f983c72",
-      "check_name": "RegexDoS",
-      "message": "Model attribute used in regular expression",
-      "file": "lib/mastodon/cli/domains.rb",
-      "line": 128,
-      "link": "https://brakemanscanner.org/docs/warning_types/denial_of_service/",
-      "code": "/\\.?(#{DomainBlock.where(:severity => 1).pluck(:domain).map do\n Regexp.escape(domain)\n end.join(\"|\")})$/",
-      "render_path": null,
-      "location": {
-        "type": "method",
-        "class": "Mastodon::CLI::Domains",
-        "method": "crawl"
-      },
-      "user_input": "DomainBlock.where(:severity => 1).pluck(:domain)",
-      "confidence": "Weak",
-      "cwe_id": [
-        20,
-        185
-      ],
-      "note": ""
-    },
     {
       "warning_type": "Cross-Site Scripting",
       "warning_code": 4,
diff --git a/docker-compose.yml b/docker-compose.yml
index d19f278f75..bcfa4c85f1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -111,7 +111,7 @@ services:
       test: ['CMD-SHELL', "ps aux | grep '[s]idekiq\ 6' || false"]
 
   ## Uncomment to enable federation with tor instances along with adding the following ENV variables
-  ## http_proxy=http://privoxy:8118
+  ## http_hidden_proxy=http://privoxy:8118
   ## ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
   # tor:
   #   image: sirboops/tor
diff --git a/lib/mastodon/cli/domains.rb b/lib/mastodon/cli/domains.rb
index d17b253681..329f171672 100644
--- a/lib/mastodon/cli/domains.rb
+++ b/lib/mastodon/cli/domains.rb
@@ -125,7 +125,7 @@ module Mastodon::CLI
       failed          = Concurrent::AtomicFixnum.new(0)
       start_at        = Time.now.to_f
       seed            = start ? [start] : Instance.pluck(:domain)
-      blocked_domains = /\.?(#{DomainBlock.where(severity: 1).pluck(:domain).map { |domain| Regexp.escape(domain) }.join('|')})$/
+      blocked_domains = /\.?(#{Regexp.union(domain_block_suspended_domains).source})$/
       progress        = create_progress_bar
 
       pool = Concurrent::ThreadPoolExecutor.new(min_threads: 0, max_threads: options[:concurrency], idletime: 10, auto_terminate: true, max_queue: 0)
@@ -189,6 +189,10 @@ module Mastodon::CLI
 
     private
 
+    def domain_block_suspended_domains
+      DomainBlock.suspend.pluck(:domain)
+    end
+
     def stats_to_summary(stats, processed, failed, start_at)
       stats.compact!
 
diff --git a/spec/features/admin/accounts_spec.rb b/spec/features/admin/accounts_spec.rb
index 6d7bab1844..ad9c51485a 100644
--- a/spec/features/admin/accounts_spec.rb
+++ b/spec/features/admin/accounts_spec.rb
@@ -22,7 +22,7 @@ describe 'Admin::Accounts' do
 
     context 'without selecting any accounts' do
       it 'displays a notice about account selection' do
-        click_on button_for_suspend
+        click_button button_for_suspend
 
         expect(page).to have_content(selection_error_text)
       end
@@ -32,7 +32,7 @@ describe 'Admin::Accounts' do
       it 'suspends the account' do
         batch_checkbox_for(approved_user_account).check
 
-        click_on button_for_suspend
+        click_button button_for_suspend
 
         expect(approved_user_account.reload).to be_suspended
       end
@@ -42,7 +42,7 @@ describe 'Admin::Accounts' do
       it 'approves the account user' do
         batch_checkbox_for(unapproved_user_account).check
 
-        click_on button_for_approve
+        click_button button_for_approve
 
         expect(unapproved_user_account.reload.user).to be_approved
       end
@@ -52,7 +52,7 @@ describe 'Admin::Accounts' do
       it 'rejects and removes the account' do
         batch_checkbox_for(unapproved_user_account).check
 
-        click_on button_for_reject
+        click_button button_for_reject
 
         expect { unapproved_user_account.reload }.to raise_error(ActiveRecord::RecordNotFound)
       end
diff --git a/spec/features/admin/custom_emojis_spec.rb b/spec/features/admin/custom_emojis_spec.rb
index 8a8b6efcd1..3fea8f06fe 100644
--- a/spec/features/admin/custom_emojis_spec.rb
+++ b/spec/features/admin/custom_emojis_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::CustomEmojis' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_enable
+        click_button button_for_enable
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/domain_blocks_spec.rb b/spec/features/admin/domain_blocks_spec.rb
index 4672c1e1a9..0d7b90c21c 100644
--- a/spec/features/admin/domain_blocks_spec.rb
+++ b/spec/features/admin/domain_blocks_spec.rb
@@ -13,7 +13,7 @@ describe 'blocking domains through the moderation interface' do
 
       fill_in 'domain_block_domain', with: 'example.com'
       select I18n.t('admin.domain_blocks.new.severity.silence'), from: 'domain_block_severity'
-      click_on I18n.t('admin.domain_blocks.new.create')
+      click_button I18n.t('admin.domain_blocks.new.create')
 
       expect(DomainBlock.exists?(domain: 'example.com', severity: 'silence')).to be true
     end
@@ -25,13 +25,13 @@ describe 'blocking domains through the moderation interface' do
 
       fill_in 'domain_block_domain', with: 'example.com'
       select I18n.t('admin.domain_blocks.new.severity.suspend'), from: 'domain_block_severity'
-      click_on I18n.t('admin.domain_blocks.new.create')
+      click_button I18n.t('admin.domain_blocks.new.create')
 
       # It presents a confirmation screen
       expect(page).to have_title(I18n.t('admin.domain_blocks.confirm_suspension.title', domain: 'example.com'))
 
       # Confirming creates a block
-      click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
+      click_button I18n.t('admin.domain_blocks.confirm_suspension.confirm')
 
       expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be true
     end
@@ -45,13 +45,13 @@ describe 'blocking domains through the moderation interface' do
 
       fill_in 'domain_block_domain', with: 'example.com'
       select I18n.t('admin.domain_blocks.new.severity.suspend'), from: 'domain_block_severity'
-      click_on I18n.t('admin.domain_blocks.new.create')
+      click_button I18n.t('admin.domain_blocks.new.create')
 
       # It presents a confirmation screen
       expect(page).to have_title(I18n.t('admin.domain_blocks.confirm_suspension.title', domain: 'example.com'))
 
       # Confirming updates the block
-      click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
+      click_button I18n.t('admin.domain_blocks.confirm_suspension.confirm')
 
       expect(domain_block.reload.severity).to eq 'suspend'
     end
@@ -65,13 +65,13 @@ describe 'blocking domains through the moderation interface' do
 
       fill_in 'domain_block_domain', with: 'subdomain.example.com'
       select I18n.t('admin.domain_blocks.new.severity.suspend'), from: 'domain_block_severity'
-      click_on I18n.t('admin.domain_blocks.new.create')
+      click_button I18n.t('admin.domain_blocks.new.create')
 
       # It presents a confirmation screen
       expect(page).to have_title(I18n.t('admin.domain_blocks.confirm_suspension.title', domain: 'subdomain.example.com'))
 
       # Confirming creates the block
-      click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
+      click_button I18n.t('admin.domain_blocks.confirm_suspension.confirm')
 
       expect(DomainBlock.where(domain: 'subdomain.example.com', severity: 'suspend')).to exist
 
@@ -88,13 +88,13 @@ describe 'blocking domains through the moderation interface' do
       visit edit_admin_domain_block_path(domain_block)
 
       select I18n.t('admin.domain_blocks.new.severity.suspend'), from: 'domain_block_severity'
-      click_on I18n.t('generic.save_changes')
+      click_button I18n.t('generic.save_changes')
 
       # It presents a confirmation screen
       expect(page).to have_title(I18n.t('admin.domain_blocks.confirm_suspension.title', domain: 'example.com'))
 
       # Confirming updates the block
-      click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
+      click_button I18n.t('admin.domain_blocks.confirm_suspension.confirm')
 
       expect(domain_block.reload.severity).to eq 'suspend'
     end
diff --git a/spec/features/admin/email_domain_blocks_spec.rb b/spec/features/admin/email_domain_blocks_spec.rb
index 14959cbe74..80efe72e95 100644
--- a/spec/features/admin/email_domain_blocks_spec.rb
+++ b/spec/features/admin/email_domain_blocks_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::EmailDomainBlocks' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_delete
+        click_button button_for_delete
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/ip_blocks_spec.rb b/spec/features/admin/ip_blocks_spec.rb
index c9b16f6f78..465c889190 100644
--- a/spec/features/admin/ip_blocks_spec.rb
+++ b/spec/features/admin/ip_blocks_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::IpBlocks' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_delete
+        click_button button_for_delete
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/software_updates_spec.rb b/spec/features/admin/software_updates_spec.rb
index 4a635d1a79..a2373d35a6 100644
--- a/spec/features/admin/software_updates_spec.rb
+++ b/spec/features/admin/software_updates_spec.rb
@@ -11,13 +11,13 @@ describe 'finding software updates through the admin interface' do
 
   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')
+    click_link 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')
+    click_link 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/features/admin/statuses_spec.rb b/spec/features/admin/statuses_spec.rb
index 531d0de953..a21c901a92 100644
--- a/spec/features/admin/statuses_spec.rb
+++ b/spec/features/admin/statuses_spec.rb
@@ -17,7 +17,7 @@ describe 'Admin::Statuses' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_report
+        click_button button_for_report
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/trends/links/preview_card_providers_spec.rb b/spec/features/admin/trends/links/preview_card_providers_spec.rb
index dca89117b1..cf9796abf3 100644
--- a/spec/features/admin/trends/links/preview_card_providers_spec.rb
+++ b/spec/features/admin/trends/links/preview_card_providers_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::Trends::Links::PreviewCardProviders' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_allow
+        click_button button_for_allow
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/trends/links_spec.rb b/spec/features/admin/trends/links_spec.rb
index 99638bc069..8b1b991a5a 100644
--- a/spec/features/admin/trends/links_spec.rb
+++ b/spec/features/admin/trends/links_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::Trends::Links' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_allow
+        click_button button_for_allow
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/trends/statuses_spec.rb b/spec/features/admin/trends/statuses_spec.rb
index 779a15d38f..a578ab0559 100644
--- a/spec/features/admin/trends/statuses_spec.rb
+++ b/spec/features/admin/trends/statuses_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::Trends::Statuses' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_allow
+        click_button button_for_allow
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/admin/trends/tags_spec.rb b/spec/features/admin/trends/tags_spec.rb
index 52e49c3a5d..7502bc8c6f 100644
--- a/spec/features/admin/trends/tags_spec.rb
+++ b/spec/features/admin/trends/tags_spec.rb
@@ -16,7 +16,7 @@ describe 'Admin::Trends::Tags' do
 
     context 'without selecting any records' do
       it 'displays a notice about selection' do
-        click_on button_for_allow
+        click_button button_for_allow
 
         expect(page).to have_content(selection_error_text)
       end
diff --git a/spec/features/captcha_spec.rb b/spec/features/captcha_spec.rb
index db89ff3e61..6ccf066208 100644
--- a/spec/features/captcha_spec.rb
+++ b/spec/features/captcha_spec.rb
@@ -27,7 +27,7 @@ describe 'email confirmation flow when captcha is enabled' do
       expect(user.reload.confirmed?).to be false
 
       # It redirects to app and confirms user
-      click_on I18n.t('challenge.confirm')
+      click_button I18n.t('challenge.confirm')
       expect(user.reload.confirmed?).to be true
       expect(page).to have_current_path(/\A#{client_app.confirmation_redirect_uri}/, url: true)
     end
diff --git a/spec/features/log_in_spec.rb b/spec/features/log_in_spec.rb
index c64e19d2b7..7e5196aba9 100644
--- a/spec/features/log_in_spec.rb
+++ b/spec/features/log_in_spec.rb
@@ -19,7 +19,7 @@ describe 'Log in' do
   it 'A valid email and password user is able to log in' do
     fill_in 'user_email', with: email
     fill_in 'user_password', with: password
-    click_on I18n.t('auth.login')
+    click_button I18n.t('auth.login')
 
     expect(subject).to have_css('div.app-holder')
   end
@@ -27,7 +27,7 @@ describe 'Log in' do
   it 'A invalid email and password user is not able to log in' do
     fill_in 'user_email', with: 'invalid_email'
     fill_in 'user_password', with: 'invalid_password'
-    click_on I18n.t('auth.login')
+    click_button I18n.t('auth.login')
 
     expect(subject).to have_css('.flash-message', text: failure_message('invalid'))
   end
@@ -38,7 +38,7 @@ describe 'Log in' do
     it 'A unconfirmed user is able to log in' do
       fill_in 'user_email', with: email
       fill_in 'user_password', with: password
-      click_on I18n.t('auth.login')
+      click_button I18n.t('auth.login')
 
       expect(subject).to have_css('div.admin-wrapper')
     end
diff --git a/spec/features/oauth_spec.rb b/spec/features/oauth_spec.rb
index 967956cc8e..0e612b56a5 100644
--- a/spec/features/oauth_spec.rb
+++ b/spec/features/oauth_spec.rb
@@ -20,7 +20,7 @@ describe 'Using OAuth from an external app' do
       expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.authorize'))
 
       # Upon authorizing, it redirects to the apps' callback URL
-      click_on I18n.t('doorkeeper.authorizations.buttons.authorize')
+      click_button I18n.t('doorkeeper.authorizations.buttons.authorize')
       expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
       # It grants the app access to the account
@@ -35,7 +35,7 @@ describe 'Using OAuth from an external app' do
       expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.deny'))
 
       # Upon denying, it redirects to the apps' callback URL
-      click_on I18n.t('doorkeeper.authorizations.buttons.deny')
+      click_button I18n.t('doorkeeper.authorizations.buttons.deny')
       expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
       # It does not grant the app access to the account
@@ -63,17 +63,17 @@ describe 'Using OAuth from an external app' do
       # Failing to log-in presents the form again
       fill_in 'user_email', with: email
       fill_in 'user_password', with: 'wrong password'
-      click_on I18n.t('auth.login')
+      click_button I18n.t('auth.login')
       expect(page).to have_content(I18n.t('auth.login'))
 
       # Logging in redirects to an authorization page
       fill_in 'user_email', with: email
       fill_in 'user_password', with: password
-      click_on I18n.t('auth.login')
+      click_button I18n.t('auth.login')
       expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.authorize'))
 
       # Upon authorizing, it redirects to the apps' callback URL
-      click_on I18n.t('doorkeeper.authorizations.buttons.authorize')
+      click_button I18n.t('doorkeeper.authorizations.buttons.authorize')
       expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
       # It grants the app access to the account
@@ -90,17 +90,17 @@ describe 'Using OAuth from an external app' do
       # Failing to log-in presents the form again
       fill_in 'user_email', with: email
       fill_in 'user_password', with: 'wrong password'
-      click_on I18n.t('auth.login')
+      click_button I18n.t('auth.login')
       expect(page).to have_content(I18n.t('auth.login'))
 
       # Logging in redirects to an authorization page
       fill_in 'user_email', with: email
       fill_in 'user_password', with: password
-      click_on I18n.t('auth.login')
+      click_button I18n.t('auth.login')
       expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.authorize'))
 
       # Upon denying, it redirects to the apps' callback URL
-      click_on I18n.t('doorkeeper.authorizations.buttons.deny')
+      click_button I18n.t('doorkeeper.authorizations.buttons.deny')
       expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
       # It does not grant the app access to the account
@@ -120,27 +120,27 @@ describe 'Using OAuth from an external app' do
         # Failing to log-in presents the form again
         fill_in 'user_email', with: email
         fill_in 'user_password', with: 'wrong password'
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('auth.login'))
 
         # Logging in redirects to a two-factor authentication page
         fill_in 'user_email', with: email
         fill_in 'user_password', with: password
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('simple_form.hints.sessions.otp'))
 
         # Filling in an incorrect two-factor authentication code presents the form again
         fill_in 'user_otp_attempt', with: 'wrong'
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('simple_form.hints.sessions.otp'))
 
         # Filling in the correct TOTP code redirects to an app authorization page
         fill_in 'user_otp_attempt', with: user.current_otp
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.authorize'))
 
         # Upon authorizing, it redirects to the apps' callback URL
-        click_on I18n.t('doorkeeper.authorizations.buttons.authorize')
+        click_button I18n.t('doorkeeper.authorizations.buttons.authorize')
         expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
         # It grants the app access to the account
@@ -157,27 +157,27 @@ describe 'Using OAuth from an external app' do
         # Failing to log-in presents the form again
         fill_in 'user_email', with: email
         fill_in 'user_password', with: 'wrong password'
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('auth.login'))
 
         # Logging in redirects to a two-factor authentication page
         fill_in 'user_email', with: email
         fill_in 'user_password', with: password
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('simple_form.hints.sessions.otp'))
 
         # Filling in an incorrect two-factor authentication code presents the form again
         fill_in 'user_otp_attempt', with: 'wrong'
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('simple_form.hints.sessions.otp'))
 
         # Filling in the correct TOTP code redirects to an app authorization page
         fill_in 'user_otp_attempt', with: user.current_otp
-        click_on I18n.t('auth.login')
+        click_button I18n.t('auth.login')
         expect(page).to have_content(I18n.t('doorkeeper.authorizations.buttons.authorize'))
 
         # Upon denying, it redirects to the apps' callback URL
-        click_on I18n.t('doorkeeper.authorizations.buttons.deny')
+        click_button I18n.t('doorkeeper.authorizations.buttons.deny')
         expect(page).to have_current_path(/\A#{client_app.redirect_uri}/, url: true)
 
         # It does not grant the app access to the account
diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb
index a263d673de..5ecea5ea16 100644
--- a/spec/lib/mastodon/cli/accounts_spec.rb
+++ b/spec/lib/mastodon/cli/accounts_spec.rb
@@ -6,6 +6,24 @@ require 'mastodon/cli/accounts'
 describe Mastodon::CLI::Accounts do
   let(:cli) { described_class.new }
 
+  # `parallelize_with_progress` cannot run in transactions, so instead,
+  # stub it with an alternative implementation that runs sequentially
+  # and can run in transactions.
+  def stub_parallelize_with_progress!
+    allow(cli).to receive(:parallelize_with_progress) do |scope, &block|
+      aggregate = 0
+      total = 0
+
+      scope.reorder(nil).find_each do |record|
+        value = block.call(record)
+        aggregate += value if value.is_a?(Integer)
+        total += 1
+      end
+
+      [total, aggregate]
+    end
+  end
+
   describe '.exit_on_failure?' do
     it 'returns true' do
       expect(described_class.exit_on_failure?).to be true
@@ -551,20 +569,15 @@ describe Mastodon::CLI::Accounts do
       let!(:follower_rony)    { Fabricate(:account, username: 'rony') }
       let!(:follower_charles) { Fabricate(:account, username: 'charles') }
       let(:follow_service)    { instance_double(FollowService, call: nil) }
-      let(:scope)             { Account.local.without_suspended }
 
       before do
-        allow(cli).to receive(:parallelize_with_progress).and_yield(follower_bob)
-                                                         .and_yield(follower_rony)
-                                                         .and_yield(follower_charles)
-                                                         .and_return([3, nil])
         allow(FollowService).to receive(:new).and_return(follow_service)
+        stub_parallelize_with_progress!
       end
 
       it 'makes all local accounts follow the target account' do
         cli.follow(target_account.username)
 
-        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
         expect(follow_service).to have_received(:call).with(follower_bob, target_account, any_args).once
         expect(follow_service).to have_received(:call).with(follower_rony, target_account, any_args).once
         expect(follow_service).to have_received(:call).with(follower_charles, target_account, any_args).once
@@ -572,7 +585,7 @@ describe Mastodon::CLI::Accounts do
 
       it 'displays a successful message' do
         expect { cli.follow(target_account.username) }.to output(
-          a_string_including('OK, followed target from 3 accounts')
+          a_string_including("OK, followed target from #{Account.local.count} accounts")
         ).to_stdout
       end
     end
@@ -592,26 +605,21 @@ describe Mastodon::CLI::Accounts do
 
     context 'when the given username is found' do
       let!(:target_account)  { Fabricate(:account) }
-      let!(:follower_chris)  { Fabricate(:account, username: 'chris') }
-      let!(:follower_rambo)  { Fabricate(:account, username: 'rambo') }
-      let!(:follower_ana)    { Fabricate(:account, username: 'ana') }
+      let!(:follower_chris)  { Fabricate(:account, username: 'chris', domain: nil) }
+      let!(:follower_rambo)  { Fabricate(:account, username: 'rambo', domain: nil) }
+      let!(:follower_ana)    { Fabricate(:account, username: 'ana', domain: nil) }
       let(:unfollow_service) { instance_double(UnfollowService, call: nil) }
-      let(:scope)            { target_account.followers.local }
 
       before do
         accounts = [follower_chris, follower_rambo, follower_ana]
-        accounts.each { |account| target_account.follow!(account) }
-        allow(cli).to receive(:parallelize_with_progress).and_yield(follower_chris)
-                                                         .and_yield(follower_rambo)
-                                                         .and_yield(follower_ana)
-                                                         .and_return([3, nil])
+        accounts.each { |account| account.follow!(target_account) }
         allow(UnfollowService).to receive(:new).and_return(unfollow_service)
+        stub_parallelize_with_progress!
       end
 
       it 'makes all local accounts unfollow the target account' do
         cli.unfollow(target_account.username)
 
-        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
         expect(unfollow_service).to have_received(:call).with(follower_chris, target_account).once
         expect(unfollow_service).to have_received(:call).with(follower_rambo, target_account).once
         expect(unfollow_service).to have_received(:call).with(follower_ana, target_account).once
@@ -671,6 +679,8 @@ describe Mastodon::CLI::Accounts do
       let(:scope)                       { Account.remote }
 
       before do
+        # TODO: we should be using `stub_parallelize_with_progress!` but
+        # this makes the assertions harder to write
         allow(cli).to receive(:parallelize_with_progress).and_yield(remote_account_example_com)
                                                          .and_yield(account_example_net)
                                                          .and_return([2, nil])
@@ -1112,26 +1122,19 @@ describe Mastodon::CLI::Accounts do
 
   describe '#cull' do
     let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
-    let!(:tom)                   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com') }
-    let!(:bob)                   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org') }
-    let!(:gon)                   { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net') }
-    let!(:ana)                   { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com') }
-    let!(:tales)                 { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net') }
+    let!(:tom)   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com', protocol: :activitypub) }
+    let!(:bob)   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org', protocol: :activitypub) }
+    let!(:gon)   { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net', protocol: :activitypub) }
+    let!(:ana)   { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com', protocol: :activitypub) }
+    let!(:tales) { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net', protocol: :activitypub) }
 
     before do
       allow(DeleteAccountService).to receive(:new).and_return(delete_account_service)
     end
 
     context 'when no domain is specified' do
-      let(:scope) { Account.remote.where(protocol: :activitypub).partitioned }
-
       before do
-        allow(cli).to receive(:parallelize_with_progress).and_yield(tom)
-                                                         .and_yield(bob)
-                                                         .and_yield(gon)
-                                                         .and_yield(ana)
-                                                         .and_yield(tales)
-                                                         .and_return([5, 3])
+        stub_parallelize_with_progress!
         stub_request(:head, 'https://example.org/users/bob').to_return(status: 404)
         stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
         stub_request(:head, 'https://example.net/users/tales').to_return(status: 200)
@@ -1140,7 +1143,6 @@ describe Mastodon::CLI::Accounts do
       it 'deletes all inactive remote accounts that longer exist in the origin server' do
         cli.cull
 
-        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
         expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
       end
@@ -1148,35 +1150,27 @@ describe Mastodon::CLI::Accounts do
       it 'does not delete any active remote account that still exists in the origin server' do
         cli.cull
 
-        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
         expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
         expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
         expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
       end
 
       it 'touches inactive remote accounts that have not been deleted' do
-        allow(tales).to receive(:touch)
-
-        cli.cull
-
-        expect(tales).to have_received(:touch).once
+        expect { cli.cull }.to(change { tales.reload.updated_at })
       end
 
       it 'displays the summary correctly' do
         expect { cli.cull }.to output(
-          a_string_including('Visited 5 accounts, removed 3')
+          a_string_including('Visited 5 accounts, removed 2')
         ).to_stdout
       end
     end
 
     context 'when a domain is specified' do
       let(:domain) { 'example.net' }
-      let(:scope)  { Account.remote.where(protocol: :activitypub, domain: domain).partitioned }
 
       before do
-        allow(cli).to receive(:parallelize_with_progress).and_yield(gon)
-                                                         .and_yield(tales)
-                                                         .and_return([2, 2])
+        stub_parallelize_with_progress!
         stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
         stub_request(:head, 'https://example.net/users/tales').to_return(status: 404)
       end
@@ -1184,13 +1178,12 @@ describe Mastodon::CLI::Accounts do
       it 'deletes inactive remote accounts that longer exist in the specified domain' do
         cli.cull(domain)
 
-        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
       end
 
       it 'displays the summary correctly' do
-        expect { cli.cull }.to output(
+        expect { cli.cull(domain) }.to output(
           a_string_including('Visited 2 accounts, removed 2')
         ).to_stdout
       end
@@ -1199,7 +1192,9 @@ describe Mastodon::CLI::Accounts do
     context 'when a domain is unavailable' do
       shared_examples 'an unavailable domain' do
         before do
-          allow(cli).to receive(:parallelize_with_progress).and_yield(tales).and_return([1, 0])
+          stub_parallelize_with_progress!
+          stub_request(:head, 'https://example.org/users/bob').to_return(status: 200)
+          stub_request(:head, 'https://example.net/users/gon').to_return(status: 200)
         end
 
         it 'skips accounts from the unavailable domain' do
@@ -1210,7 +1205,7 @@ describe Mastodon::CLI::Accounts do
 
         it 'displays the summary correctly' do
           expect { cli.cull }.to output(
-            a_string_including("Visited 1 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
+            a_string_including("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
           ).to_stdout
         end
       end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index b4c20545f5..4d3c234a0e 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -36,6 +36,12 @@ RSpec.configure do |config|
   config.after :suite do
     FileUtils.rm_rf(Dir[Rails.root.join('spec', 'test_files')])
   end
+
+  # Use the GitHub Annotations formatter for CI
+  if ENV['GITHUB_ACTIONS'] == 'true'
+    require 'rspec/github'
+    config.add_formatter RSpec::Github::Formatter
+  end
 end
 
 def body_as_json
diff --git a/spec/support/stories/profile_stories.rb b/spec/support/stories/profile_stories.rb
index 2b345ddef1..82667ca080 100644
--- a/spec/support/stories/profile_stories.rb
+++ b/spec/support/stories/profile_stories.rb
@@ -18,7 +18,7 @@ module ProfileStories
     visit new_user_session_path
     fill_in 'user_email', with: email
     fill_in 'user_password', with: password
-    click_on I18n.t('auth.login')
+    click_button I18n.t('auth.login')
   end
 
   def with_alice_as_local_user
diff --git a/spec/system/new_statuses_spec.rb b/spec/system/new_statuses_spec.rb
index 6faed6c808..244101f4d4 100644
--- a/spec/system/new_statuses_spec.rb
+++ b/spec/system/new_statuses_spec.rb
@@ -24,10 +24,10 @@ describe 'NewStatuses' do
 
     within('.compose-form') do
       fill_in "What's on your mind?", with: status_text
-      click_on 'Publish!'
+      click_button 'Publish!'
     end
 
-    expect(subject).to have_selector('.status__content__text', text: status_text)
+    expect(subject).to have_css('.status__content__text', text: status_text)
   end
 
   it 'can be posted again' do
@@ -37,9 +37,9 @@ describe 'NewStatuses' do
 
     within('.compose-form') do
       fill_in "What's on your mind?", with: status_text
-      click_on 'Publish!'
+      click_button 'Publish!'
     end
 
-    expect(subject).to have_selector('.status__content__text', text: status_text)
+    expect(subject).to have_css('.status__content__text', text: status_text)
   end
 end