Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .github/action/build-bake-publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
with:
files: |
docker-bake.hcl
php-versions.json
scripts/**
frankenphp*/**

- name: Build
Expand All @@ -61,6 +63,8 @@ jobs:
with:
files: |
docker-bake.hcl
php-versions.json
scripts/**
frankenphp*/**

- name: Build
Expand All @@ -85,6 +89,8 @@ jobs:
with:
files: |
docker-bake.hcl
php-versions.json
scripts/**
fpm*/**
nginx/**
caddy/**
Expand Down Expand Up @@ -113,6 +119,8 @@ jobs:
with:
files: |
docker-bake.hcl
php-versions.json
scripts/**
fpm*/**
nginx/**
caddy/**
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/.idea
composer.lock
vendor
vendor
/.php-library
/php-from-source.hcl
29 changes: 19 additions & 10 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" ? [
Expand Down
18 changes: 18 additions & 0 deletions php-versions.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
35 changes: 35 additions & 0 deletions scripts/bake-variables.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
107 changes: 107 additions & 0 deletions scripts/prepare-php-sources.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
61 changes: 26 additions & 35 deletions update-php-matrix.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 }));
Expand All @@ -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));
Loading