Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9d96f9b
Share how pup is resolved, downloaded and run: .github/lib/pup.js
glaubersilva Nov 4, 2026
da248a4
Add zip-name: the zip's name before the build, with a pre-release lab…
glaubersilva Nov 4, 2026
a249f41
Add release-zip: pup build, check, i18n and package for the version z…
glaubersilva Nov 4, 2026
0759093
A caller template, zip.yml: the QA and production zip the bot and the…
glaubersilva Nov 4, 2026
991fb2d
README: zip-name and release-zip in Phase 2, the Phase 3 zip.yml as t…
glaubersilva Nov 4, 2026
a04bffb
Reuse the phar an earlier step of the job downloaded, and leave none …
glaubersilva Nov 4, 2026
9cc3ba6
READMEs: the .puprc example names its regex, pup-version among the re…
glaubersilva Nov 4, 2026
98e2800
Say in the zip template why it has no concurrency group
glaubersilva Nov 4, 2026
fcea06e
Take the checkout's directory as working-directory in zip-name and re…
glaubersilva Nov 4, 2026
923926f
One readCheckoutFile and one readYesNo, in the lib
glaubersilva Nov 4, 2026
8baf066
Hand the GitHub Packages token only to the two steps that use it
glaubersilva Nov 4, 2026
85f98bf
pupCommands lists the pup arguments, since pup() adds php and the phar
glaubersilva Nov 4, 2026
a34cc15
Ignore prerelease and sequence on a production build whatever they hold
glaubersilva Nov 4, 2026
cb007c7
Say to keep one numbering scheme for a version's pre-release cycle
glaubersilva Nov 4, 2026
f8df311
Say that working-directory may be absolute, and whose value it is
glaubersilva Nov 4, 2026
b57b830
zip-name publishes the pup release it resolved, and release-zip takes it
glaubersilva Nov 4, 2026
7efcb00
Say when a production build warns about prerelease and sequence
glaubersilva Nov 4, 2026
8054bea
release-zip: run the caller's after-build commands between pup build …
glaubersilva Nov 5, 2026
67fae11
zip template: say that my-plugin in the POT path is the text domain t…
glaubersilva Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/release-prep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ publishes one; the phar downloads into the runner's temp directory and runs on t
A release without one is not supported yet: the prepare step fails, saying so and naming a pin
to use instead. A pinned release that does not exist, such as a mistyped number, fails the step
with "is not a release"; the action tells the two apart with a second HEAD request, to the
release's page.
release's page. A phar an earlier step of the same job downloaded is reused.

The TypeScript line of pup publishes no phar. How the action will call it is open until that
line ships a distribution: through pup's own GitHub Action, or straight from the script with the
Expand Down
281 changes: 26 additions & 255 deletions .github/actions/release-prep/release-prep.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,25 @@
* the API calls, so a test can pass stand-ins and assert the calls and the requests.
*/

const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')

// The three-or-four-parts pattern lives with the other version rules, shared with release-merge.
const { compareVersions, STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js')
const { readRepository, tagExists } = require('../../lib/github.js')
const { runOrFail } = require('../../lib/exec.js')
// How pup is resolved, downloaded and run lives with the other actions that run it.
const {
validatePupVersion,
resolvePupRelease,
resolvePupDistribution,
pupDownloadUrl,
pupReleaseUrl,
noPharError,
downloadPhar,
pupRunner,
lastLine,
} = require('../../lib/pup.js')
const { readCheckoutFile } = require('../../lib/workspace.js')

/**
* A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and
Expand All @@ -33,9 +43,6 @@ const SEMVER_PATTERN =
/** The numeric start of a version, which is what the branch-version comparison reads. */
const NUMERIC_PREFIX = /^\d+(\.\d+)*/

/** A pup release number, which goes into the download URL. */
const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/

/** A calendar date as the changelog writing strategies expect it. */
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/

Expand Down Expand Up @@ -228,77 +235,6 @@ function validateBranch(branch, prefixes = []) {
return trimmed
}

/**
* Checks the pup release to download.
*
* @param {string} version The `pup-version` input.
*
* @throws {Error} When it is not a release number.
*
* @returns {string} The version.
*/
function validatePupVersion(version) {
const trimmed = String(version ?? '').trim()

if (trimmed.toLowerCase() !== 'latest' && !PUP_VERSION_PATTERN.test(trimmed)) {
throw new Error(`pup-version '${trimmed}' is not a pup release number, such as 2.0.0, or 'latest'.`)
}

return trimmed
}

/**
* Resolves the `pup-version` input to a concrete pup release.
*
* A pinned release number is used as it is: resolvePupDistribution checks it against the
* release page, so there is no API call for the common case. `latest` asks the GitHub API for
* the newest release, authenticated with the action token when the caller passes one, so the
* runner's shared unauthenticated quota is not spent.
*
* @param {object} options
* @param {string} options.version The validated `pup-version` input.
* @param {string} options.token The action token, possibly empty.
* @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in.
*
* @throws {Error} When the latest release cannot be read.
*
* @returns {Promise<string>} The release tag to download from.
*/
async function resolvePupRelease({ version, token, fetchImpl }) {
if (version.toLowerCase() !== 'latest') {
return version
}

const headers = {}
if (token) {
headers.authorization = `Bearer ${token}`
}

const response = await fetchImpl('https://api.github.com/repos/stellarwp/pup/releases/latest', {
headers,
})

if (!response.ok) {
throw new Error(
`Reading the latest pup release failed (HTTP ${response.status}). Pass a pinned pup-version and run this step again. Nothing was changed.`
)
}

// The tag goes into a download URL and a temp file path, and a missing one would surface
// as a 404 on …/download/undefined/pup.phar, which the distribution check would report as
// a release that does not exist. A broken API response must fail here, naming the API, so
// the tag is validated before it is used.
const tag = (await response.json()).tag_name
if (typeof tag !== 'string' || !PUP_VERSION_PATTERN.test(tag)) {
throw new Error(
'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' +
'again. Nothing was changed.'
)
}

return tag
}

/**
* Reads what the action needs from the repository's .puprc.
*
Expand Down Expand Up @@ -393,121 +329,16 @@ function readChangeloggerStrategy(text) {
}

/**
* Where a pup release's phar is published.
* The pup commands that prepare a version, in the order they run, as the arguments `pup()` takes.
*
* @param {string} version A pup release number, already validated.
*
* @returns {string} The download URL.
*/
function pupDownloadUrl(version) {
return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar`
}

/**
* The page of a pup release, which answers 404 when the release does not exist.
*
* @param {string} version A pup release number, already validated.
*
* @returns {string} The release page URL.
*/
function pupReleaseUrl(version) {
return `https://github.com/stellarwp/pup/releases/tag/${version}`
}

/**
* Sends a HEAD request and reports whether the URL exists.
*
* @param {object} exec The @actions/exec toolkit.
* @param {string} url The URL to check.
*
* @returns {Promise<{exists: boolean, missing: boolean, result: object}>} Whether it answered
* 200, whether it answered 404, and curl's result for anything else.
*/
async function headExists(exec, url) {
const result = await exec.getExecOutput('curl', ['-fsSI', url], {
ignoreReturnCode: true,
silent: true,
})

// curl -f answers a 404 with exit code 22 and the status in stderr.
return {
exists: result.exitCode === 0,
missing: result.exitCode === 22 && /404/.test(result.stderr),
result,
}
}

/**
* Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar`
* asset means the PHP build the action runs. A release without one (1.2.3 and 0.0.1 among the
* published ones, and the TypeScript line once it ships) cannot be run from here yet.
*
* The checks are HEAD requests against the asset URL and, when that is missing, the release page,
* not the releases API, so a pinned `pup-version` stays free of API calls and rate limits. The
* second request tells a release without a phar from a release that does not exist, which a
* mistyped pin produces.
*
* @param {object} exec The @actions/exec toolkit.
* @param {string} release The resolved pup release tag.
*
* @throws {Error} When the release does not exist, or a check itself fails and the run cannot
* tell which path to take.
*
* @returns {Promise<'phar'|'ts'>} The distribution the release carries: `ts` for any release
* without a phar, the old ones included, since that is the path they take.
*/
async function resolvePupDistribution({ exec, release }) {
const asset = await headExists(exec, pupDownloadUrl(release))

if (asset.exists) {
return 'phar'
}

if (asset.missing) {
const page = await headExists(exec, pupReleaseUrl(release))

if (page.exists) {
return 'ts'
}

if (page.missing) {
throw new Error(`pup ${release} is not a release: check pup-version for a typo. Nothing was changed.`)
}

throw checkFailed(release, page.result)
}

throw checkFailed(release, asset.result)
}

/**
* The error for a release check that neither succeeded nor answered 404.
*
* @param {string} release The pup release tag.
* @param {object} result What curl returned.
*
* @returns {Error} The error, holding what curl printed.
*/
function checkFailed(release, result) {
const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean)

return new Error(
`Checking the pup ${release} release failed (exit code ${result.exitCode}):\n` + output.join('\n')
)
}

/**
* The pup commands that prepare a version, in the order they run.
*
* @param {string} phar Path to the downloaded pup.phar.
* @param {string} version The version to prepare, already validated.
*
* @returns {{command: string, args: string[]}[]} The version bump, then the TBD replacement.
* @returns {string[][]} The version bump, then the TBD replacement.
*/
function pupCommands(phar, version) {
function pupCommands(version) {
return [
{ command: 'php', args: [phar, 'replace-version', version] },
{ command: 'php', args: [phar, 'replace-tbd', version] },
['replace-version', version],
['replace-tbd', version],
]
}

Expand Down Expand Up @@ -554,24 +385,6 @@ function commitMessage(branch, version) {
return `Prepare ${branch} (${version})`
}

/**
* The last non-empty line of a command's output, where `pup get-version` prints the version after any
* PHP notice.
*
* @param {string} output What the command printed.
*
* @returns {string} The line, trimmed, or an empty string.
*/
function lastLine(output) {
return (
String(output ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.at(-1) ?? ''
)
}

/**
* Reads the version `pup get-version` printed: the numeric part of its last line, so a suffix such as
* -beta.1 is ignored and a PHP notice printed before it is skipped.
Expand All @@ -597,28 +410,6 @@ function numericPrefix(version) {
return match ? match[0] : null
}

/**
* Reads a file of the checkout.
*
* @param {string} cwd The checkout.
* @param {string} name The file, relative to it.
*
* @throws {Error} When it exists but cannot be read.
*
* @returns {string|null} The contents, or null when the file does not exist.
*/
function readCheckoutFile(cwd, name) {
try {
return fs.readFileSync(path.join(cwd, name), 'utf8')
} catch (error) {
if (error.code === 'ENOENT') {
return null
}

throw error
}
}

/**
* Refuses a version the repository has already tagged.
*
Expand Down Expand Up @@ -736,16 +527,9 @@ async function runPrepare({ github, core, exec, env, cwd = process.cwd(), fetchI
if (distribution === 'phar') {
await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch })
} else {
// The TypeScript line publishes no phar, and nothing can call it yet: a composite action's
// `uses:` references resolve when the JOB is set up, not when the step runs, so the manifest
// cannot carry its action behind a condition, and calling it straight from the script needs
// a published distribution first. Until one exists, a phar-less release fails here with an
// error naming the pin to use instead. The branch-version check in runPrepareWithPhar is
// part of the phar path — the TS contract does not define it yet.
throw new Error(
`pup ${pupRelease} publishes no pup.phar asset, so this action cannot run it. Pin ` +
'pup-version to a release that ships one, such as 2.0.0. Nothing was changed.'
)
// Nothing can call the TypeScript line yet; see noPharError. The branch-version check in
// runPrepareWithPhar is part of the phar path — the TS contract does not define it yet.
throw noPharError(pupRelease, 'Nothing was changed.')
}

core.setOutput('version', version)
Expand All @@ -769,26 +553,13 @@ async function runPrepare({ github, core, exec, env, cwd = process.cwd(), fetchI
* @returns {Promise<void>} Resolves once pup has run.
*/
async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, branch }) {
const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pharRelease}.phar`)

await runOrFail(
exec,
'curl',
['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pharRelease)],
{},
`Downloading pup ${pharRelease}`
)
const phar = await downloadPhar({ exec, env, release: pharRelease, core })
const pup = pupRunner(exec, phar)

// A version lower than the one the branch already has would bump it backwards: a wrong version, or
// the wrong branch. The same version is fine, it is what re-running a preparation finds. When pup
// finds no version at all it exits non-zero, which fails here as replace-version would.
const printed = await runOrFail(
exec,
'php',
['-d', 'display_errors=stderr', phar, 'get-version'],
{ cwd, silent: true },
'pup get-version'
)
const printed = await pup(['get-version'], { cwd, silent: true, noticesToStderr: true })
const current = readCurrentVersion(printed)
// Only the numeric start is compared: 4.17.0-beta.1 counts as 4.17.0, which compareVersions
// reads; a pre-release of the version the branch holds is still that version.
Expand All @@ -811,8 +582,8 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version,
)
}

for (const { command, args } of pupCommands(phar, version)) {
await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`)
for (const args of pupCommands(version)) {
await pup(args, { cwd })
}
}

Expand Down
Loading
Loading