Skip to content
73 changes: 73 additions & 0 deletions .github/workflows/check-php-cli-versions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Check for new PHP CLI versions

on:
schedule:
- cron: '0 9 * * 1'

permissions:
actions: write
contents: read

concurrency:
group: check-php-cli-versions
cancel-in-progress: false

jobs:
check:
name: Check supported PHP versions
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
php_versions: ${{ steps.check-versions.outputs.php_versions }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout Studio
uses: actions/checkout@v7

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

- name: Check for new PHP patch versions
id: check-versions
run: node scripts/check-php-cli-versions.mjs

- name: Send Slack alert
if: failure()
env:
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
run: |
jq --null-input \
--arg channel "$SLACK_CHANNEL_ID" \
--arg text "The scheduled PHP CLI version check failed unexpectedly. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>." \
'{ channel: $channel, text: $text, username: "PHP Version Bot", icon_emoji: ":warning:" }' |
curl --fail-with-body https://slack.com/api/chat.postMessage \
--header "Authorization: Bearer $SLACK_TOKEN" \
--header 'Content-Type: application/json; charset=utf-8' \
--data-binary @-

dispatch:
name: Dispatch PHP ${{ matrix.php_version }} build
needs: check
if: needs.check.outputs.php_versions != '[]'
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
php_version: ${{ fromJSON(needs.check.outputs.php_versions) }}
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
PHP_VERSION: ${{ matrix.php_version }}
steps:
- name: Dispatch PHP build
run: >-
gh workflow run build-php-cli-binaries.yml
--repo "$GITHUB_REPOSITORY"
--ref "$DEFAULT_BRANCH"
--field "php_version=$PHP_VERSION"
--field "apps_cdn_visibility=external"
170 changes: 170 additions & 0 deletions scripts/check-php-cli-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env node

import fs from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import phpBinaryCdnMetadata from '../packages/common/lib/php-binary-cdn-metadata.mjs';

const PHP_UPSTREAM_REPOSITORY = 'php/php-src';
const PHP_CDN_BASE_URL = 'https://appscdn.wordpress.com/downloads/wordpress-com-studio-php-cli';
const PHP_CDN_PLATFORMS = [ 'mac-silicon', 'mac-intel', 'windows-x64', 'linux-x64', 'linux-arm64' ];

function parsePatchVersion( version ) {
const match = version.match( /^(\d+)\.(\d+)\.(\d+)$/ );
if ( ! match ) {
throw new Error( `Expected a PHP patch version. Received: ${ version }` );
}

return match.slice( 1 ).map( Number );
}

export function comparePatchVersions( first, second ) {
const firstParts = parsePatchVersion( first );
const secondParts = parsePatchVersion( second );

for ( let index = 0; index < firstParts.length; index += 1 ) {
const difference = firstParts[ index ] - secondParts[ index ];
if ( difference !== 0 ) {
return difference;
}
}

return 0;
}

export function latestPatchVersion( refs, phpMinor ) {
const prefix = `refs/tags/php-${ phpMinor }.`;
const versions = refs
.map( ( ref ) => ref.ref )
.filter( ( ref ) => ref.startsWith( prefix ) )
.map( ( ref ) => ref.slice( 'refs/tags/php-'.length ) )
.filter( ( version ) => /^\d+\.\d+\.\d+$/.test( version ) );

return versions.sort( comparePatchVersions ).at( -1 );
}

async function githubRequest( path, { method = 'GET', body } = {} ) {
const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
const token = process.env.GH_TOKEN;
if ( ! token ) {
throw new Error( 'GH_TOKEN is required.' );
}

const response = await fetch( `${ apiUrl }${ path }`, {
method,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${ token }`,
'User-Agent': 'wordpress-studio-php-version-checker',
'X-GitHub-Api-Version': '2022-11-28',
},
body: body ? JSON.stringify( body ) : undefined,
} );

if ( ! response.ok ) {
throw new Error(
`GitHub API request failed (${ response.status } ${
response.statusText
}): ${ await response.text() }`
);
}

return response.status === 204 ? undefined : response.json();
}

async function findLatestPatchVersion( phpMinor ) {
const refs = await githubRequest(
`/repos/${ PHP_UPSTREAM_REPOSITORY }/git/matching-refs/tags/php-${ phpMinor }.?per_page=100`
);
const latestVersion = latestPatchVersion( refs, phpMinor );

if ( ! latestVersion ) {
throw new Error( `Could not find an upstream patch release for PHP ${ phpMinor }.` );
}

return latestVersion;
}

// The PHP binary version may exist on the CDN, but not in the metadata, if a
// previous GitHub Actions run built the same version, but the resulting PR
// hasn't yet been merged.
export async function phpVersionExistsOnCdn( phpVersion, fetchImplementation = fetch ) {
const checks = await Promise.all(
PHP_CDN_PLATFORMS.map( async ( platform ) => {
const url = `${ PHP_CDN_BASE_URL }/${ platform }/${ phpVersion }/full-install`;
const response = await fetchImplementation( url, {
method: 'HEAD',
redirect: 'manual',
} );

if ( response.status === 404 ) {
return false;
}

if ( response.status < 200 || response.status >= 400 ) {
throw new Error(
`Apps CDN check failed for ${ platform } PHP ${ phpVersion }: ` +
`${ response.status } ${ response.statusText }`
);
}

return true;
} )
);

return checks.every( Boolean );
}

async function main() {
const results = [];
const phpVersionsToBuild = [];

for ( const [ phpMinor, { version: currentVersion } ] of Object.entries(
phpBinaryCdnMetadata.versions
) ) {
const latestVersion = await findLatestPatchVersion( phpMinor );
let result = 'Up to date';

if ( comparePatchVersions( latestVersion, currentVersion ) > 0 ) {
if ( await phpVersionExistsOnCdn( latestVersion ) ) {
result = 'Already available on CDN';
} else {
phpVersionsToBuild.push( latestVersion );
result = 'Build required';
}
}

results.push( { phpMinor, currentVersion, latestVersion, result } );
}

const phpVersionsOutput = JSON.stringify( phpVersionsToBuild );
if ( process.env.GITHUB_OUTPUT ) {
await fs.appendFile( process.env.GITHUB_OUTPUT, `php_versions=${ phpVersionsOutput }\n` );
} else {
console.log( `PHP versions requiring builds: ${ phpVersionsOutput }` );
}

const summary = [
'### PHP CLI version check',
'',
'| PHP minor | Current binary | Latest upstream | Result |',
'| --- | --- | --- | --- |',
...results.map(
( { phpMinor, currentVersion, latestVersion, result } ) =>
`| ${ phpMinor } | ${ currentVersion } | ${ latestVersion } | ${ result } |`
),
'',
].join( '\n' );

if ( process.env.GITHUB_STEP_SUMMARY ) {
await fs.appendFile( process.env.GITHUB_STEP_SUMMARY, summary );
} else {
console.log( summary );
}
}

if ( process.argv[ 1 ] && import.meta.url === pathToFileURL( process.argv[ 1 ] ).href ) {
main().catch( ( error ) => {
console.error( error.message );
process.exitCode = 1;
} );
}
62 changes: 62 additions & 0 deletions scripts/check-php-cli-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
comparePatchVersions,
latestPatchVersion,
phpVersionExistsOnCdn,
} from './check-php-cli-versions.mjs';

await test( 'comparePatchVersions compares each numeric version component', () => {
assert.ok( comparePatchVersions( '8.4.10', '8.4.9' ) > 0 );
assert.ok( comparePatchVersions( '8.4.9', '8.4.10' ) < 0 );
assert.equal( comparePatchVersions( '8.4.10', '8.4.10' ), 0 );
} );

await test( 'latestPatchVersion returns the latest stable patch for the requested minor', () => {
const refs = [
{ ref: 'refs/tags/php-8.4.9' },
{ ref: 'refs/tags/php-8.4.10RC1' },
{ ref: 'refs/tags/php-8.4.10' },
{ ref: 'refs/tags/php-8.5.1' },
];

assert.equal( latestPatchVersion( refs, '8.4' ), '8.4.10' );
} );

await test( 'latestPatchVersion returns undefined when no stable patch matches', () => {
assert.equal( latestPatchVersion( [ { ref: 'refs/tags/php-8.4.10RC1' } ], '8.4' ), undefined );
} );

await test( 'phpVersionExistsOnCdn checks every artifact target', async () => {
const requests = [];
const exists = await phpVersionExistsOnCdn( '8.5.7', async ( url, options ) => {
requests.push( { url, options } );
return { status: 302, statusText: 'Found' };
} );

assert.equal( exists, true );
assert.equal( requests.length, 5 );
assert.ok(
requests.every( ( { options } ) => options.method === 'HEAD' && options.redirect === 'manual' )
);
} );

await test( 'phpVersionExistsOnCdn returns false when an artifact is missing', async () => {
const exists = await phpVersionExistsOnCdn( '8.5.8', async ( url ) => ( {
status: url.includes( 'windows-x64' ) ? 404 : 302,
statusText: '',
} ) );

assert.equal( exists, false );
} );

await test( 'phpVersionExistsOnCdn rejects unexpected CDN responses', async () => {
await assert.rejects(
() =>
phpVersionExistsOnCdn( '8.5.8', async () => ( {
status: 500,
statusText: 'Server Error',
} ) ),
/Apps CDN check failed/
);
} );
Loading