From ca961e78072e12bffd2e6f0926cebef1ae817f2f Mon Sep 17 00:00:00 2001 From: le0m Date: Sat, 24 Oct 2026 21:12:02 +0200 Subject: [PATCH] chore: replace compat workflows --- .github/actions/track-patches/action.yml | 16 +++ .github/actions/track-patches/index.mjs | 171 +++++++++++++++++++++++ .github/workflows/build-images.yml | 16 +++ .github/workflows/publish.compat.yml | 71 ---------- .github/workflows/test.compat.yml | 55 -------- .github/workflows/track-patches.yml | 23 +++ 6 files changed, 226 insertions(+), 126 deletions(-) create mode 100644 .github/actions/track-patches/action.yml create mode 100644 .github/actions/track-patches/index.mjs delete mode 100644 .github/workflows/publish.compat.yml delete mode 100644 .github/workflows/test.compat.yml create mode 100644 .github/workflows/track-patches.yml diff --git a/.github/actions/track-patches/action.yml b/.github/actions/track-patches/action.yml new file mode 100644 index 0000000..df45791 --- /dev/null +++ b/.github/actions/track-patches/action.yml @@ -0,0 +1,16 @@ +name: Track deb.sury.org patches +description: > + For each EOL version in eol/, compares the Salsa commit pinned in its Dockerfiles' DEB_PATCHES + with the one deb.sury.org last built from, and opens or updates an issue when they differ. + +inputs: + token: + description: Token used to read and write the repository's issues. + default: ${{ github.token }} + distribution: + description: Debian release whose deb.sury.org source index is read. + default: trixie + +runs: + using: node24 + main: index.mjs diff --git a/.github/actions/track-patches/index.mjs b/.github/actions/track-patches/index.mjs new file mode 100644 index 0000000..3db608c --- /dev/null +++ b/.github/actions/track-patches/index.mjs @@ -0,0 +1,171 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; + +const SALSA = 'https://salsa.debian.org/php-team/php'; +const SALSA_API = 'https://salsa.debian.org/api/v4/projects/php-team%2Fphp'; + +const token = process.env['INPUT_TOKEN']; +const distribution = process.env['INPUT_DISTRIBUTION'] || 'trixie'; +const repository = process.env.GITHUB_REPOSITORY; +const githubApi = process.env.GITHUB_API_URL || 'https://api.github.com'; + +// Salsa sometimes answers with a 502, so server and network errors are retried. +async function download(url, attempts = 5) { + for (let attempt = 1; ; attempt++) { + const response = await fetch(url).catch((error) => ({ ok: false, status: 0, error })); + if (response.ok) { + return response; + } + const clientError = response.status >= 400 && response.status < 500; + if (clientError || attempt === attempts) { + throw new Error(`${url}: ${response.error?.message ?? `HTTP ${response.status}`}`); + } + await new Promise((resolve) => setTimeout(resolve, attempt * 3000)); + } +} + +async function github(method, path, body) { + const response = await fetch(`${githubApi}${path}`, { + method, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + }, + body: body && JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`GitHub API ${method} ${path}: HTTP ${response.status} ${await response.text()}`); + } + return response.json(); +} + +/** The EOL versions: every eol/ with a cli Dockerfile. */ +function eolVersions() { + return readdirSync('eol', { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(join('eol', entry.name, 'cli', 'Dockerfile'))) + .map((entry) => entry.name) + .sort(); +} + +/** The Salsa commit pinned in DEB_PATCHES, which every flavour of the version must agree on. */ +function pinnedCommit(version) { + const commits = new Set(); + for (const flavor of readdirSync(join('eol', version))) { + const dockerfile = join('eol', version, flavor, 'Dockerfile'); + const match = readFileSync(dockerfile, 'utf8').match(/DEB_PATCHES=\S*\/archive\/([0-9a-f]{40})\//); + if (!match) { + throw new Error(`${dockerfile} has no DEB_PATCHES commit`); + } + commits.add(match[1]); + } + if (commits.size !== 1) { + throw new Error(`the Dockerfiles pin different commits: ${[...commits].join(', ')}`); + } + return [...commits][0]; +} + +/** Package name → version, from deb.sury.org's index of source packages. */ +async function latestBuilds() { + const response = await download(`https://packages.sury.org/php/dists/${distribution}/main/source/Sources.gz`); + const index = gunzipSync(Buffer.from(await response.arrayBuffer())).toString('utf8'); + const builds = new Map(); + for (const stanza of index.split(/\n\s*\n/)) { + const name = stanza.match(/^Package: (\S+)$/m)?.[1]; + const version = stanza.match(/^Version: (\S+)$/m)?.[1]; + if (name && version && !builds.has(name)) { + builds.set(name, version); + } + } + return builds; +} + +/** + * Splits a build version such as "1:7.4.33-31+0~20261021.121+debian13~1.gbpe9c428" into its + * revision without epoch ("7.4.33-31") and the start of the Salsa commit it was built from + * ("e9c428"). + */ +function parseBuild(version) { + const match = version.match(/^(?:\d+:)?([^+]+)\+.*\.gbp([0-9a-f]+)$/); + return match && { revision: match[1], commitPrefix: match[2] }; +} + +async function fullCommit(prefix) { + const response = await download(`${SALSA_API}/repository/commits/${prefix}`); + return (await response.json()).id; +} + +async function seriesPatches(commit) { + const response = await download(`${SALSA}/-/raw/${commit}/debian/patches/series`); + return (await response.text()) + .split('\n') + .map((line) => line.trim().split(/\s+/)[0]) + .filter((name) => name && !name.startsWith('#')); +} + +async function openIssues() { + const issues = []; + for (let page = 1; ; page++) { + const batch = await github('GET', `/repos/${repository}/issues?state=open&per_page=100&page=${page}`); + issues.push(...batch.filter((issue) => !issue.pull_request)); + if (batch.length < 100) { + return issues; + } + } +} + +async function check(version, builds, issues) { + const pinned = pinnedCommit(version); + const build = builds.get(`php${version}`); + const parsed = build && parseBuild(build); + if (!parsed) { + throw new Error(`no php${version} build with a commit in the deb.sury.org index (got ${build ?? 'nothing'})`); + } + if (pinned.startsWith(parsed.commitPrefix)) { + console.log(`PHP ${version}: up to date (${build})`); + return; + } + + const latest = await fullCommit(parsed.commitPrefix); + const [before, after] = await Promise.all([seriesPatches(pinned), seriesPatches(latest)]); + const added = after.filter((name) => !before.includes(name)); + + const title = `PHP ${version} patches: new deb.sury.org build ${parsed.revision}`; + const body = [ + `deb.sury.org published \`${build}\`, built from Salsa commit [\`${latest.slice(0, 12)}\`](${SALSA}/-/commit/${latest}). Our Dockerfiles pin [\`${pinned.slice(0, 12)}\`](${SALSA}/-/commit/${pinned}).`, + '', + `Changes: ${SALSA}/-/compare/${pinned}...${latest}`, + '', + 'Patches added to its series:', + ...(added.length ? added.map((name) => `- \`${name}\``) : ['None.']), + '', + `To update, set the new commit in \`DEB_PATCHES\` and the revision in \`PHP_PATCHES\` in every \`eol/${version}/*/Dockerfile\`, and review each new patch into \`eol/patches/series.${version}\`, applied or commented out.`, + ].join('\n'); + + const existing = issues.find((issue) => issue.title.startsWith(`PHP ${version} patches:`)); + if (existing) { + await github('PATCH', `/repos/${repository}/issues/${existing.number}`, { title, body }); + console.log(`PHP ${version}: updated issue #${existing.number} (${build})`); + } else { + const issue = await github('POST', `/repos/${repository}/issues`, { title, body }); + console.log(`PHP ${version}: opened issue #${issue.number} (${build})`); + } +} + +try { + const builds = await latestBuilds(); + const issues = await openIssues(); + for (const version of eolVersions()) { + try { + await check(version, builds, issues); + } catch (error) { + console.log(`::error::PHP ${version}: ${error.message}`); + process.exitCode = 1; + } + } +} catch (error) { + console.log(`::error::${error.message}`); + process.exitCode = 1; +} diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index f813465..2a42907 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -70,6 +70,7 @@ jobs: [[ "$VERSION" == '5.6' || "$VERSION" == '7.0' ]] && pcov=false || pcov=true [[ "$ARCH" == 'amd64' ]] && xhprof=true || xhprof=false [[ "$VERSION" == 8.* || "$VERSION" == 'latest' || "$VERSION" == 'alpine' ]] && otel=true || otel=false + [[ "$VERSION" == '5.6' ]] && compat=true || compat=false { echo "tag=$tag" echo "eol=$eol" @@ -79,6 +80,7 @@ jobs: echo "pcov=$pcov" echo "xhprof=$xhprof" echo "otel=$otel" + echo "compat=$compat" } >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx @@ -184,6 +186,20 @@ jobs: no-cache: ${{ env.NO_CACHE }} test: otel + - name: Build compat image + if: ${{ steps.plan.outputs.compat == 'true' }} + uses: ./.github/actions/build-image + with: + image: php-compat + tag: ${{ steps.plan.outputs.tag }} + arch: ${{ matrix.arch }} + context: ./compat + build-args: BASE_IMAGE=${{ steps.php.outputs.ref }} + push: ${{ env.PUSH }} + cache-write: ${{ env.CACHE_WRITE }} + no-cache: ${{ env.NO_CACHE }} + test: compat + - name: Upload digests if: ${{ env.PUSH == 'ghcr' }} uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish.compat.yml b/.github/workflows/publish.compat.yml deleted file mode 100644 index ffe5b37..0000000 --- a/.github/workflows/publish.compat.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Publish compatibility images - -on: - workflow_run: - workflows: ["Test compatibility images"] - branches: [main] - types: [completed] - -# Every completed test run starts this workflow; runs that won't publish get their own group, so -# they can't cancel a publish in progress. -concurrency: - group: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request' && github.event.workflow_run.head_sha == github.sha && github.workflow || github.run_id }} - cancel-in-progress: true - -jobs: - publish-compat: - name: Build and publish compatibility images - runs-on: ubuntu-22.04 - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request' && github.event.workflow_run.head_sha == github.sha }} - strategy: - fail-fast: false - matrix: - version: [ '5.6' ] - flavor: [ '', '-apache', '-fpm' ] - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - with: - driver-opts: network=host - - - name: Login to DockerHub (pull) - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_PULL_USER_4 }} - password: ${{ secrets.DOCKER_PULL_PASS_4 }} - - - name: Pull base image - run: docker pull debian:stretch-slim - - - name: Login to DockerHub (push) - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build image and push to registry - uses: docker/build-push-action@v7 - with: - platforms: linux/amd64,linux/arm64 -# cache-from: type=registry,ref=ghcr.io/chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} - cache-to: type=inline - context: ./compat - build-contexts: patches=eol/patches - file: ${{ format('compat/{0}.{1}{2}', 'Dockerfile', matrix.version, matrix.flavor) }} - tags: | - chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} - ghcr.io/chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} - push: true diff --git a/.github/workflows/test.compat.yml b/.github/workflows/test.compat.yml deleted file mode 100644 index 8e52382..0000000 --- a/.github/workflows/test.compat.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Test compatibility images - -on: - schedule: - # chosen by fair dice roll - - cron: '42 18 * * 2' - push: - branches: [main] - pull_request: - types: [opened, synchronize] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test-compat: - name: Build and test compatibility images - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - version: [ '5.6' ] - flavor: [ '', '-apache', '-fpm' ] - services: - registry: - image: registry:2 - ports: - - 5000:5000 - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Login to DockerHub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_PULL_USER_4 }} - password: ${{ secrets.DOCKER_PULL_PASS_4 }} - - - name: Build image for testing - uses: docker/build-push-action@v7 - with: -# cache-from: type=registry,ref=ghcr.io/chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} - cache-to: type=inline - context: ./compat - build-contexts: patches=eol/patches - file: ${{ format('compat/{0}.{1}{2}', 'Dockerfile', matrix.version, matrix.flavor) }} - tags: localhost:5000/chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} - push: true - - - name: Test image - env: - REGISTRY: localhost:5000/ - VERSION: ${{ matrix.version }}${{ matrix.flavor }} - run: 'docker pull localhost:5000/chialab/php-compat:${{ matrix.version }}${{ matrix.flavor }} && make -C compat test' diff --git a/.github/workflows/track-patches.yml b/.github/workflows/track-patches.yml new file mode 100644 index 0000000..fa8922e --- /dev/null +++ b/.github/workflows/track-patches.yml @@ -0,0 +1,23 @@ +name: Track deb.sury.org patches + +# Opens an issue when deb.sury.org publishes a build of an EOL version from a newer Salsa commit +# than the one pinned in its Dockerfiles' DEB_PATCHES. + +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +jobs: + track: + name: Compare pinned commits with deb.sury.org builds + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Check each EOL version + uses: ./.github/actions/track-patches