Skip to content

Instantly share code, notes, and snippets.

@slfrsn
Last active June 12, 2024 10:32
Show Gist options
  • Save slfrsn/a75b2b9ef7074e22ce3b to your computer and use it in GitHub Desktop.
Save slfrsn/a75b2b9ef7074e22ce3b to your computer and use it in GitHub Desktop.
Automatic WordPress theme updates from a GitHub repository using the native WordPress update system.

WordPress Theme Updates from GitHub

Adding automatic theme updates from a GitHub repository is actually pretty simple. The following function will hook into WordPress's native update system and grab the latest release from the repo of your choosing (if the version number has increased).

Place the following in your functions.php

// Automatic theme updates from the GitHub repository
add_filter('pre_set_site_transient_update_themes', 'automatic_GitHub_updates', 100, 1);
function automatic_GitHub_updates($data) {
  // Theme information
  $theme   = get_stylesheet(); // Folder name of the current theme
  $current = wp_get_theme()->get('Version'); // Get the version of the current theme
  // GitHub information
  $user = 'YOUR USERNAME'; // The GitHub username hosting the repository
  $repo = 'YOUR-REPO-NAME'; // Repository name as it appears in the URL
  // Get the latest release tag from the repository. The User-Agent header must be sent, as per
  // GitHub's API documentation: https://developer.github.com/v3/#user-agent-required
  $file = @json_decode(@file_get_contents('https://api.github.com/repos/'.$user.'/'.$repo.'/releases/latest', false,
      stream_context_create(['http' => ['header' => "User-Agent: ".$user."\r\n"]])
  ));
  if($file) {
	$update = filter_var($file->tag_name, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
    // Only return a response if the new version number is higher than the current version
    if($update > $current) {
  	  $data->response[$theme] = array(
	      'theme'       => $theme,
	      // Strip the version number of any non-alpha characters (excluding the period)
	      // This way you can still use tags like v1.1 or ver1.1 if desired
	      'new_version' => $update,
	      'url'         => 'https://github.com/'.$user.'/'.$repo,
	      'package'     => $file->assets[0]->browser_download_url,
      );
    }
  }
  return $data;
}

This is a pretty standard disclaimer, but make sure your releases don't contain any .git files. You'd hate for that to muck up someone's WordPress installation. If you're on a Mac you can use my Automator script to exclude .git files automagically.

Tested with WordPress 4.3

@Dapo-Obembe
Copy link

I can't get this working for me. COuld you be more clear as how the parameters will be put into this code?

@eduardo-marcolino
Copy link

eduardo-marcolino commented Apr 27, 2023

That works perfectly! Thanks for sharing!
Just a note: When working with private repos you also need to add the Authorization header both for reading the latest release and for downloading:

 $token = 'YOUR ACCESS TOKEN'; //https://github.com/settings/personal-access-tokens/new
 $file = @json_decode(@file_get_contents('https://api.github.com/repos/'.$user.'/'.$repo.'/releases/latest', false,
      stream_context_create(['http' => ['header' => "User-Agent: ".$user."\r\nAuthorization: token $token\r\n"]])
  ));
add_filter('http_request_args', function($parsed_args, $url) 
{
 $token = 'YOUR ACCESS TOKEN'; //https://github.com/settings/personal-access-tokens/new
  $user = 'YOUR USERNAME'; // The GitHub username hosting the repository
  $repo = 'YOUR-REPO-NAME'; // Repository name as it appears in the URL
  
  if (str_contains($url, "$user/$repo")) {
    $parsed_args['headers'] = ['Authorization'=> 'token '.$token;
  }
}, 10, 2)

And the URL used for download should be this:

'package'     => $file->assets[0]->url,

@TONYCRE8
Copy link

TONYCRE8 commented Jun 2, 2023

add_filter('http_request_args', function($parsed_args, $url)
{
$token = 'YOUR ACCESS TOKEN'; //https://github.com/settings/personal-access-tokens/new
$user = 'YOUR USERNAME'; // The GitHub username hosting the repository
$repo = 'YOUR-REPO-NAME'; // Repository name as it appears in the URL

if (str_contains($url, "$user/$repo")) {
$parsed_args['headers'] = ['Authorization'=> 'token '.$token;
}
})

Hi @eduardo-marcolino , thanks for sharing this. Could I just ask where this code goes? As I'm struggling to get this to work on some of my work. I seem to be getting a fatal error to do with the amount of arguments parsed. It seems like that add_filter is not accepting the url that I'm trying to pass through, saying that it only received one argument when it expected two. Here's the exact error:
PHP Fatal error: Uncaught ArgumentCountError: Too few arguments to function {closure}(), 1 passed in /my-site/public/wp-includes/class-wp-hook.php on line 310 and exactly 3 expected in /my-site/public/wp-content/themes/custom-theme/updater.php:39

It would be great to get this working. But in it's current state it doesn't look like it does that. Would it also be possible for you to share what your full file looks like? Just so I can get a better idea of how you've achieved this. Thanks!

@Gasparas
Copy link

Gasparas commented Jun 9, 2023

add_filter('http_request_args', function($parsed_args, $url) 
{
 $token = 'YOUR ACCESS TOKEN'; //https://github.com/settings/personal-access-tokens/new
  $user = 'YOUR USERNAME'; // The GitHub username hosting the repository
  $repo = 'YOUR-REPO-NAME'; // Repository name as it appears in the URL
  
  if (str_contains($url, "$user/$repo")) {
    $parsed_args['headers'] = ['Authorization'=> 'token '.$token;
  }
})

Hello @eduardo-marcolino, I have tried your code for private repo authorisation, but could not make it do anything. Would be helpful to see into some steps you do in between. Thanks!

@TONYCRE8
Copy link

TONYCRE8 commented Jun 9, 2023

add_filter('http_request_args', function($parsed_args, $url) 
{
 $token = 'YOUR ACCESS TOKEN'; //https://github.com/settings/personal-access-tokens/new
  $user = 'YOUR USERNAME'; // The GitHub username hosting the repository
  $repo = 'YOUR-REPO-NAME'; // Repository name as it appears in the URL
  
  if (str_contains($url, "$user/$repo")) {
    $parsed_args['headers'] = ['Authorization'=> 'token '.$token;
  }
})

Hello @eduardo-marcolino, I have tried your code for private repo authorisation, but could not make it do anything. Would be helpful to see into some steps you do in between. Thanks!

Hopefully I can help you with this, I've come up with a solution however it's a tad backwards but it's functional which helps.

<?php

// Automatic theme updates from the GitHub repository
add_filter('pre_set_site_transient_update_themes', 'automatic_GitHub_updates', 100, 1);
function automatic_GitHub_updates($data) {
  // Theme information
  $theme_dtls = array(
    'theme_parent'      => get_option('template'),
    'theme_parent_uri'  => get_template_directory_uri(),
    'theme_name'        => get_option('stylesheet'),
    'theme_template'    => get_stylesheet_directory(), // Folder name of the current theme
    'theme_uri'         => get_stylesheet_directory_uri(), // URL of the current theme folder
    'theme_slug'        => 'slug-of-your-theme',
    'theme_dir'         => get_theme_root(), // Folder name of the theme root
  );
  $theme       = $theme_dtls['theme_name'];

  error_log('Theme dtls: ' . print_r( $theme_dtls , true ));

  if ($theme != $theme_dtls['theme_slug']) {
    $theme = $theme_dtls['theme_slug'];
  } 

  error_log('Theme: ' . $theme);

  $current = wp_get_theme()->get('Version'); // Get the version of the current theme
  // GitHub information - you might want to call this from an environment variable or something similar
  $git        = array(
    "user" => "user",
    "repo" => "repo-slug",
    "token" => "token"
  );
  $user       = $git['user']; // The GitHub username hosting the repository
  $repo       = $git['repo']; // Repository name as it appears in the URL
  $token      = $git['token']; // Personal access token

  $url = "https://api.github.com/repos/$user/$repo/releases/latest";

  $curl = curl_init();
  curl_setopt($curl, CURLOPT_URL, $url);
  curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($curl, CURLOPT_HTTPHEADER, [
    'User-Agent: ' . $user,
    'Authorization: token ' . $token,
    'Accept: application/json'
  ]);

  $response = curl_exec($curl);

  if (curl_errno($curl)) {
    error_log('cURL Error: ' . curl_error($curl));
  } 
  
  curl_close($curl);

  $file = json_decode($response);

  if ($file) {
    $update = preg_replace('/[^0-9.]/', '', $file->tag_name);

    //error_log('Automatic GitHub updates: ' . print_r($file, true));

    // Only return a response if the new version number is higher than the current version
    if ($update > $current) {
      $data->response[$theme] = array(
        'theme'       => $theme,
        'new_version' => $update,
        'url'         => 'https://github.com/'.$user.'/'.$repo,
        'package'     => $file->zipball_url,
        'slug'        => $theme,
      );
      error_log('Theme data: ' . print_r($data->response[$theme], true));
    } else {
      error_log('No update available');
    }
    //error_log('Latest version: ' . $update);
    //error_log('Current version: ' . $current);
  }

  error_log('Automatic GitHub updates Check: ' . print_r($data->checked, true));
  error_log('Automatic GitHub updates Response: ' . print_r($data->response, true));

  // Rename the theme folder back to its original name
  if ($theme != $theme_dtls['theme_name']) {
    //Rename theme folder back to its original name: ' . $theme_dtls['theme_name'] . ' to ' . $theme

    error_log('Theme dtls after: ' . print_r( $theme_dtls , true ));
    if ($theme_dtls['theme_name'] != $theme_dtls['theme_slug']) {

      $new_dir = $theme_dtls['theme_dir'] . '/' . $theme_dtls['theme_slug'];

      rename(get_stylesheet_directory(), $new_dir);
      error_log('Theme renamed: ' . $theme_dtls['theme_name'] . ' to ' . get_stylesheet_directory());
      error_log('After Theme dtls: ' . print_r( $theme_dtls , true ));
    } else {
      error_log('Theme not renamed: ' . $theme_dtls['theme_template'] . ' to ' . get_stylesheet_directory());
      error_log('After Theme dtls: ' . print_r( $theme_dtls , true ));
    }
    //rename(get_stylesheet_directory(), $theme);
    update_option('template', $theme_dtls['theme_parent']);
    update_option('stylesheet', $theme);
    update_option('current_theme', $theme);

    //$data->response[$theme_dtls['theme_name']] = $data->response[$theme];

    //unset($data->response[$theme]);

  }

  return $data;
}

add_filter('http_request_args', function($parsed_args, $url) {
  
  /* == Check if the filter has already been applied to the current request == */
  if (isset($parsed_args['wpse_http_request_args_modified'])) {
    error_log('wpse_http_request_args_modified: ' . print_r($parsed_args, true) . ' ' . print_r($url, true));
    return $parsed_args;
  }

  // Mark the parsed_args to indicate that the filter has been applied
  $parsed_args['wpse_http_request_args_modified'] = true;

  $git        = /* again, pass this through an environment variable or something like that*/;
  $user       = $git['user']; // The GitHub username hosting the repository
  $repo       = $git['repo']; // Repository name as it appears in the URL
  $token      = $git['token']; // Personal access token

  if (strpos($url, "$user/$repo") !== false) {
    //error_log('contains: ' . print_r($url, true));

    $headers = array(
      'User-Agent' => $user,
      'Authorization' => 'token ' . $token,
      'Accept' => 'application/json, application/octet-stream'
    );

    $parsed_args['headers'] = $headers;
    //$parsed_args['headers'] = get_stylesheet_directory(  ); // Folder name of the current theme;

    error_log('headers: ' . print_r($parsed_args['headers'], true));
    $parsed_args['reject_unsafe_urls'] = false;
  }

  error_log('wpse_http_request_args: ' . print_r($parsed_args, true) . ' ' . print_r($url, true) );

  return $parsed_args;
}, 10, 2);

What this does is pull the zip from the zipball, which will have a name pattern like Username-Repo-Token - which isn't the best. But that zip has all the content we need. So we pull that zip, and we rename the folder so that we can replace the content of our outdated theme with our current theme. Hopefully this helps you as it managed to help us!

@eduardo-marcolino
Copy link

eduardo-marcolino commented Jun 9, 2023

Hey @TONYCRE8 and @Gasparas, I missed add_filter's accepted_args definition on the example (updated):

<?php

add_filter('http_request_args', function($parsed_args, $url) 
{
  //code goes here..
  return $parsed_args;
}, 10, 2);

Another thing a forgot to mention was that the zip file have the same name of the theme.

@peraknezevic
Copy link

peraknezevic commented Jan 16, 2024

I can't get this to work. Any idea what I'm doing wrong?

When I tested in my local wordpress install I have the option to update but the update fails with these errors:

Warning: Undefined array key 0 in (theme folder path)/functions.php on line 29
Warning: Attempt to read property "browser_download_url" on null in (theme folder path)/functions.php on line 29
Warning: Undefined array key 0 in (theme folder path)/functions.php on line 29
Warning: Attempt to read property "browser_download_url" on null in  (theme folder path)/functions.php on line 29 

line 29 is:

'package'     => $file->assets[0]->browser_download_url,

What's even weirder is that I don't get any update options when this is online.

@TONYCRE8
Copy link

TONYCRE8 commented Feb 8, 2024

I can't get this to work. Any idea what I'm doing wrong?

When I tested in my local wordpress install I have the option to update but the update fails with these errors:

Warning: Undefined array key 0 in (theme folder path)/functions.php on line 29
Warning: Attempt to read property "browser_download_url" on null in (theme folder path)/functions.php on line 29
Warning: Undefined array key 0 in (theme folder path)/functions.php on line 29
Warning: Attempt to read property "browser_download_url" on null in  (theme folder path)/functions.php on line 29 

line 29 is:

'package'     => $file->assets[0]->browser_download_url,

What's even weirder is that I don't get any update options when this is online.

Hi @peraknezevic , have you taken a look at the code I posted and tested if this solution works for you instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment