diff --git a/.github/action/build-bake-publish/action.yml b/.github/action/build-bake-publish/action.yml index c619e89..56bbfde 100644 --- a/.github/action/build-bake-publish/action.yml +++ b/.github/action/build-bake-publish/action.yml @@ -31,9 +31,17 @@ runs: shell: bash run: echo "${{ inputs.github_token }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + - name: Prepare PHP sources + shell: bash + run: node scripts/prepare-php-sources.mjs + - name: Build and push uses: docker/bake-action@v6 with: + source: . + files: | + docker-bake.hcl + php-from-source.hcl push: true targets: ${{ inputs.targets }} no-cache: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 325d2ab..b666a1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,8 @@ jobs: with: files: | docker-bake.hcl + php-versions.json + scripts/** frankenphp*/** - name: Build @@ -61,6 +63,8 @@ jobs: with: files: | docker-bake.hcl + php-versions.json + scripts/** frankenphp*/** - name: Build @@ -85,6 +89,8 @@ jobs: with: files: | docker-bake.hcl + php-versions.json + scripts/** fpm*/** nginx/** caddy/** @@ -113,6 +119,8 @@ jobs: with: files: | docker-bake.hcl + php-versions.json + scripts/** fpm*/** nginx/** caddy/** diff --git a/.gitignore b/.gitignore index 8844ec1..a98a425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.idea composer.lock -vendor \ No newline at end of file +vendor +/.php-library +/php-from-source.hcl diff --git a/docker-bake.hcl b/docker-bake.hcl index 88e200e..be2bacd 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -14,10 +14,6 @@ variable "frankenphpMatrix" { default = [ "8.2.35", "8.3.36", "8.4.27", "8.5.12" ] } -variable "phpLibraryRef" { - default = "e3eaff845f7a9f7259c31da54dce0320b47a3bbd" -} - variable "alpineVersion" { default = "3.24" } @@ -26,15 +22,28 @@ variable "frankenphpRef" { default = "v1.13.1" } +variable "debianVersion" { + default = "trixie" +} + +# Versions without a published official php image, written by scripts/prepare-php-sources.mjs +variable "phpFromSourceFpm" { + default = [] +} + +variable "phpFromSourceZts" { + default = [] +} + variable "goVersion" { default = "1.27" } -# PHP built from source with the upstream docker-library/php Dockerfiles +# PHP from the official images, built from the docker-library/php Dockerfiles (prepared locally) when not yet published target "php-fpm-alpine" { name = "php-fpm-alpine-${replace(substr(php, 0, 3), ".", "-")}" - context = "https://github.com/docker-library/php.git#${phpLibraryRef}:${substr(php, 0, 3)}/alpine${alpineVersion}/fpm" + context = "./.php-library/${substr(php, 0, 3)}/alpine${alpineVersion}/fpm" matrix = { "php" = phpMatrix } @@ -43,7 +52,7 @@ target "php-fpm-alpine" { target "php-zts-trixie" { name = "php-zts-trixie-${replace(substr(php, 0, 3), ".", "-")}" - context = "https://github.com/docker-library/php.git#${phpLibraryRef}:${substr(php, 0, 3)}/trixie/zts" + context = "./.php-library/${substr(php, 0, 3)}/${debianVersion}/zts" matrix = { "php" = frankenphpMatrix } @@ -58,8 +67,8 @@ target "frankenphp-upstream" { "php" = frankenphpMatrix } contexts = { - php-base = "target:php-zts-trixie-${replace(substr(php, 0, 3), ".", "-")}" - golang-base = "docker-image://docker.io/library/golang:${goVersion}-trixie" + php-base = contains(phpFromSourceZts, php) ? "target:php-zts-trixie-${replace(substr(php, 0, 3), ".", "-")}" : "docker-image://docker.io/library/php:${php}-zts-${debianVersion}" + golang-base = "docker-image://docker.io/library/golang:${goVersion}-${debianVersion}" } args = { "FRANKENPHP_VERSION" = frankenphpRef @@ -122,7 +131,7 @@ target "fpm" { "php" = phpMatrix } contexts = { - base = "target:php-fpm-alpine-${replace(substr(php, 0, 3), ".", "-")}" + base = contains(phpFromSourceFpm, php) ? "target:php-fpm-alpine-${replace(substr(php, 0, 3), ".", "-")}" : "docker-image://docker.io/library/php:${php}-fpm-alpine${alpineVersion}" } platforms = [ "linux/amd64", "linux/arm64" ] tags = imageSuffix != "" ? [ diff --git a/php-versions.json b/php-versions.json new file mode 100644 index 0000000..196b406 --- /dev/null +++ b/php-versions.json @@ -0,0 +1,18 @@ +{ + "8.2": { + "version": "8.2.35", + "sha256": "5737b98601fe5ab1b8c5db0c82ed3158acb9283ed3081ffe2faffb9ac81455e6" + }, + "8.3": { + "version": "8.3.36", + "sha256": "dd54693aa334c5c4d72e9aee96b082c2473750a18a6c1d105789c93a3b1692fd" + }, + "8.4": { + "version": "8.4.27", + "sha256": "dcf93923b0e38e0d5252b25b796520b5a47761a08b3488b6d0b5d1115764d3f0" + }, + "8.5": { + "version": "8.5.12", + "sha256": "08bc2eba2a588654ade1357174aa646b761556c862dda0678345ee29ed481209" + } +} diff --git a/scripts/bake-variables.mjs b/scripts/bake-variables.mjs new file mode 100644 index 0000000..a5728a0 --- /dev/null +++ b/scripts/bake-variables.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises'; + +export const bakeFilePath = 'docker-bake.hcl'; + +export async function readBakeFile() { + return fs.readFile(bakeFilePath, 'utf8'); +} + +export function readStringVariable(hclContent, name) { + const match = hclContent.match(new RegExp(`variable "${name}" \\{\\s*default = "([^"]*)"\\s*\\}`, 's')); + if (!match) { + throw new Error(`${name} variable not found in ${bakeFilePath}`); + } + + return match[1]; +} + +export function readListVariable(hclContent, name) { + const match = hclContent.match(new RegExp(`variable "${name}" \\{\\s*default = \\[([^\\]]*)\\]\\s*\\}`, 's')); + if (!match) { + throw new Error(`${name} variable not found in ${bakeFilePath}`); + } + + return [...match[1].matchAll(/"([^"]*)"/g)].map(([, value]) => value); +} + +export function replaceVariable(hclContent, name, value) { + const regex = new RegExp(`(variable "${name}" \\{\\s*default = )(\\[[^\\]]*\\]|"[^"]*")(\\s*\\})`, 's'); + + if (!regex.test(hclContent)) { + throw new Error(`${name} variable not found in ${bakeFilePath}`); + } + + return hclContent.replace(regex, `$1${value}$3`); +} diff --git a/scripts/prepare-php-sources.mjs b/scripts/prepare-php-sources.mjs new file mode 100644 index 0000000..3cafdf0 --- /dev/null +++ b/scripts/prepare-php-sources.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { readBakeFile, readListVariable, readStringVariable } from './bake-variables.mjs'; + +const exec = promisify(execFile); + +const libraryDirectory = '.php-library'; +const overridePath = 'php-from-source.hcl'; +const requiredPlatforms = ['linux/amd64', 'linux/arm64']; + +const hcl = await readBakeFile(); +const alpineVersion = readStringVariable(hcl, 'alpineVersion'); +const debianVersion = readStringVariable(hcl, 'debianVersion'); +const phpVersions = readListVariable(hcl, 'phpMatrix'); +const releases = JSON.parse(await fs.readFile('php-versions.json', 'utf8')); + +const variants = [ + { + variable: 'phpFromSourceFpm', + image: version => `docker.io/library/php:${version}-fpm-alpine${alpineVersion}`, + directory: minor => `${minor}/alpine${alpineVersion}/fpm`, + }, + { + variable: 'phpFromSourceZts', + image: version => `docker.io/library/php:${version}-zts-${debianVersion}`, + directory: minor => `${minor}/${debianVersion}/zts`, + }, +]; + +async function isPublishedForAllPlatforms(image) { + try { + const { stdout } = await exec('docker', ['buildx', 'imagetools', 'inspect', image, '--raw']); + const platforms = (JSON.parse(stdout).manifests ?? []) + .map(({ platform }) => platform && `${platform.os}/${platform.architecture}`); + + return requiredPlatforms.every(platform => platforms.includes(platform)); + } catch { + return false; + } +} + +function patchDockerfile(content, release) { + const patches = [ + [/^ENV PHP_VERSION .*$/m, `ENV PHP_VERSION ${release.version}`], + [/^(ENV PHP_URL=")[^"]*(" PHP_ASC_URL=")[^"]*(")$/m, `$1https://www.php.net/distributions/php-${release.version}.tar.xz$2https://www.php.net/distributions/php-${release.version}.tar.xz.asc$3`], + [/^ENV PHP_SHA256=".*"$/m, `ENV PHP_SHA256="${release.sha256}"`], + ]; + + return patches.reduce((patched, [pattern, replacement]) => { + if (!pattern.test(patched)) { + throw new Error(`Dockerfile does not match ${pattern}`); + } + + return patched.replace(pattern, replacement); + }, content); +} + +async function checkoutLibrary(directories) { + await fs.rm(libraryDirectory, { recursive: true, force: true }); + await fs.mkdir(libraryDirectory); + + const git = (...args) => exec('git', args, { cwd: libraryDirectory }); + await git('init', '--quiet'); + await git('fetch', '--quiet', '--depth', '1', 'https://github.com/docker-library/php.git', 'master'); + await git('checkout', '--quiet', 'FETCH_HEAD', '--', ...directories); +} + +const fromSource = Object.fromEntries(variants.map(({ variable }) => [variable, []])); + +for (const version of phpVersions) { + for (const variant of variants) { + if (await isPublishedForAllPlatforms(variant.image(version))) { + console.log(`Using published image ${variant.image(version)}`); + } else { + console.log(`Building ${variant.variable} ${version} from source`); + fromSource[variant.variable].push(version); + } + } +} + +const sourceDirectories = variants.flatMap(({ variable, directory }) => + fromSource[variable].map(version => directory(version.split('.').slice(0, 2).join('.')))); + +if (sourceDirectories.length > 0) { + await checkoutLibrary(sourceDirectories); + + for (const { variable, directory } of variants) { + for (const version of fromSource[variable]) { + const minor = version.split('.').slice(0, 2).join('.'); + const release = releases[minor]; + if (release?.version !== version) { + throw new Error(`php-versions.json does not contain PHP ${version}`); + } + + const dockerfilePath = `${libraryDirectory}/${directory(minor)}/Dockerfile`; + await fs.writeFile(dockerfilePath, patchDockerfile(await fs.readFile(dockerfilePath, 'utf8'), release)); + } + } +} + +const overrides = variants.map(({ variable }) => + `variable "${variable}" {\n default = [ ${fromSource[variable].map(version => `"${version}"`).join(', ')} ]\n}\n`); +await fs.writeFile(overridePath, overrides.join('\n')); +console.log(`Wrote ${overridePath}`); diff --git a/update-php-matrix.mjs b/update-php-matrix.mjs index b189d6f..ae6400b 100755 --- a/update-php-matrix.mjs +++ b/update-php-matrix.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; +import { bakeFilePath, readBakeFile, replaceVariable } from './scripts/bake-variables.mjs'; // Supported PHP major.minor versions const supportedVersions = ['8.2', '8.3', '8.4', '8.5']; const phpLibraryRepository = 'docker-library/php'; +const phpVersionsPath = 'php-versions.json'; async function fetchJson(url) { const response = await fetch(url); @@ -16,18 +18,13 @@ async function fetchJson(url) { return response.json(); } -async function fetchPhpLibraryRef() { - const commit = await fetchJson(`https://api.github.com/repos/${phpLibraryRepository}/commits/master`); - return commit.sha; -} - -async function fetchVersions(ref) { - return fetchJson(`https://raw.githubusercontent.com/${phpLibraryRepository}/${ref}/versions.json`); +async function fetchLibraryVersions() { + return fetchJson(`https://raw.githubusercontent.com/${phpLibraryRepository}/master/versions.json`); } // The official php:*-fpm-alpine images follow the newest Alpine release -function getLatestAlpineVersion(versions) { - const alpineVersions = supportedVersions.flatMap(version => versions[version].variants ?? []) +function getLatestAlpineVersion(libraryVersions) { + const alpineVersions = supportedVersions.flatMap(version => libraryVersions[version].variants ?? []) .map(variant => variant.match(/^alpine(\d+\.\d+)\/fpm$/)?.[1]) .filter(Boolean) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); @@ -39,45 +36,39 @@ function getLatestAlpineVersion(versions) { return alpineVersions.at(-1); } -function getPhpVersions(versions) { - return supportedVersions.map(version => { - const entry = versions[version]; - if (!entry || !/^\d+\.\d+\.\d+$/.test(entry.version)) { - throw new Error(`No valid version found for PHP ${version}`); - } - - return entry.version; - }); -} +// php.net publishes releases before docker-library/php picks them up +async function fetchLatestRelease(version) { + const release = await fetchJson(`https://www.php.net/releases/index.php?json&version=${version}`); + const tarball = release.source?.find(source => source.filename === `php-${release.version}.tar.xz`); -function replaceVariable(hclContent, name, value) { - const regex = new RegExp(`(variable "${name}" \\{\\s*default = )(\\[[^\\]]*\\]|"[^"]*")(\\s*\\})`, 's'); - - if (!regex.test(hclContent)) { - throw new Error(`${name} variable not found in docker-bake.hcl`); + if (!/^\d+\.\d+\.\d+$/.test(release.version) || !tarball?.sha256) { + throw new Error(`No valid release found for PHP ${version}`); } - return hclContent.replace(regex, `$1${value}$3`); + return { version: release.version, sha256: tarball.sha256 }; } -async function updateBakeFile(phpVersions, phpLibraryRef, alpineVersion) { - const hclPath = 'docker-bake.hcl'; - let hclContent = await fs.readFile(hclPath, 'utf8'); +async function fetchReleases() { + const entries = await Promise.all(supportedVersions.map(async version => [version, await fetchLatestRelease(version)])); + return Object.fromEntries(entries); +} +async function updateBakeFile(releases, alpineVersion) { + const phpVersions = supportedVersions.map(version => releases[version].version); const matrix = `[ ${phpVersions.map(v => `"${v}"`).join(', ')} ]`; + + let hclContent = await readBakeFile(); hclContent = replaceVariable(hclContent, 'phpMatrix', matrix); hclContent = replaceVariable(hclContent, 'frankenphpMatrix', matrix); - hclContent = replaceVariable(hclContent, 'phpLibraryRef', `"${phpLibraryRef}"`); hclContent = replaceVariable(hclContent, 'alpineVersion', `"${alpineVersion}"`); - await fs.writeFile(hclPath, hclContent); - console.log('Successfully updated docker-bake.hcl'); + await fs.writeFile(bakeFilePath, hclContent); + await fs.writeFile(phpVersionsPath, `${JSON.stringify(releases, null, 4)}\n`); + console.log(`Successfully updated ${bakeFilePath} and ${phpVersionsPath}`); console.log('PHP versions:', phpVersions); - console.log('docker-library/php ref:', phpLibraryRef); console.log('Alpine version:', alpineVersion); } -const phpLibraryRef = await fetchPhpLibraryRef(); -const versions = await fetchVersions(phpLibraryRef); +const libraryVersions = await fetchLibraryVersions(); -await updateBakeFile(getPhpVersions(versions), phpLibraryRef, getLatestAlpineVersion(versions)); +await updateBakeFile(await fetchReleases(), getLatestAlpineVersion(libraryVersions));