/** * Reports themes whose WordPress.org state disagrees with this repository. * * A successful `deploy-dotorg.sh` run only proves the commit reached SVN. The * themes directory then runs its own checks before serving the new version, and * that step can quietly never complete -- the commit sits in SVN indefinitely * while wordpress.org keeps serving the old version. Nothing in the deploy * surfaces that, so this runs separately and looks at the published result. * * Two states are worth waking someone for: * * svn-ahead-of-live SVN has a version the directory is not serving. * The commit landed; WordPress.org did not publish it. * * repo-ahead-of-svn style.css has a version SVN does not have. * The deploy did not run, or it failed for this theme. * * A third state -- eligible but never published at all -- is reported for * context only. Those themes are waiting on an initial directory submission, * which is a human process with no deadline, so alerting on them is noise. * * Themes we could not reach are counted apart from both, as lookup-failed. They * are not evidence of sync and they are not evidence of drift, and folding them * into either is how an api.wordpress.org outage would end up reported as a * clean run. Past a small budget of them the check fails outright. * * svn-ahead-of-live is held for a grace period (default 48h) measured from the * SVN commit date, because the directory legitimately takes a while to pick a * commit up -- observed lag when it does accept one is under a day. There is no * such wait on repo-ahead-of-svn: the deploy runs on every push to trunk, so a * version missing from SVN is already late. * * No npm dependencies, so the scheduled workflow can skip `npm ci`. */ import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const execFileAsync = promisify( execFile ); const UTILS_DIR = path.dirname( fileURLToPath( import.meta.url ) ); const REPO_ROOT = path.resolve( UTILS_DIR, '..' ); const ELIGIBLE_SCRIPT = path.join( UTILS_DIR, 'dotorg-sync-eligible.sh' ); const DEFAULT_GRACE_HOURS = 48; const CONCURRENCY = 6; // Neither fetch nor `svn info` times out on its own, so a hung connection would // sit there until the job's own timeout killed the run -- and a killed run // produces no report at all, which is the outcome this check exists to avoid. const REQUEST_TIMEOUT_MS = 15_000; // Above this share of themes failing to look up, the run is not a report about // WordPress.org any more, it is a report about the network. Below it, a few // stragglers are listed but the rest of the results still stand. const MAX_LOOKUP_FAILURE_RATE = 0.05; const MIN_LOOKUP_FAILURES = 3; // Themes are matched to the directory by slug, and a handful of our slugs // belong to somebody else's theme on WordPress.org (twentytwentytwo and // friends are wordpressdotorg's; a few others collide with unrelated themes). // Comparing our versions against those produces permanent false drift, so only // themes the directory attributes to this account are considered. const EXPECTED_AUTHOR = 'automattic'; const STATE = { svnAheadOfLive: 'svn-ahead-of-live', repoAheadOfSvn: 'repo-ahead-of-svn', notPublished: 'not-published', // In SVN but not yet served, and still inside the grace window -- the // directory may simply not have got to it. Kept distinct from in-sync so the // summary does not report a theme as healthy when it is only too new to judge. pending: 'pending', inSync: 'in-sync', // Deliberately excluded from the sync: a version we cannot parse, or a slug // the directory attributes to somebody else. Both are permanent and both are // a decision, not a failure. skipped: 'skipped', // We could not find out. Kept apart from `skipped` because the two need // opposite treatment -- a skip is a settled answer, a failed lookup is the // absence of one, and counting the latter as "not drifting" is how a // wordpress.org outage would turn into a green run. lookupFailed: 'lookup-failed', }; /** * Reads the shared candidate list. Eligibility lives in dotorg-sync-eligible.sh * so that this check and the deploy cannot disagree about which themes are in * scope. * * @return {Array<{slug: string, repoVersion: string}>} Eligible themes. */ function getEligibleThemes() { const output = execFileSync( 'bash', [ ELIGIBLE_SCRIPT ], { encoding: 'utf8', cwd: REPO_ROOT, } ); return output .split( '\n' ) .filter( Boolean ) .map( ( line ) => { const [ slug, repoVersion ] = line.trim().split( /\s+/ ); return { slug, repoVersion }; } ) .filter( ( theme ) => theme.slug && theme.repoVersion ); } /** * Compares dot-separated numeric versions segment by segment. * * Lexical comparison is wrong here in both directions: it orders 3.1.9 after * 3.1.21, and treating the digits as one integer makes a leading zero parse as * octal. * * This matches `sort -V` -- what deploy-dotorg.sh uses on the SVN side -- with * one deliberate exception: a missing trailing segment counts as zero, so 1.0 * and 1.0.0 compare equal where `sort -V` ranks 1.0 lower. Themes do write the * two spellings interchangeably (style.css says 1.0, the directory serves * 1.0.0), and they name the same release. Ordering them would report those * themes as out of sync every night with nothing to fix. * * @param {string} a First version. * @param {string} b Second version. * @return {number} Negative if a < b, positive if a > b, 0 if equal. */ function compareVersions( a, b ) { const aParts = a.split( '.' ).map( Number ); const bParts = b.split( '.' ).map( Number ); const length = Math.max( aParts.length, bParts.length ); for ( let i = 0; i < length; i++ ) { const diff = ( aParts[ i ] ?? 0 ) - ( bParts[ i ] ?? 0 ); if ( diff !== 0 ) { return diff; } } return 0; } const isNumericVersion = ( version ) => /^\d+(\.\d+)*$/.test( version ); /** * The version WordPress.org is currently serving, via the themes API. * * The bracketed query parameters have to be percent-encoded. Sent literally the * API answers with an empty body rather than an error, which reads as "theme * not in the directory" and would report every theme as unpublished. * * @param {string} slug Theme slug. * @return {Promise<{version: string, author: string|null, lastUpdated: string}|null>} Null when absent from the directory. */ async function fetchLiveVersion( slug ) { const url = 'https://api.wordpress.org/themes/info/1.2/?action=theme_information' + `&request%5Bslug%5D=${ encodeURIComponent( slug ) }`; const response = await fetch( url, { signal: AbortSignal.timeout( REQUEST_TIMEOUT_MS ), } ); // A theme that is not in the directory answers 404 with {"error": ...}, which // is an answer rather than a failure. Anything else -- a 429, a 5xx -- is a // failure, and has to stay one: treating it as "not in the directory" would // let an API outage read as an all-clear. if ( ! response.ok && response.status !== 404 ) { throw new Error( `themes API returned ${ response.status }` ); } const body = await response.json(); if ( ! body || body.error || ! body.version ) { return null; } // 1.2 returns author as an object, but older shapes return a bare string. // Null when it is neither: the author decides whether a slug collision is // somebody else's theme, and guessing "unknown" there would skip every // theme and report a clean run. const author = typeof body.author === 'string' ? body.author : body.author?.user_nicename ?? null; return { version: body.version, author, lastUpdated: body.last_updated ?? 'unknown', }; } /** * The highest numbered version directory in the theme's SVN repository, read * from the Apache directory index. This needs no svn binary and no credentials. * * @param {string} slug Theme slug. * @return {Promise} Null when the theme has no SVN repository or no numbered versions. */ async function fetchLatestSvnVersion( slug ) { const response = await fetch( `https://themes.svn.wordpress.org/${ encodeURIComponent( slug ) }/`, { signal: AbortSignal.timeout( REQUEST_TIMEOUT_MS ) } ); if ( response.status === 404 ) { return null; } if ( ! response.ok ) { throw new Error( `svn listing returned ${ response.status }` ); } const html = await response.text(); const versions = [ ...html.matchAll( /href="([\d.]+)\/"/g ) ] .map( ( match ) => match[ 1 ] ) .filter( isNumericVersion ); if ( ! versions.length ) { return null; } return versions.sort( compareVersions ).at( -1 ); } /** * When a version directory was last committed to SVN, for the grace period. * Requires the svn binary. A theme whose date cannot be read is reported rather * than held, so a broken svn client cannot silence the check. * * @param {string} slug Theme slug. * @param {string} version Version directory to inspect. * @return {Promise} Null when the date could not be determined. */ async function fetchSvnCommitDate( slug, version ) { try { const { stdout } = await execFileAsync( 'svn', [ 'info', '--non-interactive', `https://themes.svn.wordpress.org/${ slug }/${ version }/`, ], { timeout: REQUEST_TIMEOUT_MS } ); const match = stdout.match( /Last Changed Date:\s*(\S+\s+\S+\s+\S+)/ ); return match ? new Date( match[ 1 ] ) : null; } catch { return null; } } const hoursSince = ( date ) => ( Date.now() - date.getTime() ) / 36e5; /** * Classifies one theme. Network failures surface as their own state rather than * being swallowed -- a check that reports "all clear" because every request * failed is worse than one that reports nothing. * * @param {Object} theme Theme from the eligible list. * @param {number} graceHours Hours to wait on the directory before reporting an unpublished SVN commit. * @return {Promise} Classification result. */ async function checkTheme( theme, graceHours ) { const { slug, repoVersion } = theme; const result = { slug, repoVersion }; if ( ! isNumericVersion( repoVersion ) ) { return { ...result, state: STATE.skipped, note: `style.css version "${ repoVersion }" is not numeric`, }; } let live; let svnVersion; try { [ live, svnVersion ] = await Promise.all( [ fetchLiveVersion( slug ), fetchLatestSvnVersion( slug ), ] ); } catch ( error ) { return { ...result, state: STATE.lookupFailed, note: `lookup failed: ${ error.message }`, }; } if ( live && ! live.author ) { return { ...result, state: STATE.lookupFailed, note: 'themes API returned no author, so ownership of the slug is unknown', }; } if ( live && live.author !== EXPECTED_AUTHOR ) { return { ...result, state: STATE.skipped, note: `wordpress.org/themes/${ slug }/ belongs to "${ live.author }"`, }; } if ( ! svnVersion ) { return { ...result, state: STATE.notPublished, note: 'no versions in SVN' }; } result.svnVersion = svnVersion; if ( ! live ) { return { ...result, state: STATE.notPublished, note: 'not in the themes directory' }; } result.liveVersion = live.version; result.liveUpdated = live.lastUpdated; // The repo being ahead of SVN means the deploy never landed this version, // which is checked first: when a theme is in both states, the deploy is the // one that has to be fixed before the directory can publish anything. No // grace period -- the deploy runs on every push to trunk. if ( compareVersions( repoVersion, svnVersion ) > 0 ) { return { ...result, state: STATE.repoAheadOfSvn, note: 'deploy has not put this version in SVN', }; } if ( compareVersions( svnVersion, live.version ) > 0 ) { const committed = await fetchSvnCommitDate( slug, svnVersion ); const waited = committed ? hoursSince( committed ) : null; if ( waited !== null && waited < graceHours ) { return { ...result, state: STATE.pending, note: `committed ${ Math.round( waited ) }h ago, still in flight` }; } return { ...result, state: STATE.svnAheadOfLive, waitedHours: waited, note: 'WordPress.org has not published this version', }; } return { ...result, state: STATE.inSync }; } /** * Runs tasks with a bounded number in flight, to stay polite to wordpress.org. * * @param {Array} items Items to process. * @param {number} limit Maximum concurrent tasks. * @param {Function} worker Async function applied to each item. * @return {Promise} Results in input order. */ async function mapWithConcurrency( items, limit, worker ) { const results = new Array( items.length ); let cursor = 0; const runners = Array.from( { length: Math.min( limit, items.length ) }, async () => { while ( cursor < items.length ) { const index = cursor++; results[ index ] = await worker( items[ index ], index ); } } ); await Promise.all( runners ); return results; } const formatRow = ( r ) => ` ${ r.slug.padEnd( 24 ) } repo ${ ( r.repoVersion ?? '-' ).padEnd( 9 ) }` + ` svn ${ ( r.svnVersion ?? '-' ).padEnd( 9 ) } live ${ ( r.liveVersion ?? '-' ).padEnd( 9 ) }` + ( r.waitedHours ? ` (${ Math.round( r.waitedHours / 24 ) }d)` : '' ); /** * Builds the Slack message. Long lists are truncated: past a couple of dozen * entries the message stops being readable and the run log is the better place * to look, so the message links there instead of listing everything. * * @param {Object} groups Results grouped by state. * @param {number} totalCount Number of themes checked. * @return {string} Slack mrkdwn message. */ function buildSlackMessage( groups, totalCount ) { const stuck = groups[ STATE.svnAheadOfLive ] ?? []; const undeployed = groups[ STATE.repoAheadOfSvn ] ?? []; const lines = []; lines.push( `*WordPress.org sync check* — ${ stuck.length + undeployed.length } of ${ totalCount } themes out of sync` ); const section = ( title, rows ) => { if ( ! rows.length ) { return; } lines.push( '', `*${ title }* (${ rows.length })` ); lines.push( '```' ); rows.slice( 0, 25 ).forEach( ( r ) => lines.push( formatRow( r ).trimEnd() ) ); if ( rows.length > 25 ) { lines.push( ` … and ${ rows.length - 25 } more` ); } lines.push( '```' ); }; section( 'SVN ahead of live — WordPress.org has not published these', stuck ); section( 'Repo ahead of SVN — the deploy has not landed these', undeployed ); const failed = groups[ STATE.lookupFailed ] ?? []; if ( failed.length ) { lines.push( '', `_${ failed.length } theme(s) could not be checked at all; they are neither in sync nor out of it._` ); } const runUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID ? `${ process.env.GITHUB_SERVER_URL }/${ process.env.GITHUB_REPOSITORY }/actions/runs/${ process.env.GITHUB_RUN_ID }` : null; if ( runUrl ) { lines.push( '', `<${ runUrl }|Full report>` ); } return lines.join( '\n' ); } /** * Checks every eligible theme against WordPress.org and reports the drift. * * @param {Object} [options] Options. * @param {number} [options.graceHours] Hours to wait on the directory before reporting an unpublished SVN commit. * @param {string} [options.slackOutput] Path to write the Slack message to. * @param {string} [options.jsonOutput] Path to write the full results to as JSON. * @return {Promise} True when drift was found. */ export async function checkDotorgSync( options = {} ) { // A bare `--grace-hours` parses to true and would silently become one hour; // a typo parses to NaN, and every comparison against NaN is false, so every // theme still in flight would be reported as stuck. Both produce a plausible // looking alert storm out of a malformed flag, so refuse the flag instead. // `true` is what the flag parser yields for a bare `--grace-hours`, and it // has to be rejected explicitly: Number( true ) is 1, a perfectly finite // number, so a value check alone would let it through as a one hour window. const rawGraceHours = options.graceHours ?? DEFAULT_GRACE_HOURS; const graceHours = rawGraceHours === true ? Number.NaN : Number( rawGraceHours ); if ( ! Number.isFinite( graceHours ) || graceHours < 0 ) { throw new Error( `--grace-hours needs a non-negative number, as --grace-hours=24. Got "${ rawGraceHours }".` ); } const themes = getEligibleThemes(); console.log( `Checking ${ themes.length } eligible themes against WordPress.org…\n` ); const results = await mapWithConcurrency( themes, CONCURRENCY, ( theme ) => checkTheme( theme, graceHours ) ); const groups = results.reduce( ( acc, result ) => { ( acc[ result.state ] ??= [] ).push( result ); return acc; }, {} ); const stuck = groups[ STATE.svnAheadOfLive ] ?? []; const undeployed = groups[ STATE.repoAheadOfSvn ] ?? []; if ( stuck.length ) { console.log( `SVN ahead of live — WordPress.org has not published these (${ stuck.length }):` ); stuck.forEach( ( r ) => console.log( formatRow( r ) ) ); console.log( '' ); } if ( undeployed.length ) { console.log( `Repo ahead of SVN — the deploy has not landed these (${ undeployed.length }):` ); undeployed.forEach( ( r ) => console.log( formatRow( r ) ) ); console.log( '' ); } const summarise = ( state ) => ( groups[ state ] ?? [] ).length; console.log( 'Summary' ); console.log( ` in sync: ${ summarise( STATE.inSync ) }` ); console.log( ` pending (in grace): ${ summarise( STATE.pending ) }` ); console.log( ` svn ahead of live: ${ stuck.length }` ); console.log( ` repo ahead of svn: ${ undeployed.length }` ); console.log( ` not published: ${ summarise( STATE.notPublished ) }` ); console.log( ` skipped: ${ summarise( STATE.skipped ) }` ); console.log( ` lookup failed: ${ summarise( STATE.lookupFailed ) }` ); const pending = groups[ STATE.pending ] ?? []; if ( pending.length ) { console.log( '\nPending (inside the grace window, not alerted):' ); pending.forEach( ( r ) => console.log( ` ${ r.slug.padEnd( 24 ) } ${ r.note }` ) ); } const skipped = groups[ STATE.skipped ] ?? []; if ( skipped.length ) { console.log( '\nSkipped:' ); skipped.forEach( ( r ) => console.log( ` ${ r.slug.padEnd( 24 ) } ${ r.note }` ) ); } const failed = groups[ STATE.lookupFailed ] ?? []; if ( failed.length ) { console.log( '\nLookup failed (no verdict for these themes):' ); failed.forEach( ( r ) => console.log( ` ${ r.slug.padEnd( 24 ) } ${ r.note }` ) ); } const hasDrift = stuck.length > 0 || undeployed.length > 0; if ( options.slackOutput ) { fs.writeFileSync( options.slackOutput, hasDrift ? buildSlackMessage( groups, themes.length ) : '' ); } if ( options.jsonOutput ) { fs.writeFileSync( options.jsonOutput, JSON.stringify( { checkedAt: new Date().toISOString(), graceHours, results }, null, 2 ) ); } // Thrown after the outputs are written, so the artifacts still say which // themes failed and why. Returning here instead would report "no drift", // which is the one answer the results do not support: we did not find every // theme in sync, we failed to find out. const failureBudget = Math.max( MIN_LOOKUP_FAILURES, Math.ceil( themes.length * MAX_LOOKUP_FAILURE_RATE ) ); if ( failed.length > failureBudget ) { throw new Error( `${ failed.length } of ${ themes.length } themes could not be looked up ` + `(budget ${ failureBudget }); treating the run as failed rather than clean.` ); } return hasDrift; } /** * Run directly rather than imported. * * index.mjs registers this as `npm run check:dotorg-sync`, which is the * convenient way to run it locally -- but index.mjs imports dotenv and so needs * `npm ci` first. This check has no dependencies of its own, and entering * through this file keeps it that way, so the scheduled workflow can install * nothing and still run it. Exit codes match the npm command: 0 in sync, 2 out * of sync, 1 if the check itself failed. */ if ( process.argv[ 1 ] === fileURLToPath( import.meta.url ) ) { const options = {}; for ( const arg of process.argv.slice( 2 ) ) { if ( ! arg.startsWith( '--' ) ) { continue; } const [ key, value ] = arg.slice( 2 ).split( '=' ); const camelCaseKey = key.replace( /-([a-z])/g, ( [ , c ] ) => c.toUpperCase() ); options[ camelCaseKey ] = value ?? true; } checkDotorgSync( options ) .then( ( hasDrift ) => { process.exitCode = hasDrift ? 2 : 0; } ) .catch( ( error ) => { console.error( error ); process.exitCode = 1; } ); }