From 321a8f691c5f75e722b45f1b3e1122b3831d80a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 21:07:56 +0000 Subject: [PATCH 1/3] [ci] Fill the nightly release notes from the registry, not the local store Since the nightly started publishing by promotion (#3233), the deploy job builds nothing: `make promote_nightly_images` copies the tested manifests registry to registry. generate_release_notes.sh had not moved with it and still read everything from a build that no longer happens, so every section of the nightly release came out blank. Two separate ways, both silent: - The versions were read out of `/:-`. A release publishes that tag; the nightly publishes :nightly and nothing else, so every `docker run` hit an image that exists nowhere and each cell of the table was left empty. The tag to read is now IMAGE_TAG, which nightly.yml sets to nightly and a release leaves at the default. - The two "Published Docker images" lists came from `docker images`, i.e. the local store, which the promotion path never fills. They are now built by asking the registry for each published manifest, which is the same answer on either path - and reports the digest users actually pull and the architectures the manifest covers, instead of a local image id. Nothing empty passes quietly any more. A version that comes back blank, a registry with none of the images in it, or a LATEST_TAG that would silently turn the changelog range into HEAD...trunk now fails the step by name. The images are promoted before this runs, so a failure leaves the previous notes up and turns the run red rather than replacing them with a blank page. The reads also pull, where before they were free, so each image is dropped again as soon as its values are out: the browser images together are larger than the runner has free. Anything already in the store came from a local build and is left alone. The arm64 rows need the emulators that the multi-architecture build used to register as a side effect, so the job now sets up QEMU itself. The image list moves to `make print_ci_images` so the notes and the Makefile cannot drift, and both workflows pass GHCR_NAMESPACE so the GHCR list follows the repository owner rather than the script's SeleniumHQ default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HNaarX452mih58sA7QPTz2 --- .github/workflows/deploy.yml | 7 +- .github/workflows/nightly.yml | 26 +++++- Makefile | 6 ++ generate_release_notes.sh | 170 ++++++++++++++++++++++++++++++---- 4 files changed, 185 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 59812a0ba..e8e0e0d34 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -347,8 +347,13 @@ jobs: with: github_token: ${{ secrets.SELENIUM_CI_TOKEN }} branch: trunk + # The release publishes -, which is what the notes read by + # default. GHCR_NAMESPACE has to be passed though: the mirror above is + # keyed on the repository owner, not on the script's SeleniumHQ default. - name: Create release notes (release_notes.md) - run: ./generate_release_notes.sh ${LATEST_TAG} origin/trunk ${GRID_VERSION} ${BUILD_DATE} + run: | + GHCR_NAMESPACE="ghcr.io/$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" \ + ./generate_release_notes.sh ${LATEST_TAG} origin/trunk ${GRID_VERSION} ${BUILD_DATE} - name: Upload release notes uses: actions/upload-artifact@main with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 13cf76272..653636256 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -51,6 +51,14 @@ jobs: make setup_dev_env - name: Output Docker info run: docker info + # The release notes read the arm64 browser versions by running the arm64 + # image. On the build path the multi-architecture build registered the + # emulators as a side effect; the promotion path builds nothing, so nothing + # would. + - name: Set up QEMU + uses: docker/setup-qemu-action@main + with: + platforms: arm64 - name: Set Selenium base version uses: ./.github/actions/get-latest-upstream with: @@ -156,16 +164,28 @@ jobs: command: GHCR_NAMESPACE="ghcr.io/$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" VERSION="${GRID_VERSION}" BUILD_DATE=${BUILD_DATE} make release_ghcr_nightly # - name: Update package versions # run: make generate_nightly_sbom + # The changelog is the range ...trunk. An empty + # LATEST_TAG silently turns that into HEAD...trunk - nothing - so stop here + # rather than publishing notes with no changelog in them. - name: Get current latest tag - run: echo "LATEST_TAG=$(git describe --tags --abbrev=0 --exclude=nightly --exclude=selenium-grid*)" >> $GITHUB_ENV + run: | + set -euo pipefail + LATEST_TAG=$(git describe --tags --abbrev=0 --exclude=nightly --exclude='selenium-grid*') + echo "LATEST_TAG=${LATEST_TAG}" >> $GITHUB_ENV - name: Display latest tag run: echo ${LATEST_TAG} - name: Sets env var for nightly tag run: | echo "NEXT_TAG=nightly" >> $GITHUB_ENV - echo "FILTER_IMAGE_TAG=nightly" >> $GITHUB_ENV + # :nightly is the only tag the nightly publishes - the promotion never + # writes a - tag, and on that path nothing is in the local + # image store either. IMAGE_TAG is what the notes read the versions and the + # published manifests from. - name: Create release notes (release_notes.md) - run: ./generate_release_notes.sh ${LATEST_TAG} origin/trunk ${GRID_VERSION} ${BUILD_DATE} + run: | + IMAGE_TAG=nightly VIDEO_IMAGE_TAG=nightly \ + GHCR_NAMESPACE="ghcr.io/$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" \ + ./generate_release_notes.sh ${LATEST_TAG} origin/trunk ${GRID_VERSION} ${BUILD_DATE} - name: Set up Python uses: actions/setup-python@main with: diff --git a/Makefile b/Makefile index 5ae11e015..ccc1b3834 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,12 @@ SKIP_BUILD_TARGETS := $(or $(SKIP_BUILD_TARGETS),base hub distributor router ses standalone_all_browsers standalone_docker standalone_kubernetes \ video ffmpeg keda_external_scaler update_go) +# The same list, for the scripts that have to iterate it. generate_release_notes.sh +# lists what a release published, and a second copy of these names in a shell +# script is a copy that drifts. +print_ci_images: + @echo $(CI_IMAGES) + # Push what was just built, so the rest of the run can reuse it. # video does not carry the grid tag: it is built as # $(NAME)/video:$(FFMPEG_TAG_VERSION)-$(BUILD_DATE), and the compose files read it diff --git a/generate_release_notes.sh b/generate_release_notes.sh index b558100fd..698048428 100755 --- a/generate_release_notes.sh +++ b/generate_release_notes.sh @@ -7,29 +7,107 @@ BUILD_DATE=$4 NAMESPACE=${NAME:-selenium} FFMPEG_TAG_VERSION=$(grep FFMPEG_TAG_VERSION Makefile | sed 's/.*,\([^)]*\))/\1/p' | head -n 1) AUTHORS=${AUTHORS:-"SeleniumHQ"} +GHCR_NAMESPACE=${GHCR_NAMESPACE:-ghcr.io/$(echo "${AUTHORS}" | tr '[:upper:]' '[:lower:]')} TAG_VERSION=${GRID_VERSION}-${BUILD_DATE} -echo "" >>release_notes.md +# The tag the images were actually published under. A release publishes +# -, which is the default; the nightly publishes +# :nightly and nothing else, so nightly.yml passes IMAGE_TAG=nightly. Reading a +# tag that was never published is what left every cell of the table below blank. +# +# Video is the one image that does not carry the grid tag on the release path - +# it is built as - - so it gets a tag of its own. +IMAGE_TAG=${IMAGE_TAG:-${TAG_VERSION}} +VIDEO_IMAGE_TAG=${VIDEO_IMAGE_TAG:-${FFMPEG_TAG_VERSION}-${BUILD_DATE}} + +if [ -z "${LATEST_TAG}" ] || [ -z "${HEAD_BRANCH}" ] || [ -z "${GRID_VERSION}" ] || [ -z "${BUILD_DATE}" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# `git log ...` with an empty left-hand side is `HEAD...`, which +# on a checkout of that very branch is empty - a silent, changelog-shaped hole in +# the notes. Resolve both ends up front and say which one is missing instead. +for rev in "${LATEST_TAG}" "${HEAD_BRANCH}"; do + if ! git rev-parse --verify --quiet "${rev}^{commit}" >/dev/null; then + echo "cannot resolve '${rev}': the changelog would be empty" >&2 + exit 1 + fi +done + echo "### Changelog" >release_notes.md git --no-pager log "${LATEST_TAG}...${HEAD_BRANCH}" --pretty=format:"* [\`%h\`](http://github.com/seleniumhq/docker-selenium/commit/%H) - %s :: %an" --reverse >>release_notes.md -GRID_REVISION=$(docker run --entrypoint="" --rm ${NAMESPACE}/base:${TAG_VERSION} java -jar /opt/selenium/selenium-server.jar info --version | awk '{print $5}') -CHROME_VERSION=$(docker run --rm ${NAMESPACE}/node-chrome:${TAG_VERSION} google-chrome --version | awk '{print $3}') -CFT_VERSION=$(docker run --rm ${NAMESPACE}/node-chrome-for-testing:${TAG_VERSION} google-chrome --version | awk '{print $5}') -EDGE_VERSION=$(docker run --rm ${NAMESPACE}/node-edge:${TAG_VERSION} microsoft-edge --version | awk '{print $3}') -CHROMEDRIVER_VERSION=$(docker run --rm ${NAMESPACE}/node-chrome:${TAG_VERSION} chromedriver --version | awk '{print $2}') -EDGEDRIVER_VERSION=$(docker run --rm ${NAMESPACE}/node-edge:${TAG_VERSION} msedgedriver --version | awk '{print $4}') -FIREFOX_VERSION=$(docker run --rm ${NAMESPACE}/node-firefox:${TAG_VERSION} firefox --version | awk '{print $3}') -GECKODRIVER_VERSION=$(docker run --rm ${NAMESPACE}/node-firefox:${TAG_VERSION} geckodriver --version | awk 'NR==1{print $2}') -FFMPEG_VERSION=$(docker run --entrypoint="" --rm ${NAMESPACE}/video:${FFMPEG_TAG_VERSION}-${BUILD_DATE} ffmpeg -version | awk '{print $3}' | head -n 1) -RCLONE_VERSION=$(docker run --entrypoint="" --rm ${NAMESPACE}/video:${FFMPEG_TAG_VERSION}-${BUILD_DATE} rclone version | head -n 1 | awk '{print $2}' | tr -d 'v') -JRE_VERSION=$(docker run --entrypoint="" --rm ${NAMESPACE}/base:${TAG_VERSION} java --version | grep -oP '\b\d+\.\d+\.\d+\b' | head -1) -OS_VERSION=$(docker run --entrypoint="" --rm ${NAMESPACE}/base:${TAG_VERSION} cat /etc/os-release | grep PRETTY_NAME | cut -d '"' -f 2) -FIREFOX_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${NAMESPACE}/node-firefox:${TAG_VERSION} firefox --version | awk '{print $3}') -CHROME_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${NAMESPACE}/node-chrome:${TAG_VERSION} google-chrome --version | awk '{print $3}') -CHROMEDRIVER_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${NAMESPACE}/node-chrome:${TAG_VERSION} chromedriver --version | awk '{print $2}') -CHROMIUM_VERSION=$(docker run --rm ${NAMESPACE}/node-chromium:${TAG_VERSION} chromium --version | awk '{print $2}') +# --- versions, read out of the published images ------------------------------- +# +# On the promotion path the deploy job builds nothing, so none of these images is +# in the local store and the `docker run` that reads one pulls it. The browser +# images together are far larger than the runner has free, so each is dropped +# again as soon as its values have been read. Anything that was already in the +# store came from a local build and is left where the rest of the job expects it. +PRESENT_BEFORE=" $(docker images --format '{{.Repository}}:{{.Tag}}' | tr '\n' ' ')" + +drop_image() { + case "${PRESENT_BEFORE}" in + *" $1 "*) return 0 ;; + esac + docker rmi -f "$1" >/dev/null 2>&1 || true +} + +BASE_IMAGE=${NAMESPACE}/base:${IMAGE_TAG} +GRID_REVISION=$(docker run --entrypoint="" --rm ${BASE_IMAGE} java -jar /opt/selenium/selenium-server.jar info --version | awk '{print $5}') +JRE_VERSION=$(docker run --entrypoint="" --rm ${BASE_IMAGE} java --version | grep -oP '\b\d+\.\d+\.\d+\b' | head -1) +OS_VERSION=$(docker run --entrypoint="" --rm ${BASE_IMAGE} cat /etc/os-release | grep PRETTY_NAME | cut -d '"' -f 2) +drop_image ${BASE_IMAGE} + +CHROME_IMAGE=${NAMESPACE}/node-chrome:${IMAGE_TAG} +CHROME_VERSION=$(docker run --rm ${CHROME_IMAGE} google-chrome --version | awk '{print $3}') +CHROMEDRIVER_VERSION=$(docker run --rm ${CHROME_IMAGE} chromedriver --version | awk '{print $2}') +CHROME_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${CHROME_IMAGE} google-chrome --version | awk '{print $3}') +CHROMEDRIVER_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${CHROME_IMAGE} chromedriver --version | awk '{print $2}') +drop_image ${CHROME_IMAGE} + +CFT_IMAGE=${NAMESPACE}/node-chrome-for-testing:${IMAGE_TAG} +CFT_VERSION=$(docker run --rm ${CFT_IMAGE} google-chrome --version | awk '{print $5}') +drop_image ${CFT_IMAGE} + +CHROMIUM_IMAGE=${NAMESPACE}/node-chromium:${IMAGE_TAG} +CHROMIUM_VERSION=$(docker run --rm ${CHROMIUM_IMAGE} chromium --version | awk '{print $2}') +drop_image ${CHROMIUM_IMAGE} + +EDGE_IMAGE=${NAMESPACE}/node-edge:${IMAGE_TAG} +EDGE_VERSION=$(docker run --rm ${EDGE_IMAGE} microsoft-edge --version | awk '{print $3}') +EDGEDRIVER_VERSION=$(docker run --rm ${EDGE_IMAGE} msedgedriver --version | awk '{print $4}') +drop_image ${EDGE_IMAGE} + +FIREFOX_IMAGE=${NAMESPACE}/node-firefox:${IMAGE_TAG} +FIREFOX_VERSION=$(docker run --rm ${FIREFOX_IMAGE} firefox --version | awk '{print $3}') +GECKODRIVER_VERSION=$(docker run --rm ${FIREFOX_IMAGE} geckodriver --version | awk 'NR==1{print $2}') +FIREFOX_ARM64_VERSION=$(docker run --rm --platform linux/arm64 ${FIREFOX_IMAGE} firefox --version | awk '{print $3}') +drop_image ${FIREFOX_IMAGE} + +VIDEO_IMAGE=${NAMESPACE}/video:${VIDEO_IMAGE_TAG} +FFMPEG_VERSION=$(docker run --entrypoint="" --rm ${VIDEO_IMAGE} ffmpeg -version | awk '{print $3}' | head -n 1) +RCLONE_VERSION=$(docker run --entrypoint="" --rm ${VIDEO_IMAGE} rclone version | head -n 1 | awk '{print $2}' | tr -d 'v') +drop_image ${VIDEO_IMAGE} + +# A version that came back empty means the image it was read from was not +# published under ${IMAGE_TAG}, or could not be run. Publishing the table anyway +# is how a release ends up looking empty, so name what failed and stop - the +# release step has not run yet, so the previous notes stay up. +missing="" +for version in GRID_REVISION JRE_VERSION OS_VERSION CHROME_VERSION CHROMEDRIVER_VERSION \ + CHROME_ARM64_VERSION CHROMEDRIVER_ARM64_VERSION CFT_VERSION CHROMIUM_VERSION \ + EDGE_VERSION EDGEDRIVER_VERSION FIREFOX_VERSION GECKODRIVER_VERSION \ + FIREFOX_ARM64_VERSION FFMPEG_VERSION RCLONE_VERSION; do + [ -n "${!version}" ] || missing="${missing} ${version}" +done +if [ -n "${missing}" ]; then + echo "could not read from the images published as '${IMAGE_TAG}':${missing}" >&2 + exit 1 +fi + if [[ "${GRID_VERSION}" == *"SNAPSHOT"* ]]; then GRID_RELEASE_TAG="nightly" else @@ -55,25 +133,77 @@ echo "| rclone | ${RCLONE_VERSION} | ${RCLONE_VERSION} |" >>release_notes.md echo "| Java Runtime | ${JRE_VERSION} | ${JRE_VERSION} |" >>release_notes.md echo "| OS | ${OS_VERSION} | ${OS_VERSION} |" >>release_notes.md +# --- what was published ------------------------------------------------------- +# +# `docker images` listed the local store, which the promotion path never fills, +# so both sections below came out as a bare table header. Ask the registry +# instead: the digest it answers with is the one users pull, and it is the same +# check for a build and for a promotion. +# +# The image list comes from the Makefile so the two cannot drift apart. +PUBLISHED_IMAGES=$(make --no-print-directory print_ci_images) +if [ -z "${PUBLISHED_IMAGES}" ]; then + echo "could not read the image list from the Makefile" >&2 + exit 1 +fi + +list_published_images() { + local registry=$1 + local image ref raw digest platforms row + local -a rows=() + local ref_width=5 platform_width=9 + + for image in ${PUBLISHED_IMAGES}; do + if [ "${image}" = "video" ]; then + ref="${registry}/${image}:${VIDEO_IMAGE_TAG}" + else + ref="${registry}/${image}:${IMAGE_TAG}" + fi + if ! raw=$(docker buildx imagetools inspect --raw "${ref}" 2>/dev/null); then + echo "not published: ${ref}" >&2 + continue + fi + # The architectures the manifest list actually covers, so an image that + # quietly lost one is visible in the notes. `unknown` is the attestation + # entry buildx attaches alongside the real ones. + platforms=$(printf '%s' "${raw}" | jq -r '[.manifests[]? | select(.platform.os != "unknown") | "\(.platform.os)/\(.platform.architecture)"] | unique | join(",")') + digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "${ref}" 2>/dev/null) + [ ${#ref} -gt ${ref_width} ] && ref_width=${#ref} + [ ${#platforms} -gt ${platform_width} ] && platform_width=${#platforms} + rows+=("${ref}"$'\t'"${platforms:--}"$'\t'"${digest:--}") + done + + if [ ${#rows[@]} -eq 0 ]; then + echo "none of the images are published in ${registry}" >&2 + return 1 + fi + + printf "%-${ref_width}s %-${platform_width}s %s\n" "IMAGE" "PLATFORMS" "DIGEST" + for row in "${rows[@]}"; do + IFS=$'\t' read -r ref platforms digest <<<"${row}" + printf "%-${ref_width}s %-${platform_width}s %s\n" "${ref}" "${platforms}" "${digest}" + done +} + echo "" >>release_notes.md echo "### Published Docker images on [Docker Hub](https://hub.docker.com/u/${NAMESPACE})" >>release_notes.md echo "
" >>release_notes.md echo "Click to see published Docker images" >>release_notes.md echo "" >>release_notes.md echo '```' >>release_notes.md -docker images --filter=reference=${NAMESPACE}'/*:'${FILTER_IMAGE_TAG:-"*"} --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}" >>release_notes.md +list_published_images "${NAMESPACE}" >>release_notes.md || exit 1 echo '```' >>release_notes.md echo "" >>release_notes.md echo "
" >>release_notes.md echo "" >>release_notes.md -echo "### Published Docker images on [GitHub Container Registry](https://github.com/orgs/SeleniumHQ/packages?repo_name=docker-selenium)" >>release_notes.md +echo "### Published Docker images on [GitHub Container Registry](https://github.com/orgs/${AUTHORS}/packages?repo_name=docker-selenium)" >>release_notes.md echo "
" >>release_notes.md echo "Click to see published Docker images" >>release_notes.md echo "" >>release_notes.md echo '```' >>release_notes.md -docker images --filter=reference=ghcr.io/seleniumhq'/*:'${FILTER_IMAGE_TAG:-"*"} --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}" >>release_notes.md +list_published_images "${GHCR_NAMESPACE}" >>release_notes.md || exit 1 echo '```' >>release_notes.md echo "" >>release_notes.md echo "
" >>release_notes.md From f8ade1406853efe56aa488731383020a1dbb22a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 21:24:49 +0000 Subject: [PATCH 2/3] [ci] Pin the build to the core the reuse decision was made about Keying the image hash on the jar's digest (#3232) decides correctly whether to build, but nothing then makes the build use that jar. Base/Dockerfile downloads from .../releases/download/${RELEASE}/selenium-server-${VERSION}.jar and for the nightly that URL is a moving target: one asset name for a whole snapshot cycle, contents replaced every night. decide resolved the digest and did not pass it on - the build job inherited base-version and base-release only - so between the decision and the download the core could change underneath it. What that costs is not a slow cache. The tag names a core the image does not contain, and the next run keys on the new digest and rebuilds, leaving the mislabelled set published. The build is also a two-runner matrix downloading independently, minutes apart, so a re-upload between them puts two different cores into one manifest and merge_ci_images assembles it without complaint. A second revision in the tag would not have helped: the hash already carries both identities, and the gap is between the decision and the build, not in what the tag encodes. So pin it instead. decide now outputs base-asset-digest, the build job passes it to make, and Base/Dockerfile verifies the downloaded jar against it and fails when it does not match. The nightly and release fallback build paths need no change - they run get-latest-upstream themselves, so the digest is already in the environment make reads. Verification is skipped, out loud, for anything that is not a sha256: a local build, or an upstream asset old enough to predate the digest field, where identity falls back to the asset id and its upload time. Which of the two get-latest-upstream used is now in the log, because they fail differently - only a real sha256 can be checked against the jar, and a digest field that came back constant would silently return everything keyed on the core to treating every nightly as the same one. The digest is also recorded as an image label, so a published image can be traced back to its core without being pulled apart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HNaarX452mih58sA7QPTz2 --- .../actions/get-latest-upstream/action.yml | 14 +++++++++-- .github/workflows/build-images.yml | 24 +++++++++++++++---- Base/Dockerfile | 21 ++++++++++++++++ Makefile | 6 +++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/.github/actions/get-latest-upstream/action.yml b/.github/actions/get-latest-upstream/action.yml index 6588e5e47..2e20f52a9 100644 --- a/.github/actions/get-latest-upstream/action.yml +++ b/.github/actions/get-latest-upstream/action.yml @@ -51,7 +51,11 @@ runs: fi fi ASSET=$(curl -s -H "$AUTH_HEADER" https://api.github.com/repos/${AUTHORS}/selenium/releases/tags/${RELEASE} | jq -r '[.assets[] | select(.name | endswith(".jar"))] | last') - jar_file=$(echo "${ASSET}" | jq -r '.name') + jar_file=$(echo "${ASSET}" | jq -r '.name // empty') + if [ -z "${jar_file}" ]; then + echo "Release ${RELEASE} has no server jar asset" + exit 1 + fi echo "Server package: ${jar_file}" VERSION=$(echo $jar_file | sed 's/selenium-server-//;s/\.jar//') # What that jar actually IS, not just what it is called. The nightly jar @@ -60,13 +64,19 @@ runs: # version alone cannot tell two nightly cores apart. Anything keyed on the # core needs this, or it will treat last week's nightly as today's. ASSET_DIGEST=$(echo "${ASSET}" | jq -r '.digest // empty') + DIGEST_SOURCE="asset digest" if [ -z "${ASSET_DIGEST}" ] || [ "${ASSET_DIGEST}" = "null" ]; then # Older assets predate the digest field; identity then comes from the # asset id and the time it was last uploaded, which move together. ASSET_DIGEST=$(echo "${ASSET}" | jq -r '"\(.id)-\(.updated_at)"') + DIGEST_SOURCE="asset id and upload time (no digest field)" fi echo "BASE_RELEASE=${RELEASE} | BASE_VERSION=${VERSION} | VERSION=${VERSION}" - echo "Server package digest: ${ASSET_DIGEST}" + # Which of the two it is matters: only a real sha256 can be verified + # against the jar the build downloads, and if the digest field ever came + # back constant, everything keyed on the core would quietly go back to + # treating every nightly as the same one with nothing to notice it. + echo "Server package digest: ${ASSET_DIGEST} (from ${DIGEST_SOURCE})" echo "BASE_RELEASE=${RELEASE}" >> $GITHUB_ENV echo "BASE_VERSION=${VERSION}" >> $GITHUB_ENV echo "BASE_ASSET_DIGEST=${ASSET_DIGEST}" >> $GITHUB_ENV diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index f0a393e2f..2b050c890 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -53,6 +53,9 @@ on: base-release: description: 'Upstream release tag that core came from' value: ${{ jobs.decide.outputs.base-release }} + base-asset-digest: + description: 'sha256 of the server jar that core resolved to' + value: ${{ jobs.decide.outputs.base-asset-digest }} src-hash: description: 'Content hash the tag is keyed on' value: ${{ jobs.decide.outputs.src-hash }} @@ -73,6 +76,7 @@ jobs: src-hash: ${{ steps.decide.outputs.src-hash }} base-version: ${{ steps.decide.outputs.base-version }} base-release: ${{ steps.decide.outputs.base-release }} + base-asset-digest: ${{ steps.decide.outputs.base-asset-digest }} registry: ${{ steps.decide.outputs.registry }} steps: - name: Checkout code @@ -162,6 +166,9 @@ jobs: echo "src-hash=${SRC_HASH}" >> "$GITHUB_OUTPUT" echo "base-version=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "base-release=${BASE_RELEASE}" >> "$GITHUB_OUTPUT" + # The build has to end up with the jar this hash was computed from, not + # merely with the same release tag - the tag's asset is replaced nightly. + echo "base-asset-digest=${BASE_ASSET_DIGEST}" >> "$GITHUB_OUTPUT" { echo "### Images" echo @@ -246,15 +253,22 @@ jobs: command: make setup_dev_env # Use exactly the core decide hashed on, so the tag and the image agree. - # All three matter: Base/Dockerfile builds its download URL from the - # release tag AND the version + # All four matter. Base/Dockerfile builds its download URL from the release + # tag AND the version # .../releases/download/${RELEASE}/selenium-server-${VERSION}.jar # so carrying only BASE_VERSION leaves BASE_RELEASE on the Makefile default - # and asks for a jar that does not exist. + # and asks for a jar that does not exist. And that URL names the asset, not + # its contents - the nightly one is replaced every night - so the digest is + # the only part of this that pins the build to the jar the decision was + # actually made about. Base/Dockerfile fails the build when what it + # downloads does not match, which also stops the two architecture jobs - + # they download independently, minutes apart - from putting different cores + # into one manifest. - name: Use the core decide resolved run: | echo "BASE_VERSION=${{ needs.decide.outputs.base-version }}" >> $GITHUB_ENV echo "BASE_RELEASE=${{ needs.decide.outputs.base-release }}" >> $GITHUB_ENV + echo "BASE_ASSET_DIGEST=${{ needs.decide.outputs.base-asset-digest }}" >> $GITHUB_ENV echo "VERSION=${{ needs.decide.outputs.base-version }}" >> $GITHUB_ENV # Native, single platform. Edge and Chrome for Testing are amd64-only and @@ -275,7 +289,9 @@ jobs: timeout_minutes: 180 max_attempts: 3 retry_wait_seconds: 60 - command: PLATFORMS="${PLATFORMS}" VERSION="${BASE_VERSION}" BUILD_DATE="${BUILD_DATE}" make build + command: | + PLATFORMS="${PLATFORMS}" VERSION="${BASE_VERSION}" BUILD_DATE="${BUILD_DATE}" \ + BASE_ASSET_DIGEST="${BASE_ASSET_DIGEST}" make build # Also tag pr-, so cleanup-pr-images.yml has something to delete when the # pull request closes. The src-* tag is shared and must outlive any one diff --git a/Base/Dockerfile b/Base/Dockerfile index 8c601d4bb..098d1c500 100644 --- a/Base/Dockerfile +++ b/Base/Dockerfile @@ -6,6 +6,13 @@ LABEL org.opencontainers.image.source="https://github.com/${AUTHORS}/docker-sele # Arguments to define the version of dependencies to download ARG VERSION ARG RELEASE=selenium-${VERSION} +# sha256 of the server jar this build is required to contain, as CI resolved it +# before deciding to build. The download URL below is keyed on the release tag +# and the version alone, and the nightly release keeps one asset name for a whole +# snapshot cycle while its contents are replaced every night - so the URL is a +# moving target and the two are not enough to say which core an image holds. +# Empty on a local build, which takes whatever the URL currently serves. +ARG SERVER_JAR_DIGEST="" # Default value should be aligned with upstream Selenium (https://github.com/SeleniumHQ/selenium/blob/trunk/MODULE.bazel) ARG OPENTELEMETRY_VERSION=1.65.0 ARG GRPC_VERSION=1.83.1 @@ -26,6 +33,10 @@ ARG JRE_VERSION=21 ARG TARGETARCH ARG TARGETVARIANT +# Recorded so a published image can be traced back to the core it was built +# against without pulling it apart, and inherited by every image built FROM base. +LABEL org.seleniumhq.selenium.core.digest="${SERVER_JAR_DIGEST}" + USER root ENV DEBIAN_FRONTEND=noninteractive \ @@ -130,6 +141,16 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ "${TARGETVARIANT}" = "v7" ]; then \ && chmod -R 775 /opt/selenium /var/run/supervisor /var/log/supervisor /etc/passwd ${HOME} ${VIDEO_FOLDER} \ && wget --no-verbose https://github.com/${AUTHORS}/selenium/releases/download/${RELEASE}/selenium-server-${VERSION}.jar \ -O /opt/selenium/selenium-server.jar \ + # CI decides whether to build from the digest it resolved before the build + # started. If the jar moved in between, the image would be published under a + # tag naming a core it does not contain, and the two architecture builds - + # which download independently - could even end up with different cores in one + # manifest. Fail here instead. Anything that is not a sha256 (a local build, or + # an upstream asset that predates the digest field) skips the check and says so. + && case "${SERVER_JAR_DIGEST}" in \ + sha256:*) echo "${SERVER_JAR_DIGEST#sha256:} /opt/selenium/selenium-server.jar" | sha256sum -c - ;; \ + *) echo "Server jar digest not verified (SERVER_JAR_DIGEST=${SERVER_JAR_DIGEST:-none})" ;; \ + esac \ && chgrp -R 0 /opt/selenium ${HOME} ${VIDEO_FOLDER} /opt/selenium/assets /var/run/supervisor /var/log/supervisor \ && chmod -R g=u /opt/selenium ${HOME} ${VIDEO_FOLDER} /opt/selenium/assets /var/run/supervisor /var/log/supervisor \ && setfacl -Rm u:${SEL_USER}:rwx /opt /opt/selenium ${HOME} ${VIDEO_FOLDER} /opt/selenium/assets /var/run/supervisor /var/log/supervisor \ diff --git a/Makefile b/Makefile index ccc1b3834..4e4b796b1 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,11 @@ BASE_VERSION := $(or $(BASE_VERSION),$(BASE_VERSION),4.48.0) BINDING_VERSION := $(or $(BINDING_VERSION),$(BINDING_VERSION),4.48.0) BASE_RELEASE_NIGHTLY := $(or $(BASE_RELEASE_NIGHTLY),$(BASE_RELEASE_NIGHTLY),nightly) BASE_VERSION_NIGHTLY := $(or $(BASE_VERSION_NIGHTLY),$(BASE_VERSION_NIGHTLY),4.49.0-SNAPSHOT) +# sha256 of the server jar the build must end up with, as get-latest-upstream +# resolved it. Empty locally, where the build simply takes whatever the release +# URL serves; CI passes it so a core that moved mid-build fails the build instead +# of shipping under a tag that names the core it was supposed to have. +BASE_ASSET_DIGEST := $(or $(BASE_ASSET_DIGEST),$(BASE_ASSET_DIGEST),) VERSION := $(or $(VERSION),$(VERSION),4.48.0) MVN_SELENIUM_VERSION := $(or $(MVN_SELENIUM_VERSION),$(MVN_SELENIUM_VERSION),latest.release) TAG_VERSION := $(VERSION)-$(BUILD_DATE) @@ -494,6 +499,7 @@ gen_certs: base: update_go prepare_resources gen_certs cd ./Base && SEL_PASSWD=$(SEL_PASSWD) docker buildx build --platform $(PLATFORMS) $(BUILD_ARGS) --build-arg VERSION=$(BASE_VERSION) --build-arg RELEASE=$(BASE_RELEASE) --build-arg AUTHORS=$(AUTHORS) \ + --build-arg SERVER_JAR_DIGEST=$(BASE_ASSET_DIGEST) \ --secret id=SEL_PASSWD --sbom=true --attest type=provenance,mode=max -t $(NAME)/base:$(TAG_VERSION) . base_nightly: From 1c2cc285f6d953baea931c8a3439153f07dd2cae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 04:39:26 +0000 Subject: [PATCH 3/3] [ci] Stop the scaling test dying on a fork's missing Docker Hub secrets A pull request from a fork gets no secrets, so DOCKER_USERNAME and DOCKER_PASSWORD arrive empty and docker login -u="" -p="" exits 1. The step has no guard, so the job dies there - before the cluster is built and before a single test runs. The three sibling test workflows already handle this: helm-chart-test.yml and k8s-dynamic-grid-test.yml end the same line with `|| true`, and docker-test.yml does not log in to Docker Hub at all. k8s-scaling-test.yml is the only one that was missed, which is exactly the pattern seen on #3236 - Docker, Dynamic Grid and chart jobs green, scaling jobs red - and why it reads as an image problem: what fails is the step that would have authenticated the pulls. Nothing here needs the login. In build-in-job mode, which is what a fork run uses, every grid image is built in the job itself; the rest are public. The login only buys a higher anonymous pull limit, so losing it is a soft degradation, never a failure - which is the judgement the other two workflows already encode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HNaarX452mih58sA7QPTz2 --- .github/workflows/k8s-scaling-test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/k8s-scaling-test.yml b/.github/workflows/k8s-scaling-test.yml index f5f112a80..2c01835bb 100644 --- a/.github/workflows/k8s-scaling-test.yml +++ b/.github/workflows/k8s-scaling-test.yml @@ -201,8 +201,14 @@ jobs: echo "AUTHORS=${AUTHORS}" >> $GITHUB_ENV env: AUTHORS: ${{ vars.AUTHORS || github.repository_owner }} + # A pull request from a fork gets no secrets, so these are empty and the + # login exits 1 - failing the job before a single test runs. The other test + # workflows that log in here already tolerate that; this one was missed, + # which is why a fork pull request saw only the scaling jobs go red. The + # login is an anonymous-pull-limit courtesy, not a requirement: every image + # the cluster needs is either built in this job or public. - name: Login Docker Hub - run: docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" + run: docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" || true env: DOCKER_USERNAME: ${{secrets.DOCKER_USERNAME}} DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}}