xcp-ng-ce-iso
ISO assembly pipeline — takes the community RPM builds and releases a bootable XCP-ng HL ISO.
Repository: Vagrantin/xcp-ng-ce-iso · Language: Bash / YAML · License: AGPL-3.0
Table of contents
- Purpose
- Toolchain — create-install-image
- Repo structure
- How packages reach the installed host
- Docker build environment
- Build process — step by step
- install.img — internals
- CI workflow (GitHub Actions)
- Contributing
Purpose
This repo takes the signed xo-lite-ce RPM produced by xolite-ce and the signed xoa-proxy RPM produced by xoa-proxy, overlays them onto a stock XCP-ng 8.3 base using the official create-install-image toolchain, and publishes the resulting ISO as a GitHub Release.
Toolchain — create-install-image
XCP-ng’s official installer ISO is assembled with the create-install-image toolchain. XCP-ng HL uses it directly rather than maintaining a fork.
The toolchain provides two scripts:
| Script | Runs as | Produces |
|---|---|---|
create-installimg.sh | root | install.img (SquashFS ramdisk) |
create-iso.sh | non-root | final .iso file |
They must be run separately, in order. create-iso.sh accepts a --sign-script argument for MD5 imprinting.
Repo structure
xcp-ng-ce-iso/
├── configs/
│ ├── base/
│ │ └── CUSTOMREPO.tmpl ← community repo template
│ └── 8.3/ ← XCP-ng 8.3 config (target)
├── community-repo/
│ └── x86_64/
│ └── repodata/ ← generated by createrepo_c
├── scripts/
│ └── debug/ ← debug helper scripts (overlaid in CI)
├── Dockerfile.build ← build environment
└── .github/workflows/
└── build-iso.yml
CUSTOMREPO.tmpl
The community repo definition template must be copied from configs/base/ to configs/8.3/ before the build runs:
cp configs/base/CUSTOMREPO.tmpl configs/8.3/CUSTOMREPO.tmpl
This file tells the installer where to find the community RPM repo. The --define-repo flag passes it to both build scripts.
Community RPM repository layout
yum automatically appends the host architecture (x86_64/) to the base URL configured in a repo file. Therefore the community repo must be structured with repodata/ under the arch subdirectory:
community-repo/
└── x86_64/
├── xo-lite-ce-*.rpm
├── xoa-proxy-*.rpm
├── xcp-hl-release-*.rpm
└── repodata/
├── repomd.xml
└── ...
Generate the repo metadata against the arch subdirectory:
createrepo_c community-repo/x86_64/
How packages reach the installed host
install.img is the installer’s own ramdisk, not the installed host’s filesystem. Adding a package to packages.lst puts it in the installer environment and nowhere else.
The installed host is built by host-installer from the ISO’s Packages/ directory, and it installs xcp-ng-deps plus that package’s dependency closure. Staging an RPM on the media is therefore not enough on its own — if nothing in the closure requires it, it sits in the ISO repo unused.
The community packages ride in on this chain:
xcp-ng-deps
└── requires xo-lite ──► provided by xo-lite-ce
├── requires xoa-proxy
└── requires xcp-hl-release
That Requires: is the only route in. xcp-hl-release used to be staged by name with --extra-packages as well, but that flag only places a package in the ISO’s repo without selecting it for install, so once the dependency existed it was redundant and only obscured which mechanism delivered the package.
A consequence worth knowing: pinning an xolite-ce release from before the Requires: was added produces an ISO with no xcp-hl-release on it. The build’s verification step catches that rather than shipping it.
Verify on a host installed from the ISO:
rpm -q xcp-hl-release
ls /etc/yum.repos.d/xcp-hl.repo
Docker build environment
The build runs inside xcp-ng-build-env:8.3, committed as xcp-ng-build-ready after initial setup:
# First run: commit the prepared image
docker run --name xcpng-build xcp-ng/xcp-ng-build-env:8.3 /bin/true
docker commit xcpng-build xcp-ng-build-ready
Build process — step by step
1. Prepare the community repo
# Download signed RPM from xolite-ce latest release
gh release download --repo Vagrantin/xolite-ce \
--pattern "xo-lite-ce-*.rpm" \
--dir community-repo/x86_64/
# Download signed RPM from xoa-proxy latest release
gh release download --repo Vagrantin/xoa-proxy \
--pattern "xoa-proxy-*.rpm" \
--dir community-repo/x86_64/
# Generate repo metadata
createrepo_c community-repo/x86_64/
2. Inject the community public key into the installer chroot
The ISO signing subkey (GPG_PRIVATE_KEY in this repo) is imported into the runner’s keyring at the start of the pipeline. The public half is then exported from that keyring and injected into the installer chroot templates, so the installer can verify the community RPMs during installation without any manual key import by the end user.
rpm --import inside a container writes to the container’s RPM database, not to the chroot installroot that ends up in install.img. Always pass --root to target the correct location.
# Export the public key from the runner keyring
gpg --armor --export "${GPG_KEY_ID}" > /tmp/RPM-GPG-KEY-xcp-ng-ce
# Copy into the toolchain's installroot template
mkdir -p create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
cp /tmp/RPM-GPG-KEY-xcp-ng-ce \
create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
# Import into the installer chroot at build time
rpm --root="${ROOTFS}" --import /etc/pki/rpm-gpg/RPM-GPG-KEY-xcp-ng-ce
3. Run create-installimg.sh (root)
# Must run as root inside the build container
sudo ./create-installimg.sh \
--define-repo base \
--define-repo updates \
--define-repo community \
[other options]
The --define-repo flag must list all three repos: base, updates, and community.
4. Stamp the ISO build number
host-installer reads [build] number from the media’s .treeinfo and writes it to BUILD_NUMBER in /etc/xensource-inventory on the installed host. Upstream ships the placeholder cloud, and nothing else on an installed host records which media it came from — the volume label does not survive the install, and the logs kept in /var/log/installer/ do not mention it.
Stamp the ce counter into the template before create-iso.sh copies it into the ISO. Only @@...@@ tokens get substituted, so a literal value passes through untouched:
sed -i "s/^number = .*/number = ${CE_COUNTER}/" \
create-install-image/templates/iso/8.3/.treeinfo
On a host installed from the resulting ISO:
$ grep BUILD_NUMBER /etc/xensource-inventory
BUILD_NUMBER='ce23'
# xapi reads that inventory, so it is visible remotely too
$ xe host-param-get uuid=<host-uuid> param-name=software-version
... build_number: ce23; ...
5. Run create-iso.sh (non-root)
./create-iso.sh \
--sign-script implantisomd5 \
--define-repo base \
--define-repo updates \
--define-repo community \
[other options]
6. isohybrid post-processing
This step is required for the ISO to boot on physical hardware that doesn’t support UEFI.
isohybrid --uefi output.iso
Verify the result with:
fdisk -l output.iso
xorriso -report_el_torito output.iso
7. Checksum and sign
Each release ships three files alongside the ISO. The checksum file is named after the ISO it covers:
xcp-ng-ce-8.3.iso
xcp-ng-ce-8.3.iso.sha256
xcp-ng-ce-8.3.iso.sha256.asc
The checksum file contains the SHA256 hash of the ISO. The .asc file is a GPG detached signature over the checksum file, produced with the ISO signing subkey. Together they form a two-step verification chain:
ISO signing subkey
└── signs ──► xcp-ng-ce-8.3.iso.sha256 (contains hash of the ISO)
└── hash matches ──► xcp-ng-ce-8.3.iso
# Produce the checksum file
sha256sum xcp-ng-ce-8.3.iso > xcp-ng-ce-8.3.iso.sha256
# Sign it with the ISO signing subkey
gpg --batch --pinentry-mode loopback \
--detach-sign --armor \
xcp-ng-ce-8.3.iso.sha256
# → produces xcp-ng-ce-8.3.iso.sha256.asc
8. Verifying a release (end user procedure)
All three files must be downloaded into the same directory before verifying. sha256sum -c looks for the ISO by filename in the current directory.
# 1. Download all three files into the same directory
cd ~/Downloads
# download xcp-ng-ce-8.3.iso, xcp-ng-ce-8.3.iso.sha256,
# xcp-ng-ce-8.3.iso.sha256.asc from the GitHub release page
# 2. Import the community GPG key (one-time setup)
gpg --keyserver keys.openpgp.org \
--recv-keys 2F591DB9D2C128C4C3D963F46DA00DCA5BBA215A
Step 3 — verify the checksum file was signed by this project:
gpg --verify xcp-ng-ce-8.3.iso.sha256.asc \
xcp-ng-ce-8.3.iso.sha256
Expected output (the important line is Good signature):
gpg: Signature made ...
gpg: Good signature from "XCP-ng home lab Edition <xcp-ng-ce.lid530@passmail.com>"
If you see BAD signature the checksum file has been tampered with — do not proceed.
Step 4 — verify the ISO matches the signed checksum:
sha256sum -c xcp-ng-ce-8.3.iso.sha256
Expected output:
xcp-ng-ce-8.3.iso: OK
If you see FAILED the ISO file is corrupted or was replaced — delete it and re-download.
install.img — internals
install.img is a bzip2-compressed cpio (newc) archive, not SquashFS. create-installimg.sh builds it with find . | cpio -o -H newc | bzip2, so unsquashfs/mksquashfs do not apply.
It holds the installer’s ramdisk, not the installed host’s filesystem — see How packages reach the installed host.
Unpacking and repacking
# Unpack
mkdir installimg-root
bzip2 -dc install.img | (cd installimg-root && cpio -idm)
# Make changes inside installimg-root/
# ...
# Repack (matching upstream: cpio newc, bzip2)
(cd installimg-root && find . | cpio -o -H newc) | bzip2 > install.img.new
List the contents without unpacking:
bzip2 -dc install.img | cpio -it
answerfile.xml injection (optional)
answerfile.xml referenced via file:/// in the installer resolves to the ramdisk root (from install.img), not the ISO CD-ROM root. To inject an answerfile for unattended installs, place it inside install.img via the repack workflow above.
Disable GPG-related checks via answerfile attributes:
<installation gpgcheck="false" repo-gpgcheck="false">
...
</installation>
CI workflow (GitHub Actions)
The workflow is dispatch-only. The iso-agent pushes the release tag and then dispatches the workflow on that tag ref, passing the exact component release tags to bake in. A plain tag push no longer triggers a build: resolving components via releases/latest raced with freshly published component releases and could bake a stale RPM.
Key environment requirements:
env:
TMPDIR: /tmp # Must be set here AND in Dockerfile
HOME: /tmp # Required for non-root create-iso.sh step
Key steps:
- name: Derive versions from tag
run: |
TAG="$" # e.g. v8.3-ce4
XCPNG_VER=$(echo "${TAG}" | sed 's/^v//;s/-ce.*//') # → 8.3
echo "XCPNG_VER=${XCPNG_VER}" >> $GITHUB_ENV
- name: Import ISO signing key
# GPG_PRIVATE_KEY in this repo holds the ISO signing subkey —
# different key material from GPG_PRIVATE_KEY in xolite-ce / xoa-proxy
run: |
echo "$" | gpg --batch \
--pinentry-mode loopback \
--passphrase "$" --import
- name: Export public key for installer chroot
run: |
gpg --armor --export "${GPG_KEY_ID}" > /tmp/RPM-GPG-KEY-xcp-ng-ce
cp /tmp/RPM-GPG-KEY-xcp-ng-ce \
create-install-image/templates/installimg/base/etc/pki/rpm-gpg/
- name: Set up Docker build environment
run: docker build -t xcp-ng-build-ready -f Dockerfile.build .
- name: Download xolite-ce RPM
run: |
gh release download --repo Vagrantin/xolite-ce \
--pattern "xo-lite-ce-*.rpm" \
--dir community-repo/x86_64/
- name: Download xoa-proxy RPM
run: |
gh release download --repo Vagrantin/xoa-proxy \
--pattern "xoa-proxy-*.rpm" \
--dir community-repo/x86_64/
- name: Prepare community repo
run: createrepo_c community-repo/x86_64/
- name: Copy CUSTOMREPO.tmpl
run: cp configs/base/CUSTOMREPO.tmpl configs/8.3/
- name: Run create-installimg.sh (root)
run: |
docker run --rm --privileged \
-v $PWD:/build \
-v $PWD/create-install-image:/create-install-image \
xcp-ng-build-ready \
bash -c "cd /create-install-image && sudo ./create-installimg.sh ..."
- name: Run create-iso.sh (non-root)
run: |
docker run --rm \
-e HOME=/tmp \
-v $PWD:/build \
xcp-ng-build-ready \
bash -c "cd /create-install-image && ./create-iso.sh ..."
- name: Restore working directory ownership
run: sudo chown -R $(id -u):$(id -g) .
- name: isohybrid post-processing
run: isohybrid --uefi output.iso
- name: implantisomd5
run: implantisomd5 output.iso
- name: Generate and sign checksum
run: |
CHECKSUM_FILE="${OUTPUT}.sha256"
sha256sum "${OUTPUT}" > "${CHECKSUM_FILE}"
echo "CHECKSUM_FILE=${CHECKSUM_FILE}" >> $GITHUB_ENV
gpg --batch --pinentry-mode loopback \
--detach-sign --armor "${CHECKSUM_FILE}"
gpg --verify "${CHECKSUM_FILE}.asc" "${CHECKSUM_FILE}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: $
files: |
$
$
$.asc
- name: Remove GPG private key
if: always()
run: shred -u /tmp/community-signing.key || true
The Docker volume mount must reference create-install-image. Verify the mount target matches the actual cloned directory name.
Contributing
- Fork Vagrantin/xcp-ng-ce-iso.
- Test changes using the local Docker workflow above.
- Enable
DEBUG_BUILDvariable in your fork’s GitHub Actions settings to activate debug script overlay for CI troubleshooting. - Open a pull request against
main.