Add a custom action on admin content page I want to add a custom action to the dropdown on the admin content page (/admin/content) as shown in the screenshot below using Drupal 9. The views module is enabled so that form is part of the view.

I have found that actions are now plugins so I should just need to create a new plugin for my custom action.
I've used the actions found in /core/lib/Drupal/Core/Action/Plugin/Action/ as an example for my new action so just created a new plugin that extends EntityActionBase add added the relevant methods.
I have a custom module called shop_scrape.
I created the file shop_scrape/src/Plugin/Action/ScrapeAction.php
That file contains:
&lt;?php

/**
 * @file
 * Contains \Drupal\shop_scrape\Plugin\Action\ScrapeAction.
 */

namespace Drupal\shop_scrape\Plugin\Action;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\Plugin\Action\EntityActionBase;
use Drupal\Core\Annotation\Action;
use Drupal\Core\Annotation\Translation;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Scrape a page.
 *
 * @Action(
 *   id = &quot;entity:scrape_action&quot;,
 *   label = @Translation(&quot;Scrape a page&quot;),
 *   deriver = &quot;Drupal\shop_scrape\Plugin\Action\EntityScrapeActionDeriver&quot;,
 * )
 */
class ScrapeAction extends EntityActionBase
{

  /**'
   * ScrapeAction constructor.
   * @param array $configuration
   * @param $plugin_id
   * @param $plugin_definition
   * @param EntityTypeManagerInterface $entity_type_manager
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager)
  {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition)
  {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container-&gt;get('entity_type.manager')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function executeMultiple(array $entities)
  {
    foreach ($entities as $entity) {
      \Drupal::messenger()-&gt;addMessage('Scrape ' . $entity-&gt;id());
    }
  }

  /**
   * {@inheritdoc}
   */
  public function execute($object = NULL)
  {
    $this-&gt;executeMultiple([$object]);
  }

  /**
   * {@inheritdoc}
   */
  public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE)
  {
    $access = AccessResult::allowed();
    return $return_as_object ? $access : $access-&gt;isAllowed();
  }

}

Then I also created a EntityScrapeActionDeriver that for now just returns true:

&lt;?php

namespace Drupal\shop_scrape\Plugin\Action;

use Drupal\Core\Entity\EntityTypeInterface;

/**
 * Provides an action deriver that finds scrapable entity types.
 *
 * @see \Drupal\Core\Action\Plugin\Action\PublishAction
 * @see \Drupal\Core\Action\Plugin\Action\UnpublishAction
 */
class EntityScrapeActionDeriver extends EntityActionDeriverBase {

  /**
   * {@inheritdoc}
   */
  protected function isApplicable(EntityTypeInterface $entity_type) {
    return true;
    //return $entity_type-&gt;getLabel() == 'page';
  }

}

But my custom action is not appearing. Are there other steps I need to take or have I done something wrong?
Here is a sample code which creates a custom action plugin which modifies node title.
action_plugin_examples/src/Plugin/Action/ArchiveNode.php
&lt;?php

namespace Drupal\action_plugin_examples\Plugin\Action;

use Drupal\Core\Action\ActionBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\pathauto\PathautoState;

/**
 * Provides an Archive Node Action.
 *
 * @Action(
 *   id = &quot;action_plugin_examples_archive_node&quot;,
 *   label = @Translation(&quot;Archive Node&quot;),
 *   type = &quot;node&quot;,
 *   category = @Translation(&quot;Custom&quot;)
 * )
 */
class ArchiveNode extends ActionBase implements ContainerFactoryPluginInterface {


  /**
   * The Messenger service.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;

  /**
   * Logger service.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $logger;

  /**
   * The path alias manager.
   *
   * @var \Drupal\path_alias\AliasManagerInterface
   */
  protected $aliasManager;

  /**
   * Language manager for retrieving the default Langcode.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $languageManager;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $instance = new static($configuration, $plugin_id, $plugin_definition);
    $instance-&gt;logger = $container-&gt;get('logger.factory')-&gt;get('action_plugin_examples');
    $instance-&gt;messenger = $container-&gt;get('messenger');
    $instance-&gt;aliasManager = $container-&gt;get('path_alias.manager');
    $instance-&gt;languageManager = $container-&gt;get('language_manager');
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  public function access($node, AccountInterface $account = NULL, $return_as_object = FALSE) {
    /** @var \Drupal\node\NodeInterface $node */
    $access = $node-&gt;access('update', $account, TRUE)
      -&gt;andIf($node-&gt;title-&gt;access('edit', $account, TRUE));
    return $return_as_object ? $access : $access-&gt;isAllowed();
  }

  /**
   * {@inheritdoc}
   */
  public function execute($node = NULL) {

    /** @var \Drupal\node\NodeInterface $node */

    $language = $this-&gt;languageManager-&gt;getCurrentLanguage()-&gt;getId();

    $old_alias = $this-&gt;aliasManager-&gt;getAliasByPath('/node/' . $node-&gt;id(), $language);

    $title = $node-&gt;getTitle();
    $date = $node-&gt;created-&gt;value;
    $year = date('Y', $date);
    // $old_alias = $node-&gt;path-&gt;alias;
    $new_title = $this-&gt;t('[Archive] | @title', ['@title' =&gt; $title]);
    $node-&gt;setTitle($new_title);
    $node-&gt;setSticky(FALSE);
    $node-&gt;setPromoted(FALSE);

    $new_alias = '/archive/' . $year . $old_alias;
    $node-&gt;set(&quot;path&quot;, [
      'alias' =&gt; $new_alias,
      'langcode' =&gt; $language,
      'pathauto' =&gt; PathautoState::SKIP,
    ]);

    $node-&gt;save();

    $message = $this-&gt;t('Node with NID : @id Archived.', ['@id' =&gt; $node-&gt;id()]);

    $this-&gt;logger-&gt;notice($message);
    $this-&gt;messenger-&gt;addMessage($message);

  }

}

action_plugin_examples/config/optional/system.action.action_plugin_examples_archive_node.yml
langcode: en
status: true
dependencies:
  enforced:
    module:
      - action_plugin_examples
  module:
    - node
    - pathauto
id: action_plugin_examples_archive_node
label: 'Archive Node'
type: node
plugin: action_plugin_examples_archive_node
configuration: {  }

Note : plugin yml file needs to be in the below format.

system.action.&lt;plugin_id&gt;.yml