mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 11:03:22 +01:00
Composer, PHPStan, CI and `make dev-php` target PHP 8.3; the bundled php.ini loads the PHP 8.3 ionCube loader. Composer's platform check is off (vendor/composer/platform_check.php removed), and docs, agent prompts and comments name 8.3 instead of 8.1. The PHP 8.3 upgrade guide drops the manual rollback and stop-gap sections now that 2.7.2 moves MAIN and load balancers to PHP 8.3 on its own.
352 lines
16 KiB
YAML
352 lines
16 KiB
YAML
name: Build Patch
|
|
|
|
# A patch build is a release with the fixes made since: X.Y.Z-pN. It is built
|
|
# from a branch and published on the channel of the release it patches, with no
|
|
# release of its own: no version bump to commit, no changelog to write, no
|
|
# translation pass. The panel's updater takes it as it takes a release, on MAIN
|
|
# and on load balancers (docs/en/builds/patch_builds.md).
|
|
#
|
|
# Version: <base>-p<N>. <base> is the newest X.Y.Z tag the ref contains (or the
|
|
# one given), N the next number after its existing patch builds. PHP's
|
|
# version_compare() orders X.Y.Z < X.Y.Z-p1 < X.Y.Z-p2 < the next version's
|
|
# nightlies and release, so a panel moves from the release to its newest patch
|
|
# and on to the next release when there is one.
|
|
#
|
|
# The version is stamped into the build, as for a nightly: the ref keeps the
|
|
# release's version in its source. A ref whose source is already past the base
|
|
# (the next release is prepared) is refused: that is the next release, not a
|
|
# patch of this one.
|
|
#
|
|
# Published with the workflow's own token, so the release workflows, which
|
|
# build on `released` / `prereleased`, do not run a second time.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
ref:
|
|
description: 'Branch, tag or commit to build'
|
|
type: string
|
|
default: main
|
|
base:
|
|
description: 'Release to patch (X.Y.Z). Empty: the newest release the ref contains'
|
|
type: string
|
|
default: ''
|
|
dry_run:
|
|
description: 'Test and build only: publish nothing, keep the archives as a run artifact'
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
concurrency:
|
|
group: build-patch
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
plan:
|
|
name: Plan
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
sha: ${{ steps.plan.outputs.sha }}
|
|
base: ${{ steps.plan.outputs.base }}
|
|
version: ${{ steps.plan.outputs.version }}
|
|
prerelease: ${{ steps.plan.outputs.prerelease }}
|
|
latest: ${{ steps.plan.outputs.latest }}
|
|
steps:
|
|
- name: Checkout the ref
|
|
uses: actions/checkout@v7
|
|
with:
|
|
ref: ${{ inputs.ref }}
|
|
fetch-depth: 0
|
|
|
|
- name: Choose the base release and the patch number
|
|
id: plan
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
BASE_INPUT: ${{ inputs.base }}
|
|
run: |
|
|
set -euo pipefail
|
|
SHA=$(git rev-parse HEAD)
|
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
|
|
|
BASE="$BASE_INPUT"
|
|
if [ -z "$BASE" ]; then
|
|
BASE=$(git tag --merged "$SHA" --list --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
|
|
fi
|
|
if ! printf '%s' "$BASE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
echo "::error::No release to patch: '${BASE}' is not an X.Y.Z tag"
|
|
exit 1
|
|
fi
|
|
if ! git rev-parse -q --verify "refs/tags/${BASE}" >/dev/null; then
|
|
echo "::error::Tag ${BASE} does not exist"
|
|
exit 1
|
|
fi
|
|
if ! git merge-base --is-ancestor "refs/tags/${BASE}" "$SHA"; then
|
|
echo "::error::${SHA} does not contain release ${BASE}: a patch is the release with fixes on top"
|
|
exit 1
|
|
fi
|
|
if [ "$(git rev-list --count "refs/tags/${BASE}..${SHA}")" = "0" ]; then
|
|
echo "::error::Nothing to ship: the ref is release ${BASE} itself"
|
|
exit 1
|
|
fi
|
|
|
|
CODE_VER=$(sed -nE "s/.*define\('XC_VM_VERSION', '([^']+)'\).*/\1/p" src/Core/Config/ConstantsInitializer.php | head -n1)
|
|
if [ "$CODE_VER" != "$BASE" ]; then
|
|
echo "::error::The source is version ${CODE_VER}, not ${BASE}. A ref prepared for the next release is released, not shipped as a patch of ${BASE}; build from a branch that is still ${BASE}."
|
|
exit 1
|
|
fi
|
|
|
|
LAST_N=$(git tag --list "${BASE}-p*" | sed -nE "s/^${BASE//./\\.}-p([0-9]+)\$/\1/p" | sort -n | tail -n1)
|
|
if [ -n "$LAST_N" ] && [ "$(git rev-parse "refs/tags/${BASE}-p${LAST_N}^{commit}")" = "$SHA" ]; then
|
|
echo "::error::${BASE}-p${LAST_N} is already this commit"
|
|
exit 1
|
|
fi
|
|
VERSION="${BASE}-p$(( ${LAST_N:-0} + 1 ))"
|
|
|
|
# On the channel of the release it patches: a pre-release's patch is a pre-release.
|
|
PRERELEASE=$(gh release view "$BASE" -R "$GITHUB_REPOSITORY" --json isPrerelease --jq '.isPrerelease')
|
|
# "Latest" only for a patch of the newest stable release: a patch of an
|
|
# older one is published later and must not take its place.
|
|
LATEST=false
|
|
if [ "$PRERELEASE" = "false" ]; then
|
|
NEWEST=$(gh release list -R "$GITHUB_REPOSITORY" --exclude-pre-releases --exclude-drafts --limit 100 --json tagName --jq '.[].tagName' \
|
|
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1 || true)
|
|
[ "$NEWEST" = "$BASE" ] && LATEST=true
|
|
fi
|
|
|
|
echo "Patch build ${VERSION} of ${BASE} from ${SHA} (prerelease=${PRERELEASE}, latest=${LATEST})"
|
|
{
|
|
echo "base=${BASE}"
|
|
echo "version=${VERSION}"
|
|
echo "prerelease=${PRERELEASE}"
|
|
echo "latest=${LATEST}"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
build:
|
|
name: Test, build and publish
|
|
needs: plan
|
|
runs-on: ubuntu-latest
|
|
# The tests run on the database engine production runs (tests/Support/TestDb.php).
|
|
services:
|
|
mariadb:
|
|
image: mariadb:11.4
|
|
env:
|
|
MARIADB_ROOT_PASSWORD: xcvm-test
|
|
ports:
|
|
- 3306:3306
|
|
options: >-
|
|
--tmpfs /var/lib/mysql
|
|
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
|
--health-interval=5s
|
|
--health-timeout=5s
|
|
--health-retries=20
|
|
env:
|
|
XCVM_TEST_DB_DSN: mysql:host=127.0.0.1;port=3306
|
|
XCVM_TEST_DB_USER: root
|
|
XCVM_TEST_DB_PASS: xcvm-test
|
|
VERSION: ${{ needs.plan.outputs.version }}
|
|
BASE: ${{ needs.plan.outputs.base }}
|
|
SHA: ${{ needs.plan.outputs.sha }}
|
|
steps:
|
|
- name: Checkout the planned commit
|
|
uses: actions/checkout@v7
|
|
with:
|
|
ref: ${{ needs.plan.outputs.sha }}
|
|
fetch-depth: 0
|
|
|
|
- name: Setup PHP
|
|
uses: shivammathur/setup-php@v2
|
|
with:
|
|
php-version: '8.3'
|
|
coverage: none
|
|
extensions: pdo_mysql
|
|
|
|
- name: Run unit tests
|
|
run: php tests/phpunit.phar -c tests/phpunit.xml.dist
|
|
|
|
- name: Stamp the patch version
|
|
run: |
|
|
set -euo pipefail
|
|
FILE=src/Core/Config/ConstantsInitializer.php
|
|
sed -i -E "s/define\('XC_VM_VERSION', '[^']+'\)/define('XC_VM_VERSION', '${VERSION}')/" "$FILE"
|
|
grep -q "define('XC_VM_VERSION', '${VERSION}')" "$FILE" || { echo "::error::Version stamp failed"; exit 1; }
|
|
|
|
- name: Build LB and MAIN archives
|
|
run: |
|
|
make new
|
|
make lb LAST_TAG="${BASE}"
|
|
make main LAST_TAG="${BASE}"
|
|
|
|
# A patch is a small update, not a re-download of the whole panel. The
|
|
# updater overlays the archive onto the live tree (src/update: `cp -a`,
|
|
# never a wipe) then runs post-update, so the MAIN update archive only
|
|
# needs what differs from the base release: xc_vm.tar.gz — what the MAIN
|
|
# updater downloads — becomes a delta. XC_VM.zip stays the full installer
|
|
# (a fresh MAIN install lays down the whole tree).
|
|
#
|
|
# loadbalancer.tar.gz stays FULL. It is both the LB updater's download
|
|
# AND the LB's only installer: ServerInstallCommand pulls MAIN's own
|
|
# release (XC_VM_VERSION, which after a patch is X.Y.Z-pN) to provision a
|
|
# fresh node, and there is no LB equivalent of XC_VM.zip. A delta would
|
|
# lay a partial tree onto a clean node, so an LB enrolled from a patched
|
|
# MAIN would be broken. A full archive also updates correctly (the
|
|
# overlay rewrites every file) and `make lb LAST_TAG=BASE` already emits
|
|
# its deleted_files.txt relative to BASE.
|
|
#
|
|
# The MAIN delta is diffed on the BUILT TREES, base-release archive vs
|
|
# this build, NOT on `git diff`: that way a build-generated file (the
|
|
# composer autoloader, a generated config) is included too, and the
|
|
# version can never advance without every file that changed with it (no
|
|
# fail-open).
|
|
- name: Reduce the update archives to a delta
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
rm -rf .delta && mkdir -p .delta/base
|
|
# The patched release's own MAIN archive to diff against (BASE — the
|
|
# release this patch is of). It is an X.Y.Z release (never a -pN), so
|
|
# it is always a full tree. A failure here aborts the build rather
|
|
# than shipping a wrong delta. Only xc_vm.tar.gz is deltaed, so the
|
|
# LB base archive is not needed.
|
|
gh release download "$BASE" -R "$GITHUB_REPOSITORY" -p xc_vm.tar.gz -D .delta/base
|
|
|
|
# Files removed from the codebase since the base release, so a patch
|
|
# that deletes (or renames away) a file — a vulnerable script, say —
|
|
# does not leave it deployed. The overlay cannot remove a file, so
|
|
# these are added to the shipped deleted_files.txt.
|
|
#
|
|
# Produced by the SAME pipeline as `make generate_deleted_files`
|
|
# (--name-status --no-renames, awk on 'D', strip src/): identical to
|
|
# the lines the committed list already holds, so there is no format
|
|
# differential for MigrationRunner::runFileCleanup to trip on, and
|
|
# --no-renames makes a rename list its old path as a deletion. Scoped
|
|
# to tracked src/: the live cleanup rm's whatever this lists, so it
|
|
# must never carry a runtime config/content/bin path.
|
|
mapfile -t DELETED < <(git diff --name-status --no-renames "refs/tags/${BASE}..${SHA}" -- src/ | awk '$1 == "D" { sub(/^src\//, "", $2); print $2 }')
|
|
|
|
# <patch archive> <base archive> <out archive> <kind> <append-deletions>:
|
|
# <out> gets the files new or changed vs the base tree — by content,
|
|
# mode OR type, so a chmod or a file→symlink flip is not missed.
|
|
make_delta() {
|
|
local patchA="$1" baseA="$2" outA="$3" kind="$4" appendDel="$5"
|
|
local p="$PWD/.delta/$kind-patch" b="$PWD/.delta/$kind-base"
|
|
mkdir -p "$p" "$b"
|
|
tar -xzf "$patchA" -C "$p"
|
|
tar -xzf "$baseA" -C "$b"
|
|
|
|
# Fold this patch's source deletions into the shipped delete list
|
|
# (MAIN ships the committed list, which predates the patch; the LB
|
|
# list is regenerated by the build, so it is left alone).
|
|
local dfl="$p/migrations/deleted_files.txt"
|
|
if [ "$appendDel" = 1 ] && [ -f "$dfl" ]; then
|
|
local d
|
|
for d in ${DELETED+"${DELETED[@]}"}; do
|
|
[ -n "$d" ] && { grep -qxF "$d" "$dfl" || echo "$d" >> "$dfl"; }
|
|
done
|
|
fi
|
|
|
|
local list="$PWD/.delta/$kind.list"; : > "$list"
|
|
local f tp tb
|
|
while IFS= read -r f; do
|
|
if [ ! -e "$b/$f" ] && [ ! -L "$b/$f" ]; then echo "$f" >> "$list"; continue; fi
|
|
tp=$([ -L "$p/$f" ] && echo l || echo f); tb=$([ -L "$b/$f" ] && echo l || echo f)
|
|
if [ "$tp" != "$tb" ]; then echo "$f" >> "$list"
|
|
elif [ "$tp" = l ]; then [ "$(readlink "$p/$f")" != "$(readlink "$b/$f")" ] && echo "$f" >> "$list" || true
|
|
elif [ "$(stat -c '%a' "$p/$f")" != "$(stat -c '%a' "$b/$f")" ]; then echo "$f" >> "$list"
|
|
elif ! cmp -s "$p/$f" "$b/$f"; then echo "$f" >> "$list"; fi
|
|
done < <(cd "$p" && find . \( -type f -o -type l \) | sort)
|
|
|
|
grep -qxF "./Core/Config/ConstantsInitializer.php" "$list" \
|
|
|| { echo "::error::$kind delta is missing the stamped version file — the version would advance without its code; aborting"; exit 1; }
|
|
# Ship every directory the delta touches as an explicit entry, at
|
|
# its built-tree mode, ahead of the changed files. Why: the
|
|
# deployed updater extracts the archive into a tempfile.mkdtemp()
|
|
# dir (mode 0700) and then runs `cp -a <tmp>/. <install root>`,
|
|
# which copies each staging directory's OWN mode onto the live
|
|
# tree. A full archive carries every directory entry; a file-list
|
|
# delta carries none, so tar would create `.`, `Core/`, `Public/`…
|
|
# in the staging dir at the updater host's umask — `.` is 0700 from
|
|
# mkdtemp, and the rest 0750/0700 under a tight root umask — and
|
|
# stamp that onto the install root. `status` then chowns the code
|
|
# trees to root:xc_vm (TreeOwnership::rootOwn), so a 0700 directory
|
|
# locks the xc_vm group out and nginx/the autoloader cannot start.
|
|
# Shipping the dir entries makes the modes deterministic and equal
|
|
# to a full update. --no-recursion: each named dir/file alone, not
|
|
# its subtree.
|
|
chmod 0755 "$p"
|
|
local dirs="$PWD/.delta/$kind.dirs"
|
|
awk -F/ '{ d = "."; for (i = 2; i < NF; i++) { d = d "/" $i; print d } }' "$list" | sort -u > "$dirs"
|
|
{ echo .; cat "$dirs" "$list"; } > "$list.full"
|
|
tar -C "$p" --owner=0 --group=0 --no-recursion -czf "$outA" -T "$list.full"
|
|
echo "$kind delta: $(wc -l < "$list") files ($(du -h "$outA" | cut -f1))"
|
|
sed "s#^# $kind: #" "$list"
|
|
}
|
|
|
|
# Only the MAIN archive becomes a delta. loadbalancer.tar.gz is left
|
|
# as the full archive `make lb` produced — it is the LB's installer
|
|
# as well as its update payload (see the note above the diff step).
|
|
make_delta dist/xc_vm.tar.gz .delta/base/xc_vm.tar.gz dist/xc_vm.tar.gz main 1
|
|
|
|
# Rebuild the hash file over the final assets (the updater refuses an
|
|
# asset hashes.md5 does not list); the full installer is unchanged.
|
|
( cd dist && for a in XC_VM.zip xc_vm.tar.gz loadbalancer.tar.gz; do
|
|
md5sum "$a" | awk -v n="$a" '{print $1, n}'
|
|
done > hashes.md5 )
|
|
rm -rf .delta
|
|
|
|
- name: Write changelog and release notes
|
|
run: |
|
|
set -euo pipefail
|
|
# Everything the update delivers: the changes since BASE, the release
|
|
# this patch is of (which the delta covers).
|
|
git log --no-merges --format='%s' "refs/tags/${BASE}..${SHA}" \
|
|
| jq -R . | jq -s --arg v "$VERSION" '{version: $v, changes: .}' > dist/changelog.json
|
|
{
|
|
echo "Patch build of **${BASE}**: the release with the fixes made since, as a small update. Update as usual; no new release is needed."
|
|
echo
|
|
echo "Source: https://github.com/${GITHUB_REPOSITORY}/commit/${SHA}"
|
|
echo
|
|
echo "## Changes since ${BASE}"
|
|
git log --no-merges --format='- %s (%h)' "refs/tags/${BASE}..${SHA}"
|
|
} > "${RUNNER_TEMP}/notes.md"
|
|
cat "${RUNNER_TEMP}/notes.md"
|
|
|
|
- name: Keep the archives (dry run)
|
|
if: ${{ inputs.dry_run }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ${{ needs.plan.outputs.version }}
|
|
path: dist/*
|
|
retention-days: 7
|
|
|
|
- name: Publish the patch build
|
|
if: ${{ !inputs.dry_run }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PRERELEASE: ${{ needs.plan.outputs.prerelease }}
|
|
LATEST: ${{ needs.plan.outputs.latest }}
|
|
run: |
|
|
set -euo pipefail
|
|
FLAGS=(--latest="${LATEST}")
|
|
if [ "$PRERELEASE" = "true" ]; then
|
|
FLAGS=(--prerelease)
|
|
fi
|
|
gh release create "$VERSION" dist/* \
|
|
-R "$GITHUB_REPOSITORY" \
|
|
--target "$SHA" \
|
|
--title "$VERSION" \
|
|
--notes-file "${RUNNER_TEMP}/notes.md" \
|
|
"${FLAGS[@]}"
|
|
|
|
notify:
|
|
needs: [plan, build]
|
|
if: ${{ !inputs.dry_run }}
|
|
uses: ./.github/workflows/release-notifier.yml
|
|
with:
|
|
tag_name: ${{ needs.plan.outputs.version }}
|
|
release_name: ${{ needs.plan.outputs.version }}
|
|
is_prerelease: ${{ needs.plan.outputs.prerelease == 'true' }}
|
|
secrets: inherit
|