Files
pgadmin4/.github/workflows/release-candidate.yml
T
Dave Page 720dd3714f Ship the docs tarball with its versioned directory, as releases always have
The web server unpacks this into the documentation root and then hands
docloader the directory name pgadmin4-<version>-docs, so a flat archive would
have scattered several hundred HTML files across that root and then failed on
a directory that was never created. Every published tarball carries that top
level; the one assembled here did not, and the first release to notice would
have been the next one.
2026-09-22 11:25:32 +01:00

427 lines
19 KiB
YAML

# Release candidate builds, replacing the buildfarm's pgadmin4-*-build jobs.
#
# The same builds as the nightly snapshot, published to the development server
# instead of the download server. That difference is the point: a snapshot is
# finished when it is published, whereas a candidate is published in order to
# be tested, and promoted to the download server only afterwards.
#
# developer.pgadmin.org serves this tree over HTTP as soon as it is written, so
# a candidate's repositories can be added and installed from immediately,
# rather than waiting on the hourly sync between the download server and the
# mirrors.
#
# Never scheduled. A candidate exists because somebody decided to make one.
name: Release candidate
on:
workflow_dispatch:
inputs:
location:
description: >-
Build directory name, normally today's date. A second
candidate on the same day needs a -N suffix, for example
2026-09-17-2.
required: false
default: ''
publish:
description: Upload to the development server when the build succeeds
type: boolean
required: false
default: false
concurrency:
# One candidate at a time, and never cancelled: a cancelled run can abandon a
# notarisation already submitted to Apple, and leaves the signing token in an
# unknown state.
group: 'release-candidate'
cancel-in-progress: false
permissions:
contents: read
jobs:
# The directory name, decided once. Two jobs need it and they must agree:
# the repo RPMs carry the candidate's own URL inside them, so if that job and
# the assemble job each called date(1) a run straddling midnight would ship a
# repository configuration pointing at a build that does not exist.
location:
runs-on: ubuntu-24.04
outputs:
name: ${{ steps.pick.outputs.name }}
steps:
- id: pick
env:
REQUESTED: ${{ inputs.location }}
run: |
set -euo pipefail
NAME="${REQUESTED:-}"
[ -n "${NAME}" ] || NAME=$(date +%Y-%m-%d)
# Validated here so that everything downstream, including a shell on
# the machine holding the publishing keys, is working with a name
# that cannot be anything but a date and an optional suffix. The
# wrapper on the server applies its own rule to the same value; this
# one exists so a bad input fails in five seconds rather than after
# an hour of building.
if ! printf '%s' "${NAME}" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}(-[1-9][0-9]?)?$'; then
echo "Not a valid build location: ${NAME}" >&2
echo "Expected YYYY-MM-DD, optionally with a -N suffix." >&2
exit 1
fi
echo "Build location: ${NAME}"
echo "name=${NAME}" >> "${GITHUB_OUTPUT}"
deb:
uses: ./.github/workflows/check-deb-build.yml
rpm:
uses: ./.github/workflows/check-rpm-build.yml
tarball:
uses: ./.github/workflows/check-tarball-build.yml
wheel:
uses: ./.github/workflows/check-python-build.yml
docs:
uses: ./.github/workflows/check-doc-builds.yml
# The container, pushed to the registry under this candidate's location so
# that it can be tested and then re-tagged at promotion. Not part of the
# download tree, which is why it is not in assemble's needs.
container:
needs: location
if: inputs.publish
uses: ./.github/workflows/check-container-build.yml
with:
publish: true
location: ${{ needs.location.outputs.name }}
secrets: inherit
# The Helm chart is built as a check but not published here. Its version
# comes from pkg/helm/Chart.yaml, so pushing it would publish the release
# version before the release, and the registry has no notion of a candidate
# the way the download server does. It goes out at promotion.
helm:
uses: ./.github/workflows/check-helm-build.yml
# The repository configuration RPMs. These are not built by the QA
# workflows, because they are not a build of pgAdmin: they are a one-file
# package carrying a .repo pointing at the tree they ship in, which is why
# the URL has to be known before they are built.
repo-rpm:
needs: location
runs-on: ubuntu-24.04
container: almalinux:9
steps:
- name: Install the build environment
run: dnf install -y --setopt=install_weak_deps=False rpm-build python3 git
- uses: actions/checkout@v7
- name: Mark the workspace as a safe git directory
run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"
- name: Build the repository RPMs
env:
LOCATION: ${{ needs.location.outputs.name }}
run: |
# Through the environment rather than interpolated into the script.
# The location is a dispatch input, so it is text somebody chose, and
# workflow expression substitution happens before the shell ever sees the
# line, so a crafted location would otherwise become shell text.
export PGADMIN_REPO_DIR="https://developer.pgadmin.org/builds/${LOCATION}/yum"
echo "Repositories will point at ${PGADMIN_REPO_DIR}"
pkg/redhat/repo-rpm.sh
- name: Archive the repository RPMs
uses: actions/upload-artifact@v7
with:
name: pgadmin4-repo-rpm-build-output
if-no-files-found: error
path: dist/*.noarch.rpm
# snapshot-macos.yml and snapshot-windows.yml are the signed-build
# workflows rather than snapshot-only ones; the name predates there being
# a second caller.
macos:
uses: ./.github/workflows/snapshot-macos.yml
secrets: inherit
windows:
uses: ./.github/workflows/snapshot-windows.yml
secrets: inherit
assemble:
needs: [location, deb, rpm, tarball, wheel, docs, repo-rpm, macos, windows]
# the runner, not a hosted runner. This job assembles a tree of several
# hundred megabytes and then, when publishing, sends it to the development
# server; splitting the two meant uploading that tree as an artifact and
# immediately downloading it again on the runner, having first queued for a
# hosted runner behind whatever else the organisation was building.
runs-on: [self-hosted, publish]
# For the variables naming the keys and the target, which live here. The
# environment carries no protection rules, so a run that is not publishing
# is not gated by it.
environment: publishing
# Everything must have built except Windows. That is the one runner which
# can legitimately be offline, and a candidate without the installer is
# still worth having; a candidate missing its documentation, source tarball,
# wheel or repository RPMs is not, and the repository RPMs are worse than
# missing, since install-repo-readme.py then fails after the packages have
# been uploaded and signed, leaving half a candidate on the server.
if: >-
always()
&& needs.location.result == 'success'
&& needs.deb.result == 'success'
&& needs.rpm.result == 'success'
&& needs.tarball.result == 'success'
&& needs.wheel.result == 'success'
&& needs.docs.result == 'success'
&& needs.repo-rpm.result == 'success'
&& needs.macos.result == 'success'
steps:
- uses: actions/checkout@v7
- name: Collect the build artefacts
uses: actions/download-artifact@v7
with:
path: artefacts
# The layout is the one the publishing scripts and the download site
# expect, and is what the wrapper's stage-create verb creates on the
# staging server.
# The Debian architecture comes from the package filename rather than a
# table, since dpkg already encodes it there; the codename does not, so
# that mapping is explicit.
- name: Assemble the staging tree
env:
LOCATION: ${{ needs.location.outputs.name }}
run: |
set -euo pipefail
# Flat, beside apt/ and yum/, which is how both the staging server
# and the snapshots tree are laid out. The docs/macos/pip/source/
# windows subdivision belongs to a published release, and it is the
# promotion step that sorts files into it on the way there.
# Explicitly, rather than trusting the checkout to have cleaned it.
# This runs on a self-hosted runner whose workspace persists between
# jobs, and a previous run's tree left behind here is indistinguishable
# from this one's until something downstream has to choose between
# them.
rm -rf stage
STAGE="stage/${LOCATION}"
mkdir -p "${STAGE}/yum"
# An artefact can be legitimately absent: the Windows signing host is
# allowed to be offline, and a failed matrix leg is reported by its
# own job rather than by a confusing cp error here.
copy_into() {
DEST=$1; shift
for SRC in "$@"; do
[ -e "${SRC}" ] || continue
mkdir -p "${DEST}"
cp "${SRC}" "${DEST}/"
done
}
codename_for() {
case "$1" in
debian-12) echo bookworm ;;
debian-13) echo trixie ;;
ubuntu-2204) echo jammy ;;
ubuntu-2404) echo noble ;;
ubuntu-2604) echo resolute ;;
*) echo "Unknown Debian build name: $1" >&2; exit 1 ;;
esac
}
for DIR in artefacts/pgadmin4-deb-build-output-*; do
[ -d "${DIR}" ] || continue
CODENAME=$(codename_for "${DIR##*-output-}")
for DEB in "${DIR}"/*.deb; do
ARCH=$(basename "${DEB}" .deb); ARCH=${ARCH##*_}
TARGET="${STAGE}/apt/${CODENAME}/dists/pgadmin4/main/binary-${ARCH}"
mkdir -p "${TARGET}"
cp "${DEB}" "${TARGET}/"
done
done
# RPM trees are named for the distribution, which the build name
# already carries: el-9 becomes redhat/rhel-9-x86_64.
for DIR in artefacts/pgadmin4-rpm-build-output-*; do
[ -d "${DIR}" ] || continue
NAME=${DIR##*-output-}
case "${NAME}" in
el-*) FAMILY=redhat; TREE="rhel-${NAME#el-}" ;;
fedora-*) FAMILY=fedora; TREE="${NAME}" ;;
*) echo "Unknown RPM build name: ${NAME}" >&2; exit 1 ;;
esac
# Every RPM for a distribution goes in that distribution's
# tree, including the noarch ones. Taking the directory from the
# package's own architecture would file pgadmin4 and
# pgadmin4-web, both noarch, under rhel-9-noarch, where no
# client looks: dnf asks for <name>-<version>-$basearch. The
# RPM matrix is x86_64 only for now, so this is a constant; when
# aarch64 arrives it becomes a property of the build.
TARGET="${STAGE}/yum/${FAMILY}/${TREE}-x86_64"
mkdir -p "${TARGET}"
cp "${DIR}"/*.rpm "${TARGET}/"
done
copy_into "${STAGE}" artefacts/pgadmin4-tarball-build-output/*
copy_into "${STAGE}" artefacts/pgadmin4-python-build-output/*
# The version is read once here and used for the documentation
# filenames, which the build leaves unversioned as pgadmin4.pdf and
# pgAdmin4.epub whilst the download site has always carried
# pgadmin4-<version>.pdf and .epub.
VERSION=$(python3 -c "import sys; sys.path.insert(0, 'web'); import version; print(version.APP_VERSION)")
for SRC in artefacts/pgadmin4-pdf-build-output/*.pdf; do
[ -e "${SRC}" ] && cp "${SRC}" "${STAGE}/pgadmin4-${VERSION}.pdf"
done
for SRC in artefacts/pgadmin4-epub-build-output/*.epub; do
[ -e "${SRC}" ] && cp "${SRC}" "${STAGE}/pgadmin4-${VERSION}.epub"
done
# The docs tarball is what load-docs.sh unpacks on the web server, so
# it ships as a tarball rather than as the directory of files the
# build produces.
if [ -d artefacts/pgadmin4-html-build-output ]; then
# Under a pgadmin4-<version>-docs/ directory, not flat. The
# web server's load-docs.sh unpacks this into the docs root
# and then hands docloader that directory name, so a flat
# archive would scatter several hundred files across the root
# and then fail on a directory that does not exist. Every
# released tarball has carried that top level; the tar here
# had not.
DOCS_DIR="pgadmin4-${VERSION}-docs"
rm -rf "artefacts/${DOCS_DIR}"
mv artefacts/pgadmin4-html-build-output "artefacts/${DOCS_DIR}"
tar -czf "${STAGE}/pgadmin4-${VERSION}-docs.tar.gz" \
-C artefacts "${DOCS_DIR}"
fi
# At the root of yum/, beside the family directories, which is
# where the README instructions tell people to fetch them from.
copy_into "${STAGE}/yum" artefacts/pgadmin4-repo-rpm-build-output/*
copy_into "${STAGE}" artefacts/pgadmin4-macos-snapshot-*/*
copy_into "${STAGE}" artefacts/pgadmin4-windows-snapshot/*
find "${STAGE}" -type f | sort
- name: Check the keys are present
if: inputs.publish
env:
STAGING: ${{ vars.STAGING_SSH_TARGET }}
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
run: |
set -euo pipefail
for TOOL in ssh rsync; do
command -v "${TOOL}" >/dev/null || {
echo "This runner has no ${TOOL}." >&2; exit 1; }
done
# These three variables reach option positions. The target is the
# first positional argument to ssh, and the key paths are spliced
# into the -e string that rsync splits on whitespace before exec'ing
# it, so a value beginning with a hyphen, or one carrying a space,
# would be read as an option or as a second word rather than as the
# host or the path it is meant to be. Only an administrator can set
# a repository variable, which makes this defence in depth rather
# than a live hole, but it is checked here, once, rather than at
# each point of use; the -- in the publish step below closes the
# same gap from the other end.
TARGET_RE='^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9.-]*)?$'
printf '%s' "${STAGING}" | grep -Eq "${TARGET_RE}" || {
echo "STAGING_SSH_TARGET is not a [user@]host." >&2; exit 1; }
for KEY in "${PUBLISH_KEY/#\~/$HOME}" "${UPLOAD_KEY/#\~/$HOME}"; do
printf '%s' "${KEY}" | grep -Eq '^/[A-Za-z0-9._/-]+$' || {
echo "Not a plain absolute path: ${KEY}" >&2; exit 1; }
if [ ! -r "${KEY}" ]; then
echo "Missing or unreadable SSH key: ${KEY}" >&2
exit 1
fi
# A public key is readable too, and pointing -i at one fails
# later inside ssh with a message that does not mention which
# file was wrong. Both halves of a pair sit in the same
# directory with names one suffix apart, so this is an easy
# thing to configure wrongly and a cheap thing to catch.
if head -c 4 "${KEY}" | grep -q '^ssh-'; then
echo "${KEY} is a public key; -i needs the private one." >&2
echo "Drop the .pub suffix from the variable that names it." >&2
exit 1
fi
done
if [ ! -r ~/.ssh/known_hosts ]; then
echo "This runner has no ~/.ssh/known_hosts, so host key" >&2
echo "checking would fail. Add the servers' host keys." >&2
exit 1
fi
- name: Publish the candidate
if: inputs.publish
env:
STAGING: ${{ vars.STAGING_SSH_TARGET }}
LOCATION: ${{ inputs.location }}
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
run: |
set -euo pipefail
if [ -z "${STAGING}" ]; then
echo "The STAGING_SSH_TARGET variable is not set." >&2
exit 1
fi
# One directory came out of assemble, named for the date. An explicit
# location overrides it so that a test run does not land on the
# build the buildfarm is still writing on the same server.
# assemble produces exactly one directory, named for the date.
# Anything else means the artefact is not what this step thinks it
# is, and guessing which one to publish is not a decision to make at
# three in the morning.
if [ "$(find stage -mindepth 1 -maxdepth 1 -type d | wc -l)" != "1" ]; then
echo "Expected one directory in the build artefact:" >&2
ls -l stage >&2
exit 1
fi
BUILT=$(basename "$(find stage -mindepth 1 -maxdepth 1 -type d)")
DEST=${LOCATION:-${BUILT}}
echo "Publishing ${BUILT} as ${DEST}"
publish() {
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
-o StrictHostKeyChecking=yes -- "${STAGING}" "$@"
}
publish stage-create "${DEST}"
# No --delete, deliberately: rrsync refuses it anyway, and a
# publishing step that can remove things is a publishing step that
# can remove the wrong things.
rsync -rlt --safe-links --no-specials --no-devices \
-e "ssh -i ${UPLOAD_KEY/#\~/$HOME} -o BatchMode=yes -o StrictHostKeyChecking=yes" \
"stage/${BUILT}/" "${STAGING}:${DEST}/"
# Signing before indexing: the metadata has to describe the signed
# packages rather than the unsigned ones just uploaded.
publish stage-sign "${DEST}"
publish stage-index-apt "${DEST}"
publish stage-index-yum "${DEST}"
# Installing from the candidate, which is the only check that proves a user
# could have. It gates nothing automatically yet, promotion still being a
# human decision, but a red result here is a candidate that must not be
# promoted.
acceptance:
needs: [location, assemble]
if: inputs.publish
uses: ./.github/workflows/acceptance-test.yml
with:
base-url: https://developer.pgadmin.org/builds/${{ needs.location.outputs.name }}