diff --git a/load-application.php b/load-application.php
index 7144fdad..da5a8f38 100644
--- a/load-application.php
+++ b/load-application.php
@@ -26,5 +26,6 @@
$application->add( new Team51\Command\Front_List_Exports() );
$application->add( new Team51\Command\Get_PHP_Errors() );
$application->add( new Team51\Command\Remove_User() );
+$application->add( new Team51\Command\Rotate_GitHub_Secrets() );
$application->run();
diff --git a/src/commands/rotate-github-secrets.php b/src/commands/rotate-github-secrets.php
new file mode 100644
index 00000000..eb73f880
--- /dev/null
+++ b/src/commands/rotate-github-secrets.php
@@ -0,0 +1,138 @@
+setDescription( 'Updates GitHub secrets across all site repositories within the GITHUB_API_OWNER organization.' )
+ ->setHelp( 'This command allows you to bulk-update GitHub secrets.' );
+ }
+
+ protected function execute( InputInterface $input, OutputInterface $output ) {
+ $api_helper = new API_Helper();
+
+ $mappings = array();
+
+ $output->writeln( 'Getting data from DeployHQ.' );
+
+ $projects = $api_helper->call_deploy_hq_api( 'projects', 'GET', array() );
+
+ if ( empty( $projects ) ) {
+ $output->writeln( 'Failed to get data from DeployHQ.' );
+ exit;
+ }
+
+ foreach ( $projects as $project ) {
+ $repository = false;
+ $sftp_username = false;
+
+ // Get git repo name.
+ if ( empty( $project->repository->url ) ) {
+ continue;
+ }
+
+ if ( ! preg_match( '/git@github.com:' . GITHUB_API_OWNER . '\/(.*).git/', $project->repository->url, $matches ) ) {
+ continue;
+ }
+
+ $repository = $matches[1];
+
+ // Get SFTP username.
+ $servers = $api_helper->call_deploy_hq_api( "projects/{$project->name}/servers", 'GET', array() );
+
+ if ( empty( $servers ) ) {
+ continue;
+ }
+
+ foreach ( $servers as $server ) {
+ if ( ! empty( $server->branch ) && 'trunk' === $server->branch ) {
+ $sftp_username = $server->username;
+ break;
+ }
+ }
+
+ if ( ! empty( $repository ) && ! empty( $sftp_username ) ) {
+ $mappings[ $sftp_username ] = array(
+ 'repository' => $repository
+ );
+ }
+ }
+
+ $output->writeln( 'Getting site data from Pressable.' );
+
+ $sites = $api_helper->call_pressable_api( 'sites', 'GET', array() );
+
+ if ( 'Success' !== $sites->message || empty( $sites->data ) ) {
+ $output->writeln( 'Failed to get data from Pressable.' );
+ exit;
+ }
+
+ foreach ( $sites->data as $site ) {
+
+ // Get SFTP accounts for this site.
+ $users = $api_helper->call_pressable_api( "/sites/{$site->id}/ftp", 'GET', array() );
+
+ if ( 'Success' !== $users->message || empty( $users->data ) ) {
+ continue;
+ }
+
+ $sftp_username = false;
+
+ foreach ( $users->data as $user ) {
+ if ( PRESSABLE_ACCOUNT_EMAIL === $user->email ) {
+ $sftp_username = $user->username;
+ break;
+ }
+ }
+
+ if ( ! empty( $sftp_username ) && array_key_exists( $sftp_username, $mappings ) ) {
+ $mappings[ $sftp_username ]['site_url'] = $site->url;
+ }
+ }
+
+ $output->writeln( 'Adding secrets to GitHub.' );
+
+ foreach ( $mappings as $sftp => $data ) {
+ $secrets = array(
+ 'GH_BOT_TOKEN' => GITHUB_API_TOKEN,
+ 'DEPLOYHQ_TOKEN' => DEPLOY_HQ_API_KEY,
+ 'SITE_URL_TRUNK' => $data['site_url'],
+ );
+
+ $gh_key = $api_helper->call_github_api(
+ sprintf( 'repos/%s/%s/actions/secrets/public-key', GITHUB_API_OWNER, $data['repository'] ),
+ array(),
+ 'GET'
+ );
+
+ if ( empty( $gh_key ) ) {
+ $output->writeln( "Failed to get public key for repository '{$data['repository']}, skipping'." );
+ continue;
+ }
+
+ foreach ( $secrets as $secret_name => $secret_value ) {
+ $public_key = sodium_base642bin( $gh_key->key, SODIUM_BASE64_VARIANT_ORIGINAL );
+
+ $secret = $api_helper->call_github_api(
+ sprintf( 'repos/%s/%s/actions/secrets/%s', GITHUB_API_OWNER, $data['repository'], $secret_name ),
+ array(
+ 'encrypted_value' => base64_encode( sodium_crypto_box_seal( $secret_value, $public_key ) ),
+ 'key_id' => $gh_key->key_id,
+ ),
+ 'PUT'
+ );
+ }
+ }
+
+ $output->writeln( "All done." );
+ }
+}