Files
XC_VM/.github/workflows/build-patch.yml
T
Divarion_D 9f67e7113a build: require PHP 8.3, the runtime the panel ships
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.
2026-10-24 20:45:32 +03:00

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