Skip to content

Build verified upstream RPMs #109

Build verified upstream RPMs

Build verified upstream RPMs #109

Workflow file for this run

name: Build verified upstream RPMs
on:
workflow_dispatch:
inputs:
full:
description: Rebuild every package, ignoring what is already published
required: false
default: false
type: boolean
push:
branches: [main]
paths:
- config/**
- packages/**
- tools/source_pipeline.py
- tools/runtime_contract.py
- tests/**
- .github/workflows/rebuild-rpms.yml
pull_request:
paths:
- config/**
- packages/**
- tools/source_pipeline.py
- tools/runtime_contract.py
- tests/**
- .github/workflows/rebuild-rpms.yml
schedule:
- cron: '41 6 * * *'
permissions:
contents: read
id-token: write
packages: write
pages: write
# Build waves may run concurrently. They use run-scoped artifacts and each
# publication re-seeds from the then-current repository, so concurrent work
# cannot lose RPMs. The consumer tag itself remains serialized at `publish`.
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
stage0: ${{ steps.matrix.outputs.stage0 }}
stage1: ${{ steps.matrix.outputs.stage1 }}
stage2: ${{ steps.matrix.outputs.stage2 }}
stage3: ${{ steps.matrix.outputs.stage3 }}
stage4: ${{ steps.matrix.outputs.stage4 }}
build_list: ${{ steps.matrix.outputs.build_list }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Validate factory policy and tests
run: |
python3 tools/check_workflow_quoting.py
python3 tools/runtime_contract.py \
config/bluefin-packages.toml config/runtime-contract.toml --check
python3 -m unittest discover -s tests -v
- name: Install zstandard
run: pip install --quiet zstandard
- id: matrix
env:
FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.full == true && '1' || '0' }}
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: |
python3 - <<'PY'
import json, os, re, subprocess, urllib.request, gzip, io, sys
from pathlib import Path
config = json.loads(Path('config/upstream-sources.json').read_text())
full = os.environ.get('FULL') == '1'
changed = set()
base = os.environ.get('BASE_SHA', '')
if not full and re.fullmatch(r'[0-9a-f]{40}', base or '') and set(base) != {'0'}:
paths = subprocess.check_output(
['git', 'diff', '--name-only', f'{base}..HEAD'], text=True).splitlines()
changed = {
match.group(1)
for path in paths
if (match := re.match(r'^packages/([^/]+)/', path))
}
print(f"changed package recipes: {', '.join(sorted(changed)) or 'none'}")
# Fetch the currently published repo so we only rebuild what is new
# or changed. Matching is name + normalized version: Fedora's spec
# Version rewrites the tarball's '.' to '~' (gnome-shell 51.beta ->
# 51~beta), so normalize '~' to '.' on both sides. A mismatch just
# rebuilds a package we could have skipped -- safe, only slower.
published = {}
if not full:
try:
base = "https://projectbluefin.github.io/utah-packages/"
repomd = urllib.request.urlopen(base + "repodata/repomd.xml", timeout=60).read().decode()
href = re.search(r'<location href="([^"]*primary[^"]*)"', repomd).group(1)
raw = urllib.request.urlopen(base + href, timeout=120).read()
if href.endswith('.zst'):
import zstandard
stream = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(raw))
else:
stream = gzip.GzipFile(fileobj=io.BytesIO(raw))
text = stream.read()
for m in re.finditer(rb'<package[^>]*>.*?<name>([^<]+)</name>.*?<version[^>]*ver="([^"]+)"', text, re.S):
published[m.group(1).decode()] = m.group(2).decode()
print(f"published repo has {len(published)} packages")
except Exception as error:
print(f"WARNING: could not read published repo, rebuilding all: {error}", file=sys.stderr)
def norm(version: str) -> str:
return version.replace('~', '.')
build = []
for package in config['packages']:
name = package['name']
version = package.get('version', '')
if full or name in changed or name not in published or norm(published[name]) != norm(version):
build.append(package)
else:
print(f"skip {name} {version}: already published")
outputs = {'build_list': json.dumps([p['name'] for p in build])}
for n in range(5):
outputs[f'stage{n}'] = json.dumps(
[p['name'] for p in build if (p.get('stage') or 0) == n])
with open(os.environ['GITHUB_OUTPUT'], 'a') as handle:
for key, value in outputs.items():
handle.write(f'{key}={value}\n')
print(f"will build {len(build)} of {len(config['packages'])} packages")
PY
preflight:
# Every missing platform dependency has so far been found the slow way: a
# stage fails, we read one "No match for argument" line, add one package,
# and wait another half hour for the next one. gtk4 needed pango >= 1.58.0
# that Fedora 44 does not carry. This job resolves every recipe's
# BuildRequires in the real build root up front, so one run enumerates all
# of the gaps instead of one per round.
#
# It never gates a build. A requirement listed here may be perfectly fine:
# a later-stage package legitimately BuildRequires something an earlier
# stage has not built yet, and that is indistinguishable from a real gap
# without resolving against the staged output. Read it as a worklist.
needs: prepare
if: needs.prepare.outputs.build_list != '[]'
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Resolve BuildRequires for packages being built
env:
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
BUILD_LIST: ${{ needs.prepare.outputs.build_list }}
run: |
mkdir -p work/reports
docker run --rm \
-e FACTORY_REPO -e BUILD_LIST \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
cp /repos/hummingbird.repo /etc/yum.repos.d/
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
dnf -y $disable install dnf-plugins-core rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
: > /work/reports/preflight.txt
# Only resolve BuildRequires for packages this run will actually
# build (the incremental matrix), not the whole tree.
# BUILD_LIST is a JSON array; strip brackets, quotes and commas
# without ever writing a literal quote character.
for package in $(echo "$BUILD_LIST" | sed -e s/\\[// -e s/\\]// -e s/\\\"//g -e s/,/ /g); do
dir="/packages/$package"
test -d "$dir" || { echo "build_list names missing dir: $dir" >> /work/reports/preflight.txt; continue; }
spec=$(find "$dir" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec" || continue
# --assumeno resolves the transaction without installing it,
# but it also *declines* the transaction, so dnf exits non-zero
# even when resolution succeeded. Keying off the exit status
# listed all 70 recipes as failures and buried the real ones.
# Judge by the error text instead.
output=$(dnf $disable --assumeno builddep -D "_sourcedir $dir" "$spec" 2>&1 || true)
# Two classes, and they are not equally trustworthy.
#
# "No match for argument" means a capability nothing in the root
# provides. That has been reliable: it is how the real pango gap
# was found.
#
# "cannot install both" is a version conflict between the layered
# repositories and what @System already carries, and it has been
# not a reliable predictor: packages have built successfully after
# reporting one here. The resolution
# this job performs is not the one the build performs, so conflicts
# are recorded separately and must not be treated as gaps.
missing=$(printf "%s\n" "$output" \
| grep -E "No match for argument|nothing provides" || true)
conflict=$(printf "%s\n" "$output" | grep -E "cannot install" || true)
if [ -n "$missing" ] || [ -n "$conflict" ]; then
printf "%s\n" "$package" >> /work/reports/preflight.txt
if [ -n "$missing" ]; then
printf "%s\n" "$missing" | sed "s/^/ MISSING /" >> /work/reports/preflight.txt
fi
if [ -n "$conflict" ]; then
printf "%s\n" "$conflict" | sed "s/^/ conflict(unreliable) /" >> /work/reports/preflight.txt
fi
fi
done
echo "=== recipes with unresolved BuildRequires ==="
cat /work/reports/preflight.txt
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: preflight-buildrequires
path: work/reports/preflight.txt
if-no-files-found: warn
rebuild0:
needs: prepare
if: needs.prepare.outputs.stage0 != '[]'
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.prepare.outputs.stage0) }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-sccache
- name: Add best-effort swap for Firefox PGO/LTO
if: matrix.package == 'firefox'
run: |
set -euo pipefail
available_gib=$(df --output=avail -BG "$RUNNER_TEMP" | tail -n1 | tr -dc 0-9)
if [ "$available_gib" -lt 20 ]; then
echo "Only ${available_gib} GiB free; skipping extra swap to preserve build disk" >&2
exit 0
fi
swapfile="$RUNNER_TEMP/firefox-pgo.swap"
sudo fallocate -l 12G "$swapfile"
sudo chmod 600 "$swapfile"
sudo mkswap "$swapfile"
sudo swapon "$swapfile" || { echo "Host does not permit extra swap" >&2; exit 0; }
free -h
- name: Fetch and verify direct upstream source
env:
PACKAGE: ${{ matrix.package }}
run: |
python3 tools/source_pipeline.py "$PACKAGE" --output work/sources --report-dir work/reports
- name: Build the verified source with its RPM recipe
env:
PACKAGE: ${{ matrix.package }}
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
run: |
test -d "packages/$PACKAGE"
mkdir -p work/result
# Per-package, so it is resolved out here rather than inside the
# container, which only sees config/ and packages/.
DIST_BUMP="$(python3 tools/dist_bump.py "$PACKAGE")"
export DIST_BUMP
docker run --rm --privileged \
-e PACKAGE -e FACTORY_REPO -e DIST_BUMP \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
# The fedora:rawhide image ships fedora-cisco-openh264 enabled, but
# its packages are signed with Cisco key, which the image does not
# trust -- so any builddep graph reaching gstreamer/pipewire dies on
# "Import of the key did not help, wrong key?". openh264 is a runtime
# codec, never a build requirement, and Fedora own noopenh264 provides
# the same libopenh264.so.8 soname, so disabling the repo resolves.
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
# Fedora 44 plus Hummingbird, mirroring Hummingbird mock.cfg:
# Fedora release repos with its own Pulp repos shadowing them by
# priority. Proven correct by this job own diagnostic below --
# openssl 3.5.7 means libcrypto.so.3, the ABI Hummingbird has.
# A Rawhide root produced RPMs needing libcrypto.so.4 instead.
cp /repos/hummingbird.repo /etc/yum.repos.d/
# RPMs from earlier stages become a local repo, so a later stage
# can satisfy a BuildRequires on something this run just built.
# The guard must recurse: upload-artifact takes the common parent
# of its path globs as the artifact root, so an artifact declaring
# work/result/*.rpm and work/reports/*.json unpacks as
# prior/result/*.rpm, not prior/*.rpm. A non-recursive glob matched
# nothing, this block was silently skipped in every stage, and
# mutter resolved gsettings-desktop-schemas to Fedora 50.1 instead
# of the 51.beta stage 0 had just built. createrepo_c itself walks
# the tree, so only the test needed fixing.
if [ -n "$(find /work/prior -name "*.rpm" -print -quit 2>/dev/null)" ]; then
dnf -y $disable install createrepo_c
createrepo_c /work/prior
printf "[stages]\nname=stages\nbaseurl=file:///work/prior\nenabled=1\ngpgcheck=0\npriority=1\n" \
> /etc/yum.repos.d/stages.repo
# priority alone does not keep Fedora out. gnome-control-center
# pulled Fedora accountsservice 23.13.9 even though stage 0 had
# built 26.27.3 and [stages] was priority 1: the Fedora main
# package entered the transaction and pinned accountsservice-libs
# to its exact NEVR, so our libs could not be installed and our
# devel, which needs them, was dropped --
# cannot install both accountsservice-libs-26.27.3 from stages
# and accountsservice-libs-23.13.9-16.fc44 from fedora
# Excluding by name is what settles it: whatever an earlier stage
# built, Fedora must not answer for. Names come from rpm rather
# than from parsing filenames, which stops working the moment a
# disttag changes.
EXCLUDE=$(find /work/prior -name "*.rpm" -type f -print0 \
| xargs -0 -r rpm -qp --qf "%{NAME}\n" 2>/dev/null \
| sort -u | paste -sd, -)
echo "excluding from Fedora: $EXCLUDE"
fi
# Hummingbird ships newer versions of some names than Fedora 44
# does, and the two must never mix in one transaction. The
# conflicts this prevents are real: libicu 78.3 (hum) vs 77.1
# (fc44) broke samba and evolution-data-server, and Fedora ruby
# 3.3/3.4-default-gems vs Hummingbird ruby4.0-default-gems broke
# webkitgtk, colord, libnotify and zsh. Always prefer the
# Hummingbird copy by excluding these names from Fedora.
# Unconditional: stage 0 has no prior RPMs, so the block above
# never ran and EXCLUDE would otherwise be empty here.
HB_EXCLUDE="ruby-default-gems,ruby3.3-default-gems,ruby3.4-default-gems,libicu,icu,gpgme,qt6-qtbase"
EXCLUDE="${HB_EXCLUDE}${EXCLUDE:+,}${EXCLUDE}"
echo "hummingbird exclusions: $HB_EXCLUDE"
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
# The plain fedora image is not a build root: it lacks the group mock
# installs, so /usr/bin/echo and friends are missing. Most packages pull
# them in transitively; squashfs-tools calls echo directly from its
# manpage installer and fails without it.
dnf -y $disable install dnf-plugins-core mock rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
# NO APOSTROPHES IN THIS SCRIPT. It is the body of bash -exc
# a single-quoted string, so one closes it and everything after
# is reparsed. That is what the stilted "Fedora own
# noopenh264" and "this job own diagnostic" above are avoiding.
# Three apostrophes in this very comment took out all 36 stage 0
# jobs at once, in under two minutes, with no clue in the log.
#
# The AlmaLinux convention, one distro over. They keep the vendor
# release and dist and append to it -- their dnf is
# 4.14.0-34.el9_8.alma.1 against 34.el9_8 from Red Hat, and both
# .alma and .alma.N appear in their repositories.
#
# The vendor here is Hummingbird, not Fedora. These packages are
# built for Hummingbird and installed on Hummingbird; Fedora 44 is
# only the other half of the buildroot, the way a compiler is. An
# earlier version of this tagged them .fc44.bfin, which named the
# distribution they are not for.
#
# The tag is read from the Hummingbird packages present in the
# buildroot rather than hardcoded, so a move to hum2 carries
# itself. A buildroot containing none of them is a repository
# misconfiguration -- the exact failure this factory exists to
# avoid -- so it stops rather than quietly tagging something else.
HUM_TAG="$(rpm -qa --qf "%{RELEASE}\n" | grep -oE "hum[0-9]+$" | sort -u | head -n1)"
if [ -z "$HUM_TAG" ]; then
echo "No Hummingbird package in the buildroot; cannot derive a disttag" >&2
rpm -qa --qf "%{NAME} %{RELEASE}\n" | sort | head -20 >&2
exit 1
fi
DISTTAG=".${HUM_TAG}.bfin${DIST_BUMP:-}"
echo "disttag: $DISTTAG"
# libratbag %check starts ratbagd, which calls
# Gio.bus_get_sync(Gio.BusType.SYSTEM) and dies with "Could not
# connect: No such file or directory" -- a plain container has no
# system bus socket. Seven of its ten suites already pass and the
# failing one is a real test, so give the build root a bus rather
# than disabling the test. Non-fatal: no other package needs it.
dnf -y $disable install dbus-daemon || true
mkdir -p /run/dbus
dbus-daemon --system --fork || true
# mock defines USER in its build root; a bare container does not.
# just 1.57.0 tests/functions.rs:88 calls env::var("USER").unwrap()
# and panicked with NotPresent -- 1823 tests passed, that one did
# not. Same shape as the missing system bus: supply what a real
# build root has rather than disable the test.
export USER="${USER:-root}"
export LOGNAME="${LOGNAME:-$USER}"
spec=$(find "/packages/$PACKAGE" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$spec"
# An imported spec keeps its dist-git PatchN and auxiliary SourceN
# files next to itself, while the verified upstream archive lands in
# /work/sources. rpmbuild takes a single _sourcedir, so stage both:
# recipe files first, then the verified archive, which therefore wins
# over anything of the same name carried in the import.
staged=/work/staged/$PACKAGE
rm -rf "$staged"
mkdir -p "$staged"
cp -a "/packages/$PACKAGE/." "$staged/"
cp -a "/work/sources/$PACKAGE/." "$staged/"
# Packages with %generate_buildrequires -- every Rust one -- compute
# their real BuildRequires during the build, so the spec alone does not
# list them and rpmbuild exits 11 asking to be re-run. Install what the
# generated source RPM declares and retry, bounded so an unsatisfiable
# requirement fails instead of looping.
for _ in 1 2 3 4 5; do
rm -f /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm
if rpmbuild -br "$spec" --define "_sourcedir $staged" \
--define "dist $DISTTAG"; then break; fi
generated=$(ls /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm 2>/dev/null | head -1)
test -n "$generated"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$generated"
done
rpmbuild -ba "$spec" \
--define "_sourcedir $staged" \
--define "dist $DISTTAG" \
--define "_rpmdir /work/result"
find /work/result -name "*.rpm" -type f -print0 | \
xargs -0 -r rpm -qp --qf "%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n"
# A build that produced no RPM must fail here. if-no-files-found on
# the upload cannot catch it: the artifact also carries
# work/reports/*.json, so one file always matches and the upload
# reports success while shipping no packages at all.
test -n "$(find /work/result -name "*.rpm" -type f -print -quit)"
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rpm-s0-${{ matrix.package }}
path: |
work/result/**/*.rpm
work/reports/*.json
if-no-files-found: error
rebuild1:
needs: [prepare, rebuild0]
if: ${{ !cancelled() && needs.prepare.outputs.stage1 != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.prepare.outputs.stage1) }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-sccache
- name: Collect RPMs built by earlier stages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: work/prior
merge-multiple: true
continue-on-error: true
- name: Fetch and verify direct upstream source
env:
PACKAGE: ${{ matrix.package }}
run: |
python3 tools/source_pipeline.py "$PACKAGE" --output work/sources --report-dir work/reports
- name: Build the verified source with its RPM recipe
env:
PACKAGE: ${{ matrix.package }}
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
run: |
test -d "packages/$PACKAGE"
mkdir -p work/result
# Per-package, so it is resolved out here rather than inside the
# container, which only sees config/ and packages/.
DIST_BUMP="$(python3 tools/dist_bump.py "$PACKAGE")"
export DIST_BUMP
docker run --rm --privileged \
-e PACKAGE -e FACTORY_REPO -e DIST_BUMP \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
# The fedora:rawhide image ships fedora-cisco-openh264 enabled, but
# its packages are signed with Cisco key, which the image does not
# trust -- so any builddep graph reaching gstreamer/pipewire dies on
# "Import of the key did not help, wrong key?". openh264 is a runtime
# codec, never a build requirement, and Fedora own noopenh264 provides
# the same libopenh264.so.8 soname, so disabling the repo resolves.
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
# Fedora 44 plus Hummingbird, mirroring Hummingbird mock.cfg:
# Fedora release repos with its own Pulp repos shadowing them by
# priority. Proven correct by this job own diagnostic below --
# openssl 3.5.7 means libcrypto.so.3, the ABI Hummingbird has.
# A Rawhide root produced RPMs needing libcrypto.so.4 instead.
cp /repos/hummingbird.repo /etc/yum.repos.d/
# RPMs from earlier stages become a local repo, so a later stage
# can satisfy a BuildRequires on something this run just built.
# The guard must recurse: upload-artifact takes the common parent
# of its path globs as the artifact root, so an artifact declaring
# work/result/*.rpm and work/reports/*.json unpacks as
# prior/result/*.rpm, not prior/*.rpm. A non-recursive glob matched
# nothing, this block was silently skipped in every stage, and
# mutter resolved gsettings-desktop-schemas to Fedora 50.1 instead
# of the 51.beta stage 0 had just built. createrepo_c itself walks
# the tree, so only the test needed fixing.
if [ -n "$(find /work/prior -name "*.rpm" -print -quit 2>/dev/null)" ]; then
dnf -y $disable install createrepo_c
createrepo_c /work/prior
printf "[stages]\nname=stages\nbaseurl=file:///work/prior\nenabled=1\ngpgcheck=0\npriority=1\n" \
> /etc/yum.repos.d/stages.repo
# priority alone does not keep Fedora out. gnome-control-center
# pulled Fedora accountsservice 23.13.9 even though stage 0 had
# built 26.27.3 and [stages] was priority 1: the Fedora main
# package entered the transaction and pinned accountsservice-libs
# to its exact NEVR, so our libs could not be installed and our
# devel, which needs them, was dropped --
# cannot install both accountsservice-libs-26.27.3 from stages
# and accountsservice-libs-23.13.9-16.fc44 from fedora
# Excluding by name is what settles it: whatever an earlier stage
# built, Fedora must not answer for. Names come from rpm rather
# than from parsing filenames, which stops working the moment a
# disttag changes.
EXCLUDE=$(find /work/prior -name "*.rpm" -type f -print0 \
| xargs -0 -r rpm -qp --qf "%{NAME}\n" 2>/dev/null \
| sort -u | paste -sd, -)
echo "excluding from Fedora: $EXCLUDE"
fi
# Hummingbird ships newer versions of some names than Fedora 44
# does, and the two must never mix in one transaction. The
# conflicts this prevents are real: libicu 78.3 (hum) vs 77.1
# (fc44) broke samba and evolution-data-server, and Fedora ruby
# 3.3/3.4-default-gems vs Hummingbird ruby4.0-default-gems broke
# webkitgtk, colord, libnotify and zsh. Always prefer the
# Hummingbird copy by excluding these names from Fedora.
# Unconditional: stage 0 has no prior RPMs, so the block above
# never ran and EXCLUDE would otherwise be empty here.
HB_EXCLUDE="ruby-default-gems,ruby3.3-default-gems,ruby3.4-default-gems,libicu,icu,gpgme,qt6-qtbase"
EXCLUDE="${HB_EXCLUDE}${EXCLUDE:+,}${EXCLUDE}"
echo "hummingbird exclusions: $HB_EXCLUDE"
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
# The plain fedora image is not a build root: it lacks the group mock
# installs, so /usr/bin/echo and friends are missing. Most packages pull
# them in transitively; squashfs-tools calls echo directly from its
# manpage installer and fails without it.
dnf -y $disable install dnf-plugins-core mock rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
# NO APOSTROPHES IN THIS SCRIPT. It is the body of bash -exc
# a single-quoted string, so one closes it and everything after
# is reparsed. That is what the stilted "Fedora own
# noopenh264" and "this job own diagnostic" above are avoiding.
# Three apostrophes in this very comment took out all 36 stage 0
# jobs at once, in under two minutes, with no clue in the log.
#
# The AlmaLinux convention, one distro over. They keep the vendor
# release and dist and append to it -- their dnf is
# 4.14.0-34.el9_8.alma.1 against 34.el9_8 from Red Hat, and both
# .alma and .alma.N appear in their repositories.
#
# The vendor here is Hummingbird, not Fedora. These packages are
# built for Hummingbird and installed on Hummingbird; Fedora 44 is
# only the other half of the buildroot, the way a compiler is. An
# earlier version of this tagged them .fc44.bfin, which named the
# distribution they are not for.
#
# The tag is read from the Hummingbird packages present in the
# buildroot rather than hardcoded, so a move to hum2 carries
# itself. A buildroot containing none of them is a repository
# misconfiguration -- the exact failure this factory exists to
# avoid -- so it stops rather than quietly tagging something else.
HUM_TAG="$(rpm -qa --qf "%{RELEASE}\n" | grep -oE "hum[0-9]+$" | sort -u | head -n1)"
if [ -z "$HUM_TAG" ]; then
echo "No Hummingbird package in the buildroot; cannot derive a disttag" >&2
rpm -qa --qf "%{NAME} %{RELEASE}\n" | sort | head -20 >&2
exit 1
fi
DISTTAG=".${HUM_TAG}.bfin${DIST_BUMP:-}"
echo "disttag: $DISTTAG"
# libratbag %check starts ratbagd, which calls
# Gio.bus_get_sync(Gio.BusType.SYSTEM) and dies with "Could not
# connect: No such file or directory" -- a plain container has no
# system bus socket. Seven of its ten suites already pass and the
# failing one is a real test, so give the build root a bus rather
# than disabling the test. Non-fatal: no other package needs it.
dnf -y $disable install dbus-daemon || true
mkdir -p /run/dbus
dbus-daemon --system --fork || true
# mock defines USER in its build root; a bare container does not.
# just 1.57.0 tests/functions.rs:88 calls env::var("USER").unwrap()
# and panicked with NotPresent -- 1823 tests passed, that one did
# not. Same shape as the missing system bus: supply what a real
# build root has rather than disable the test.
export USER="${USER:-root}"
export LOGNAME="${LOGNAME:-$USER}"
spec=$(find "/packages/$PACKAGE" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$spec"
# An imported spec keeps its dist-git PatchN and auxiliary SourceN
# files next to itself, while the verified upstream archive lands in
# /work/sources. rpmbuild takes a single _sourcedir, so stage both:
# recipe files first, then the verified archive, which therefore wins
# over anything of the same name carried in the import.
staged=/work/staged/$PACKAGE
rm -rf "$staged"
mkdir -p "$staged"
cp -a "/packages/$PACKAGE/." "$staged/"
cp -a "/work/sources/$PACKAGE/." "$staged/"
# Packages with %generate_buildrequires -- every Rust one -- compute
# their real BuildRequires during the build, so the spec alone does not
# list them and rpmbuild exits 11 asking to be re-run. Install what the
# generated source RPM declares and retry, bounded so an unsatisfiable
# requirement fails instead of looping.
for _ in 1 2 3 4 5; do
rm -f /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm
if rpmbuild -br "$spec" --define "_sourcedir $staged" \
--define "dist $DISTTAG"; then break; fi
generated=$(ls /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm 2>/dev/null | head -1)
test -n "$generated"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$generated"
done
rpmbuild -ba "$spec" \
--define "_sourcedir $staged" \
--define "dist $DISTTAG" \
--define "_rpmdir /work/result"
find /work/result -name "*.rpm" -type f -print0 | \
xargs -0 -r rpm -qp --qf "%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n"
# A build that produced no RPM must fail here. if-no-files-found on
# the upload cannot catch it: the artifact also carries
# work/reports/*.json, so one file always matches and the upload
# reports success while shipping no packages at all.
test -n "$(find /work/result -name "*.rpm" -type f -print -quit)"
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rpm-s1-${{ matrix.package }}
path: |
work/result/**/*.rpm
work/reports/*.json
if-no-files-found: error
rebuild2:
needs: [prepare, rebuild0, rebuild1]
if: ${{ !cancelled() && needs.prepare.outputs.stage2 != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.prepare.outputs.stage2) }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-sccache
- name: Collect RPMs built by earlier stages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: work/prior
merge-multiple: true
continue-on-error: true
- name: Fetch and verify direct upstream source
env:
PACKAGE: ${{ matrix.package }}
run: |
python3 tools/source_pipeline.py "$PACKAGE" --output work/sources --report-dir work/reports
- name: Build the verified source with its RPM recipe
env:
PACKAGE: ${{ matrix.package }}
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
run: |
test -d "packages/$PACKAGE"
mkdir -p work/result
# Per-package, so it is resolved out here rather than inside the
# container, which only sees config/ and packages/.
DIST_BUMP="$(python3 tools/dist_bump.py "$PACKAGE")"
export DIST_BUMP
docker run --rm --privileged \
-e PACKAGE -e FACTORY_REPO -e DIST_BUMP \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
# The fedora:rawhide image ships fedora-cisco-openh264 enabled, but
# its packages are signed with Cisco key, which the image does not
# trust -- so any builddep graph reaching gstreamer/pipewire dies on
# "Import of the key did not help, wrong key?". openh264 is a runtime
# codec, never a build requirement, and Fedora own noopenh264 provides
# the same libopenh264.so.8 soname, so disabling the repo resolves.
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
# Fedora 44 plus Hummingbird, mirroring Hummingbird mock.cfg:
# Fedora release repos with its own Pulp repos shadowing them by
# priority. Proven correct by this job own diagnostic below --
# openssl 3.5.7 means libcrypto.so.3, the ABI Hummingbird has.
# A Rawhide root produced RPMs needing libcrypto.so.4 instead.
cp /repos/hummingbird.repo /etc/yum.repos.d/
# RPMs from earlier stages become a local repo, so a later stage
# can satisfy a BuildRequires on something this run just built.
# The guard must recurse: upload-artifact takes the common parent
# of its path globs as the artifact root, so an artifact declaring
# work/result/*.rpm and work/reports/*.json unpacks as
# prior/result/*.rpm, not prior/*.rpm. A non-recursive glob matched
# nothing, this block was silently skipped in every stage, and
# mutter resolved gsettings-desktop-schemas to Fedora 50.1 instead
# of the 51.beta stage 0 had just built. createrepo_c itself walks
# the tree, so only the test needed fixing.
if [ -n "$(find /work/prior -name "*.rpm" -print -quit 2>/dev/null)" ]; then
dnf -y $disable install createrepo_c
createrepo_c /work/prior
printf "[stages]\nname=stages\nbaseurl=file:///work/prior\nenabled=1\ngpgcheck=0\npriority=1\n" \
> /etc/yum.repos.d/stages.repo
# priority alone does not keep Fedora out. gnome-control-center
# pulled Fedora accountsservice 23.13.9 even though stage 0 had
# built 26.27.3 and [stages] was priority 1: the Fedora main
# package entered the transaction and pinned accountsservice-libs
# to its exact NEVR, so our libs could not be installed and our
# devel, which needs them, was dropped --
# cannot install both accountsservice-libs-26.27.3 from stages
# and accountsservice-libs-23.13.9-16.fc44 from fedora
# Excluding by name is what settles it: whatever an earlier stage
# built, Fedora must not answer for. Names come from rpm rather
# than from parsing filenames, which stops working the moment a
# disttag changes.
EXCLUDE=$(find /work/prior -name "*.rpm" -type f -print0 \
| xargs -0 -r rpm -qp --qf "%{NAME}\n" 2>/dev/null \
| sort -u | paste -sd, -)
echo "excluding from Fedora: $EXCLUDE"
fi
# Hummingbird ships newer versions of some names than Fedora 44
# does, and the two must never mix in one transaction. The
# conflicts this prevents are real: libicu 78.3 (hum) vs 77.1
# (fc44) broke samba and evolution-data-server, and Fedora ruby
# 3.3/3.4-default-gems vs Hummingbird ruby4.0-default-gems broke
# webkitgtk, colord, libnotify and zsh. Always prefer the
# Hummingbird copy by excluding these names from Fedora.
# Unconditional: stage 0 has no prior RPMs, so the block above
# never ran and EXCLUDE would otherwise be empty here.
HB_EXCLUDE="ruby-default-gems,ruby3.3-default-gems,ruby3.4-default-gems,libicu,icu,gpgme,qt6-qtbase"
EXCLUDE="${HB_EXCLUDE}${EXCLUDE:+,}${EXCLUDE}"
echo "hummingbird exclusions: $HB_EXCLUDE"
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
# The plain fedora image is not a build root: it lacks the group mock
# installs, so /usr/bin/echo and friends are missing. Most packages pull
# them in transitively; squashfs-tools calls echo directly from its
# manpage installer and fails without it.
dnf -y $disable install dnf-plugins-core mock rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
# NO APOSTROPHES IN THIS SCRIPT. It is the body of bash -exc
# a single-quoted string, so one closes it and everything after
# is reparsed. That is what the stilted "Fedora own
# noopenh264" and "this job own diagnostic" above are avoiding.
# Three apostrophes in this very comment took out all 36 stage 0
# jobs at once, in under two minutes, with no clue in the log.
#
# The AlmaLinux convention, one distro over. They keep the vendor
# release and dist and append to it -- their dnf is
# 4.14.0-34.el9_8.alma.1 against 34.el9_8 from Red Hat, and both
# .alma and .alma.N appear in their repositories.
#
# The vendor here is Hummingbird, not Fedora. These packages are
# built for Hummingbird and installed on Hummingbird; Fedora 44 is
# only the other half of the buildroot, the way a compiler is. An
# earlier version of this tagged them .fc44.bfin, which named the
# distribution they are not for.
#
# The tag is read from the Hummingbird packages present in the
# buildroot rather than hardcoded, so a move to hum2 carries
# itself. A buildroot containing none of them is a repository
# misconfiguration -- the exact failure this factory exists to
# avoid -- so it stops rather than quietly tagging something else.
HUM_TAG="$(rpm -qa --qf "%{RELEASE}\n" | grep -oE "hum[0-9]+$" | sort -u | head -n1)"
if [ -z "$HUM_TAG" ]; then
echo "No Hummingbird package in the buildroot; cannot derive a disttag" >&2
rpm -qa --qf "%{NAME} %{RELEASE}\n" | sort | head -20 >&2
exit 1
fi
DISTTAG=".${HUM_TAG}.bfin${DIST_BUMP:-}"
echo "disttag: $DISTTAG"
# libratbag %check starts ratbagd, which calls
# Gio.bus_get_sync(Gio.BusType.SYSTEM) and dies with "Could not
# connect: No such file or directory" -- a plain container has no
# system bus socket. Seven of its ten suites already pass and the
# failing one is a real test, so give the build root a bus rather
# than disabling the test. Non-fatal: no other package needs it.
dnf -y $disable install dbus-daemon || true
mkdir -p /run/dbus
dbus-daemon --system --fork || true
# mock defines USER in its build root; a bare container does not.
# just 1.57.0 tests/functions.rs:88 calls env::var("USER").unwrap()
# and panicked with NotPresent -- 1823 tests passed, that one did
# not. Same shape as the missing system bus: supply what a real
# build root has rather than disable the test.
export USER="${USER:-root}"
export LOGNAME="${LOGNAME:-$USER}"
spec=$(find "/packages/$PACKAGE" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$spec"
# An imported spec keeps its dist-git PatchN and auxiliary SourceN
# files next to itself, while the verified upstream archive lands in
# /work/sources. rpmbuild takes a single _sourcedir, so stage both:
# recipe files first, then the verified archive, which therefore wins
# over anything of the same name carried in the import.
staged=/work/staged/$PACKAGE
rm -rf "$staged"
mkdir -p "$staged"
cp -a "/packages/$PACKAGE/." "$staged/"
cp -a "/work/sources/$PACKAGE/." "$staged/"
# Packages with %generate_buildrequires -- every Rust one -- compute
# their real BuildRequires during the build, so the spec alone does not
# list them and rpmbuild exits 11 asking to be re-run. Install what the
# generated source RPM declares and retry, bounded so an unsatisfiable
# requirement fails instead of looping.
for _ in 1 2 3 4 5; do
rm -f /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm
if rpmbuild -br "$spec" --define "_sourcedir $staged" \
--define "dist $DISTTAG"; then break; fi
generated=$(ls /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm 2>/dev/null | head -1)
test -n "$generated"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$generated"
done
rpmbuild -ba "$spec" \
--define "_sourcedir $staged" \
--define "dist $DISTTAG" \
--define "_rpmdir /work/result"
find /work/result -name "*.rpm" -type f -print0 | \
xargs -0 -r rpm -qp --qf "%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n"
# A build that produced no RPM must fail here. if-no-files-found on
# the upload cannot catch it: the artifact also carries
# work/reports/*.json, so one file always matches and the upload
# reports success while shipping no packages at all.
test -n "$(find /work/result -name "*.rpm" -type f -print -quit)"
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rpm-s2-${{ matrix.package }}
path: |
work/result/**/*.rpm
work/reports/*.json
if-no-files-found: error
rebuild3:
needs: [prepare, rebuild0, rebuild1, rebuild2]
if: ${{ !cancelled() && needs.prepare.outputs.stage3 != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.prepare.outputs.stage3) }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-sccache
- name: Collect RPMs built by earlier stages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: work/prior
merge-multiple: true
continue-on-error: true
- name: Fetch and verify direct upstream source
env:
PACKAGE: ${{ matrix.package }}
run: |
python3 tools/source_pipeline.py "$PACKAGE" --output work/sources --report-dir work/reports
- name: Build the verified source with its RPM recipe
env:
PACKAGE: ${{ matrix.package }}
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
run: |
test -d "packages/$PACKAGE"
mkdir -p work/result
# Per-package, so it is resolved out here rather than inside the
# container, which only sees config/ and packages/.
DIST_BUMP="$(python3 tools/dist_bump.py "$PACKAGE")"
export DIST_BUMP
docker run --rm --privileged \
-e PACKAGE -e FACTORY_REPO -e DIST_BUMP \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
# The fedora:rawhide image ships fedora-cisco-openh264 enabled, but
# its packages are signed with Cisco key, which the image does not
# trust -- so any builddep graph reaching gstreamer/pipewire dies on
# "Import of the key did not help, wrong key?". openh264 is a runtime
# codec, never a build requirement, and Fedora own noopenh264 provides
# the same libopenh264.so.8 soname, so disabling the repo resolves.
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
# Fedora 44 plus Hummingbird, mirroring Hummingbird mock.cfg:
# Fedora release repos with its own Pulp repos shadowing them by
# priority. Proven correct by this job own diagnostic below --
# openssl 3.5.7 means libcrypto.so.3, the ABI Hummingbird has.
# A Rawhide root produced RPMs needing libcrypto.so.4 instead.
cp /repos/hummingbird.repo /etc/yum.repos.d/
# RPMs from earlier stages become a local repo, so a later stage
# can satisfy a BuildRequires on something this run just built.
# The guard must recurse: upload-artifact takes the common parent
# of its path globs as the artifact root, so an artifact declaring
# work/result/*.rpm and work/reports/*.json unpacks as
# prior/result/*.rpm, not prior/*.rpm. A non-recursive glob matched
# nothing, this block was silently skipped in every stage, and
# mutter resolved gsettings-desktop-schemas to Fedora 50.1 instead
# of the 51.beta stage 0 had just built. createrepo_c itself walks
# the tree, so only the test needed fixing.
if [ -n "$(find /work/prior -name "*.rpm" -print -quit 2>/dev/null)" ]; then
dnf -y $disable install createrepo_c
createrepo_c /work/prior
printf "[stages]\nname=stages\nbaseurl=file:///work/prior\nenabled=1\ngpgcheck=0\npriority=1\n" \
> /etc/yum.repos.d/stages.repo
# priority alone does not keep Fedora out. gnome-control-center
# pulled Fedora accountsservice 23.13.9 even though stage 0 had
# built 26.27.3 and [stages] was priority 1: the Fedora main
# package entered the transaction and pinned accountsservice-libs
# to its exact NEVR, so our libs could not be installed and our
# devel, which needs them, was dropped --
# cannot install both accountsservice-libs-26.27.3 from stages
# and accountsservice-libs-23.13.9-16.fc44 from fedora
# Excluding by name is what settles it: whatever an earlier stage
# built, Fedora must not answer for. Names come from rpm rather
# than from parsing filenames, which stops working the moment a
# disttag changes.
EXCLUDE=$(find /work/prior -name "*.rpm" -type f -print0 \
| xargs -0 -r rpm -qp --qf "%{NAME}\n" 2>/dev/null \
| sort -u | paste -sd, -)
echo "excluding from Fedora: $EXCLUDE"
fi
# Hummingbird ships newer versions of some names than Fedora 44
# does, and the two must never mix in one transaction. The
# conflicts this prevents are real: libicu 78.3 (hum) vs 77.1
# (fc44) broke samba and evolution-data-server, and Fedora ruby
# 3.3/3.4-default-gems vs Hummingbird ruby4.0-default-gems broke
# webkitgtk, colord, libnotify and zsh. Always prefer the
# Hummingbird copy by excluding these names from Fedora.
# Unconditional: stage 0 has no prior RPMs, so the block above
# never ran and EXCLUDE would otherwise be empty here.
HB_EXCLUDE="ruby-default-gems,ruby3.3-default-gems,ruby3.4-default-gems,libicu,icu,gpgme,qt6-qtbase"
EXCLUDE="${HB_EXCLUDE}${EXCLUDE:+,}${EXCLUDE}"
echo "hummingbird exclusions: $HB_EXCLUDE"
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
# The plain fedora image is not a build root: it lacks the group mock
# installs, so /usr/bin/echo and friends are missing. Most packages pull
# them in transitively; squashfs-tools calls echo directly from its
# manpage installer and fails without it.
dnf -y $disable install dnf-plugins-core mock rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
# NO APOSTROPHES IN THIS SCRIPT. It is the body of bash -exc
# a single-quoted string, so one closes it and everything after
# is reparsed. That is what the stilted "Fedora own
# noopenh264" and "this job own diagnostic" above are avoiding.
# Three apostrophes in this very comment took out all 36 stage 0
# jobs at once, in under two minutes, with no clue in the log.
#
# The AlmaLinux convention, one distro over. They keep the vendor
# release and dist and append to it -- their dnf is
# 4.14.0-34.el9_8.alma.1 against 34.el9_8 from Red Hat, and both
# .alma and .alma.N appear in their repositories.
#
# The vendor here is Hummingbird, not Fedora. These packages are
# built for Hummingbird and installed on Hummingbird; Fedora 44 is
# only the other half of the buildroot, the way a compiler is. An
# earlier version of this tagged them .fc44.bfin, which named the
# distribution they are not for.
#
# The tag is read from the Hummingbird packages present in the
# buildroot rather than hardcoded, so a move to hum2 carries
# itself. A buildroot containing none of them is a repository
# misconfiguration -- the exact failure this factory exists to
# avoid -- so it stops rather than quietly tagging something else.
HUM_TAG="$(rpm -qa --qf "%{RELEASE}\n" | grep -oE "hum[0-9]+$" | sort -u | head -n1)"
if [ -z "$HUM_TAG" ]; then
echo "No Hummingbird package in the buildroot; cannot derive a disttag" >&2
rpm -qa --qf "%{NAME} %{RELEASE}\n" | sort | head -20 >&2
exit 1
fi
DISTTAG=".${HUM_TAG}.bfin${DIST_BUMP:-}"
echo "disttag: $DISTTAG"
# libratbag %check starts ratbagd, which calls
# Gio.bus_get_sync(Gio.BusType.SYSTEM) and dies with "Could not
# connect: No such file or directory" -- a plain container has no
# system bus socket. Seven of its ten suites already pass and the
# failing one is a real test, so give the build root a bus rather
# than disabling the test. Non-fatal: no other package needs it.
dnf -y $disable install dbus-daemon || true
mkdir -p /run/dbus
dbus-daemon --system --fork || true
# mock defines USER in its build root; a bare container does not.
# just 1.57.0 tests/functions.rs:88 calls env::var("USER").unwrap()
# and panicked with NotPresent -- 1823 tests passed, that one did
# not. Same shape as the missing system bus: supply what a real
# build root has rather than disable the test.
export USER="${USER:-root}"
export LOGNAME="${LOGNAME:-$USER}"
spec=$(find "/packages/$PACKAGE" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$spec"
# An imported spec keeps its dist-git PatchN and auxiliary SourceN
# files next to itself, while the verified upstream archive lands in
# /work/sources. rpmbuild takes a single _sourcedir, so stage both:
# recipe files first, then the verified archive, which therefore wins
# over anything of the same name carried in the import.
staged=/work/staged/$PACKAGE
rm -rf "$staged"
mkdir -p "$staged"
cp -a "/packages/$PACKAGE/." "$staged/"
cp -a "/work/sources/$PACKAGE/." "$staged/"
# Packages with %generate_buildrequires -- every Rust one -- compute
# their real BuildRequires during the build, so the spec alone does not
# list them and rpmbuild exits 11 asking to be re-run. Install what the
# generated source RPM declares and retry, bounded so an unsatisfiable
# requirement fails instead of looping.
for _ in 1 2 3 4 5; do
rm -f /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm
if rpmbuild -br "$spec" --define "_sourcedir $staged" \
--define "dist $DISTTAG"; then break; fi
generated=$(ls /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm 2>/dev/null | head -1)
test -n "$generated"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$generated"
done
rpmbuild -ba "$spec" \
--define "_sourcedir $staged" \
--define "dist $DISTTAG" \
--define "_rpmdir /work/result"
find /work/result -name "*.rpm" -type f -print0 | \
xargs -0 -r rpm -qp --qf "%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n"
# A build that produced no RPM must fail here. if-no-files-found on
# the upload cannot catch it: the artifact also carries
# work/reports/*.json, so one file always matches and the upload
# reports success while shipping no packages at all.
test -n "$(find /work/result -name "*.rpm" -type f -print -quit)"
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rpm-s3-${{ matrix.package }}
path: |
work/result/**/*.rpm
work/reports/*.json
if-no-files-found: error
rebuild4:
needs: [prepare, rebuild0, rebuild1, rebuild2, rebuild3]
if: ${{ !cancelled() && needs.prepare.outputs.stage4 != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.prepare.outputs.stage4) }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-sccache
- name: Collect RPMs built by earlier stages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: work/prior
merge-multiple: true
continue-on-error: true
- name: Fetch and verify direct upstream source
env:
PACKAGE: ${{ matrix.package }}
run: |
python3 tools/source_pipeline.py "$PACKAGE" --output work/sources --report-dir work/reports
- name: Build the verified source with its RPM recipe
env:
PACKAGE: ${{ matrix.package }}
FACTORY_REPO: ${{ vars.FACTORY_REPO }}
run: |
test -d "packages/$PACKAGE"
mkdir -p work/result
# Per-package, so it is resolved out here rather than inside the
# container, which only sees config/ and packages/.
DIST_BUMP="$(python3 tools/dist_bump.py "$PACKAGE")"
export DIST_BUMP
docker run --rm --privileged \
-e PACKAGE -e FACTORY_REPO -e DIST_BUMP \
-v "$PWD/work:/work:Z" \
-v "$PWD/packages:/packages:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
# The fedora:rawhide image ships fedora-cisco-openh264 enabled, but
# its packages are signed with Cisco key, which the image does not
# trust -- so any builddep graph reaching gstreamer/pipewire dies on
# "Import of the key did not help, wrong key?". openh264 is a runtime
# codec, never a build requirement, and Fedora own noopenh264 provides
# the same libopenh264.so.8 soname, so disabling the repo resolves.
disable=--disablerepo=fedora-cisco-openh264
# The Fedora container images set tsflags=nodocs, so every %doc file is
# dropped at install time. rand_core ships its crate docs that way and
# its lib.rs does #![doc = include_str!("../README.md")], so rust-just
# failed to compile: rustc could not read ../README.md. Nothing was wrong
# with the Fedora package: mock installs docs into a build root, and
# this container was not. Restore that.
sed -i "/^tsflags=nodocs/d" /etc/dnf/dnf.conf
# Fedora 44 plus Hummingbird, mirroring Hummingbird mock.cfg:
# Fedora release repos with its own Pulp repos shadowing them by
# priority. Proven correct by this job own diagnostic below --
# openssl 3.5.7 means libcrypto.so.3, the ABI Hummingbird has.
# A Rawhide root produced RPMs needing libcrypto.so.4 instead.
cp /repos/hummingbird.repo /etc/yum.repos.d/
# RPMs from earlier stages become a local repo, so a later stage
# can satisfy a BuildRequires on something this run just built.
# The guard must recurse: upload-artifact takes the common parent
# of its path globs as the artifact root, so an artifact declaring
# work/result/*.rpm and work/reports/*.json unpacks as
# prior/result/*.rpm, not prior/*.rpm. A non-recursive glob matched
# nothing, this block was silently skipped in every stage, and
# mutter resolved gsettings-desktop-schemas to Fedora 50.1 instead
# of the 51.beta stage 0 had just built. createrepo_c itself walks
# the tree, so only the test needed fixing.
if [ -n "$(find /work/prior -name "*.rpm" -print -quit 2>/dev/null)" ]; then
dnf -y $disable install createrepo_c
createrepo_c /work/prior
printf "[stages]\nname=stages\nbaseurl=file:///work/prior\nenabled=1\ngpgcheck=0\npriority=1\n" \
> /etc/yum.repos.d/stages.repo
# priority alone does not keep Fedora out. gnome-control-center
# pulled Fedora accountsservice 23.13.9 even though stage 0 had
# built 26.27.3 and [stages] was priority 1: the Fedora main
# package entered the transaction and pinned accountsservice-libs
# to its exact NEVR, so our libs could not be installed and our
# devel, which needs them, was dropped --
# cannot install both accountsservice-libs-26.27.3 from stages
# and accountsservice-libs-23.13.9-16.fc44 from fedora
# Excluding by name is what settles it: whatever an earlier stage
# built, Fedora must not answer for. Names come from rpm rather
# than from parsing filenames, which stops working the moment a
# disttag changes.
EXCLUDE=$(find /work/prior -name "*.rpm" -type f -print0 \
| xargs -0 -r rpm -qp --qf "%{NAME}\n" 2>/dev/null \
| sort -u | paste -sd, -)
echo "excluding from Fedora: $EXCLUDE"
fi
# Hummingbird ships newer versions of some names than Fedora 44
# does, and the two must never mix in one transaction. The
# conflicts this prevents are real: libicu 78.3 (hum) vs 77.1
# (fc44) broke samba and evolution-data-server, and Fedora ruby
# 3.3/3.4-default-gems vs Hummingbird ruby4.0-default-gems broke
# webkitgtk, colord, libnotify and zsh. Always prefer the
# Hummingbird copy by excluding these names from Fedora.
# Unconditional: stage 0 has no prior RPMs, so the block above
# never ran and EXCLUDE would otherwise be empty here.
HB_EXCLUDE="ruby-default-gems,ruby3.3-default-gems,ruby3.4-default-gems,libicu,icu,gpgme,qt6-qtbase"
EXCLUDE="${HB_EXCLUDE}${EXCLUDE:+,}${EXCLUDE}"
echo "hummingbird exclusions: $HB_EXCLUDE"
if [ -n "${FACTORY_REPO:-}" ]; then
printf "[factory]\nname=factory\nbaseurl=%s\nenabled=1\ngpgcheck=0\npriority=5\n" \
"$FACTORY_REPO" > /etc/yum.repos.d/factory.repo
fi
# The plain fedora image is not a build root: it lacks the group mock
# installs, so /usr/bin/echo and friends are missing. Most packages pull
# them in transitively; squashfs-tools calls echo directly from its
# manpage installer and fails without it.
dnf -y $disable install dnf-plugins-core mock rpm-build @buildsys-build
rpm -q --qf "buildroot openssl: %{VERSION}-%{RELEASE}\n" openssl-libs || true
# NO APOSTROPHES IN THIS SCRIPT. It is the body of bash -exc
# a single-quoted string, so one closes it and everything after
# is reparsed. That is what the stilted "Fedora own
# noopenh264" and "this job own diagnostic" above are avoiding.
# Three apostrophes in this very comment took out all 36 stage 0
# jobs at once, in under two minutes, with no clue in the log.
#
# The AlmaLinux convention, one distro over. They keep the vendor
# release and dist and append to it -- their dnf is
# 4.14.0-34.el9_8.alma.1 against 34.el9_8 from Red Hat, and both
# .alma and .alma.N appear in their repositories.
#
# The vendor here is Hummingbird, not Fedora. These packages are
# built for Hummingbird and installed on Hummingbird; Fedora 44 is
# only the other half of the buildroot, the way a compiler is. An
# earlier version of this tagged them .fc44.bfin, which named the
# distribution they are not for.
#
# The tag is read from the Hummingbird packages present in the
# buildroot rather than hardcoded, so a move to hum2 carries
# itself. A buildroot containing none of them is a repository
# misconfiguration -- the exact failure this factory exists to
# avoid -- so it stops rather than quietly tagging something else.
HUM_TAG="$(rpm -qa --qf "%{RELEASE}\n" | grep -oE "hum[0-9]+$" | sort -u | head -n1)"
if [ -z "$HUM_TAG" ]; then
echo "No Hummingbird package in the buildroot; cannot derive a disttag" >&2
rpm -qa --qf "%{NAME} %{RELEASE}\n" | sort | head -20 >&2
exit 1
fi
DISTTAG=".${HUM_TAG}.bfin${DIST_BUMP:-}"
echo "disttag: $DISTTAG"
# libratbag %check starts ratbagd, which calls
# Gio.bus_get_sync(Gio.BusType.SYSTEM) and dies with "Could not
# connect: No such file or directory" -- a plain container has no
# system bus socket. Seven of its ten suites already pass and the
# failing one is a real test, so give the build root a bus rather
# than disabling the test. Non-fatal: no other package needs it.
dnf -y $disable install dbus-daemon || true
mkdir -p /run/dbus
dbus-daemon --system --fork || true
# mock defines USER in its build root; a bare container does not.
# just 1.57.0 tests/functions.rs:88 calls env::var("USER").unwrap()
# and panicked with NotPresent -- 1823 tests passed, that one did
# not. Same shape as the missing system bus: supply what a real
# build root has rather than disable the test.
export USER="${USER:-root}"
export LOGNAME="${LOGNAME:-$USER}"
spec=$(find "/packages/$PACKAGE" -maxdepth 1 -name "*.spec" -print -quit)
test -n "$spec"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$spec"
# An imported spec keeps its dist-git PatchN and auxiliary SourceN
# files next to itself, while the verified upstream archive lands in
# /work/sources. rpmbuild takes a single _sourcedir, so stage both:
# recipe files first, then the verified archive, which therefore wins
# over anything of the same name carried in the import.
staged=/work/staged/$PACKAGE
rm -rf "$staged"
mkdir -p "$staged"
cp -a "/packages/$PACKAGE/." "$staged/"
cp -a "/work/sources/$PACKAGE/." "$staged/"
# Packages with %generate_buildrequires -- every Rust one -- compute
# their real BuildRequires during the build, so the spec alone does not
# list them and rpmbuild exits 11 asking to be re-run. Install what the
# generated source RPM declares and retry, bounded so an unsatisfiable
# requirement fails instead of looping.
for _ in 1 2 3 4 5; do
rm -f /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm
if rpmbuild -br "$spec" --define "_sourcedir $staged" \
--define "dist $DISTTAG"; then break; fi
generated=$(ls /root/rpmbuild/SRPMS/*.buildreqs.nosrc.rpm 2>/dev/null | head -1)
test -n "$generated"
dnf -y $disable ${EXCLUDE:+--setopt=fedora.excludepkgs="$EXCLUDE"} \
${EXCLUDE:+--setopt=updates.excludepkgs="$EXCLUDE"} builddep -D "_sourcedir /packages/$PACKAGE" "$generated"
done
rpmbuild -ba "$spec" \
--define "_sourcedir $staged" \
--define "dist $DISTTAG" \
--define "_rpmdir /work/result"
find /work/result -name "*.rpm" -type f -print0 | \
xargs -0 -r rpm -qp --qf "%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n"
# A build that produced no RPM must fail here. if-no-files-found on
# the upload cannot catch it: the artifact also carries
# work/reports/*.json, so one file always matches and the upload
# reports success while shipping no packages at all.
test -n "$(find /work/result -name "*.rpm" -type f -print -quit)"
'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rpm-s4-${{ matrix.package }}
path: |
work/result/**/*.rpm
work/reports/*.json
if-no-files-found: error
# A disttag is a convention, not a guarantee. What actually has to be true is
# that every RPM we publish outranks whatever Fedora 44 and Hummingbird offer
# under the same name -- otherwise dnf installs theirs and the rebuild we went
# to the trouble of making is never used. Nothing checked that, and it fails
# silently: the image still resolves, just to the wrong build.
#
# This resolves each name against those repositories, with our own output not
# in the picture, and compares. It reports every offender before failing, so
# one run yields the whole worklist instead of one entry at a time.
precedence:
needs: [prepare, rebuild0, rebuild1, rebuild2, rebuild3, rebuild4]
if: ${{ !cancelled() && needs.prepare.outputs.build_list != '[]' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: built
merge-multiple: true
- name: Assert our builds outrank Fedora and Hummingbird
run: |
docker run --rm \
-v "$PWD/built:/built:ro,Z" \
-v "$PWD/config:/repos:ro,Z" \
quay.io/fedora/fedora:44 bash -exc '
dnf -y install rpmdevtools >/dev/null
cp /repos/hummingbird.repo /etc/yum.repos.d/
disable=--disablerepo=fedora-cisco-openh264
fmt="%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}"
: > /tmp/losers
: > /tmp/overlap
find /built -name "*.rpm" -type f | sort | while read -r rpm; do
case "$rpm" in
*.src.rpm) continue ;;
# The malcontent bootstrap is ranked below the real build on
# purpose and is deleted before publish. It is meant to lose.
*-0.bootstrap.*) continue ;;
esac
name=$(rpm -qp --qf "%{NAME}" "$rpm" 2>/dev/null) || continue
ours=$(rpm -qp --qf "$fmt" "$rpm")
line=$(dnf -q $disable repoquery --latest-limit=1 \
--qf "$fmt %{reponame}" "$name" 2>/dev/null | tail -n1)
theirs=${line%% *}
repo=${line##* }
# Nothing else ships this name, so there is nothing to outrank.
[ -n "$theirs" ] || continue
rc=0; rpmdev-vercmp "$ours" "$theirs" >/dev/null 2>&1 || rc=$?
# 11 means the first argument is newer, which is what we need.
# 0 (equal) and 12 (theirs newer) both mean ours will not win.
[ "$rc" = 11 ] || echo " $name: ours $ours, $repo has $theirs" >> /tmp/losers
# .hum1.bfin outranks .hum1 at equal release by construction, so
# a package the base OS also ships no longer appears as a loser.
# It is still worth knowing about: this factory is for the
# desktop stack Hummingbird does not ship.
case "$repo" in
*hummingbird*) echo " $name: ours $ours, $repo also has $theirs" >> /tmp/overlap ;;
esac
done
if [ -s /tmp/losers ]; then
echo "These builds do not outrank what the repositories already offer,"
echo "so dnf would install the other one:"
cat /tmp/losers
echo
echo "Our disttag outranks both Fedora and Hummingbird at an equal"
echo "release, so a loser here means the thing we forked has moved on:"
echo "its leading release segment is now higher than ours. Rebase the"
echo "spec on the newer dist-git, which raises ours to match."
echo
echo "If the release has NOT moved and we simply need to build it again,"
echo "add \"dist_bump\" to the package in config/upstream-sources.json"
echo "instead. That appends .N after .bfin, which is what AlmaLinux does"
echo "with .alma.1. It cannot help with a release bump, because the"
echo "leading segment comes from the spec."
exit 1
fi
if [ -s /tmp/overlap ]; then
echo "Note: Hummingbird also ships these, and we outrank it:"
cat /tmp/overlap
echo "The remit is the desktop stack Hummingbird does not ship, so each of"
echo "these is worth checking. Not a failure."
fi
echo "every build outranks what Fedora 44 and Hummingbird offer"
'
publish:
# The consumer tag is atomic. Successful artifacts from a failed matrix are
# still retained by Actions for diagnosis, but they never replace the
# coherent repository Utah consumes. Publication requires every selected
# stage plus precedence and the Hummingbird-only transaction below.
#
# This is no longer main-only. The repository is published as an OCI image
# first and to Pages second, and the OCI push is what consumers actually
# read. Pages can only deploy from the default branch, which meant nothing
# existed to consume until a merge landed -- so an image could never be
# tested against the packages it was meant to use before merging either of
# them. A registry has no such rule.
#
# A pull request from a fork is the exception, and it has to be stated
# rather than assumed: GitHub gives those a read-only GITHUB_TOKEN, so the
# GHCR login and push would fail however the rest of the run went. Skipping
# is honest; failing on a permission that was never going to be granted is
# noise that reads like a broken pipeline.
if: >-
${{ !cancelled()
&& needs.prepare.outputs.build_list != '[]'
&& needs.precedence.result == 'success'
&& (needs.rebuild0.result == 'success' || needs.rebuild0.result == 'skipped')
&& (needs.rebuild1.result == 'success' || needs.rebuild1.result == 'skipped')
&& (needs.rebuild2.result == 'success' || needs.rebuild2.result == 'skipped')
&& (needs.rebuild3.result == 'success' || needs.rebuild3.result == 'skipped')
&& (needs.rebuild4.result == 'success' || needs.rebuild4.result == 'skipped')
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository) }}
needs: [prepare, rebuild0, rebuild1, rebuild2, rebuild3, rebuild4, precedence]
# Build waves can overlap, but `:latest` is an atomic consumer input. Queue
# only this critical section per ref; the later publisher re-seeds from the
# repository that its predecessor just made current.
concurrency:
group: utah-packages-publish-${{ github.ref }}
cancel-in-progress: false
runs-on: ubuntu-24.04
outputs:
ref: ${{ steps.oci.outputs.ref }}
digest: ${{ steps.oci.outputs.digest }}
steps:
- name: Seed repository from the last published factory image
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]")
image="ghcr.io/${owner}/utah-packages:latest"
mkdir -p repository
echo "${GITHUB_TOKEN}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
if podman pull "$image"; then
container=$(podman create "$image")
podman cp "$container:/repository/." repository/
podman rm "$container"
else
echo "No prior factory image; publishing a new repository"
fi
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: rpm-*
path: repository
merge-multiple: true
- name: Create and keylessly sign repository metadata
run: |
sudo apt-get update && sudo apt-get install -y createrepo-c
# The malcontent bootstrap pass exists only to give the next stage a
# malcontent-libs linked against accountsservice 26, breaking the
# flatpak-libs -> libmalcontent-0.so.0 cycle. It has no parental
# controls UI, so it must not reach anyone's system. Its release is
# 0.bootstrap, below the real build, so this is belt and braces.
find repository -name '*-0.bootstrap.*.rpm' -print -delete
createrepo_c --update repository
curl -fsSL https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64 -o cosign
install -m 0755 cosign /usr/local/bin/cosign
cosign sign-blob --yes --bundle repository/repodata/repomd.xml.bundle repository/repodata/repomd.xml
- name: Validate Hummingbird-only consumer transaction
run: |
CONTRACT=$(python3 tools/runtime_contract.py \
config/bluefin-packages.toml config/runtime-contract.toml)
BASE_IMAGE=$(python3 tools/runtime_contract.py \
config/bluefin-packages.toml config/runtime-contract.toml --base-image)
export CONTRACT
docker run --rm \
-e CONTRACT \
-v "$PWD/repository:/repository:ro,Z" \
-v "$PWD/config:/config:ro,Z" \
"$BASE_IMAGE" bash -euo pipefail -c '
cp /config/hummingbird.repo /etc/yum.repos.d/hummingbird.repo
cat >/etc/yum.repos.d/factory.repo <<EOF
[factory]
name=Utah package factory candidate
baseurl=file:///repository
enabled=1
gpgcheck=0
EOF
mapfile -t packages <<<"$CONTRACT"
test "${#packages[@]}" -gt 0
DNF=$(command -v dnf5 || command -v dnf)
output=$($DNF --assumeno --setopt=install_weak_deps=False \
--disablerepo="*" \
--enablerepo=factory \
--enablerepo=public-hummingbird-x86_64-rpms \
install "${packages[@]}" 2>&1 || true)
printf "%s\n" "$output"
if grep -Eqi "No match for argument|nothing provides|conflicting requests|cannot install both|does not belong to a distupgrade repository" <<<"$output"; then
echo "Hummingbird-only consumer transaction is not resolvable" >&2
exit 1
fi
if ! grep -Eq "Transaction Summary|Nothing to do" <<<"$output"; then
echo "DNF did not produce a valid transaction summary" >&2
exit 1
fi
echo "Hummingbird-only consumer transaction resolves ${#packages[@]} packages"
'
# The repository as an OCI image, which is how everything else in this
# ecosystem ships build output: Utah Containerfile already pulls
# projectbluefin/common and ublue-os/brew this way, pinned by digest.
#
# A registry beats a Pages site here on three counts. It works from any
# branch, so an image can be built against a package set before either is
# merged. It is addressable by digest, so an image records exactly which
# packages went into it rather than whatever the site served that day. And
# provenance comes from the registry and a signature over the digest,
# rather than from an unsigned directory of RPMs over HTTPS.
- name: Publish the repository as an OCI image
id: oci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKER_CONFIG: ${{ runner.temp }}/.docker
run: |
set -euo pipefail
owner=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]")
image="ghcr.io/${owner}/utah-packages"
# main publishes the tag consumers follow; every other ref publishes
# under its own name so it can be tested without touching main.
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
tag=latest
else
tag=$(echo "${{ github.ref_name }}" | tr "/" "-" | tr -cd "[:alnum:]._-" | cut -c1-96)
fi
printf "FROM scratch\nCOPY repository /repository\n" > Containerfile.repo
# cosign signs by pushing to the same registry, but it reads the
# Docker config rather than podman own auth file, so the login has to
# land somewhere both of them look. DOCKER_CONFIG is set on this step
# and on the signing step to the same directory.
mkdir -p "${DOCKER_CONFIG}"
echo "${GITHUB_TOKEN}" | podman login ghcr.io -u "${{ github.actor }}" \
--password-stdin --authfile "${DOCKER_CONFIG}/config.json"
podman build --tag "${image}:${tag}" --file Containerfile.repo .
podman push "${image}:${tag}" --digestfile /tmp/digest \
--authfile "${DOCKER_CONFIG}/config.json"
digest=$(cat /tmp/digest)
echo "ref=${image}:${tag}" >> "$GITHUB_OUTPUT"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "published ${image}:${tag}"
echo "consume it with: ${image}@${digest}"
{
echo "### Package repository published"
echo
echo '```'
echo "${image}:${tag}"
echo "${image}@${digest}"
echo '```'
echo
echo "Consume in a Containerfile:"
echo '```dockerfile'
echo "FROM ${image}@${digest} AS packages"
echo "COPY --from=packages /repository /etc/utah-packages"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Sign the published image
env:
COSIGN_YES: "true"
DOCKER_CONFIG: ${{ runner.temp }}/.docker
run: |
owner=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]")
cosign sign "ghcr.io/${owner}/utah-packages@${{ steps.oci.outputs.digest }}"
# Handed to the Pages job rather than deployed here, so that the registry
# push never depends on the github-pages environment. That environment
# usually carries a deployment branch rule, and inheriting it would have
# blocked this job on exactly the branches the OCI push exists to serve.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: repository
path: repository
retention-days: 1
# A convenience mirror for anything wanting a plain HTTP repository. Pages can
# only deploy from the default branch, which is why it is no longer the thing
# consumers depend on.
publish_pages:
if: ${{ !cancelled() && github.ref == 'refs/heads/main' && needs.publish.result == 'success' }}
needs: publish
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: repository
path: repository
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: repository
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5