Skip to content

XDOCKER-18: Automate updating the stable version - #92

Merged
vmassol merged 33 commits into
masterfrom
XDOCKER-18-automate-docker-images-release
Aug 26, 2026
Merged

XDOCKER-18: Automate updating the stable version#92
vmassol merged 33 commits into
masterfrom
XDOCKER-18-automate-docker-images-release

Conversation

@vmassol

@vmassol vmassol commented Jul 19, 2026

Copy link
Copy Markdown
Member

What

Automates the manual Update Docker Images
release step as Gradle tasks, mirroring the existing updateLibreOffice precedent. Target UX: ./gradlew release does
the pre-push work, then (after a manual review, commit and push) ./gradlew submitOfficialImage opens the upstream
official-images Pull Request. No JIRA issue and no manual sha256 computation are needed anymore.

Jira: XDOCKER-18

This PR regenerates no image. versions.json starts from exactly the values master generates from today, so the
only generated file that changes is .github/workflows/docker-build.yml, which gains versions.json to its path
filters. The scripts under gradle/ are deliberately not in those filters: none of them takes part in the generation,
so a change to one cannot change an image, and building the whole matrix for it would cost 12 jobs for nothing - that
they still configure and generate is what gradlew-check verifies, now on Pull Requests too.

Where the configuration lives now

The version data moves out of build.gradle into a versions.json data file, and the build logic moves into scripts
applied from gradle/, so that the configuration stays separate from the code updating it and the update tasks can
persist it with JsonOutput.

File Holds
versions.json Per cycle: role, the XWiki version + sha256, the three JDBC driver versions + sha256, tomcatImageTag, an optional libraryComment. Shared: the LibreOffice version + one sha256 per architecture.
build.gradle The template expansion (generate, generateWorkflows) and the model it reads.
gradle/helpers.gradle HTTP, hashing, version comparison and process helpers.
gradle/update-versions.gradle updateXWiki, updateJDBC, updateLibreOffice.
gradle/run-instances.gradle smokeTest.
gradle/official-image.gradle submitOfficialImage, the generation of the library/xwiki file, and the single map defining what each cycle role means (section comment + PR label) against which the model is checked.
gradle/release.gradle The release umbrella and the ordering the chained tasks need.

The per-cycle JDBC versions replace the shared mysql / mariadb / postgres maps, since each cycle is now updated
from its own POM. The values are unchanged, cycle 16 included.

The tasks

All are runnable on their own and listed by ./gradlew tasks; release chains the ones a release needs.

Task What it does
updateXWiki For each cycle, resolves the latest final XWiki version on that cycle's line from XWiki's Maven metadata; for any that changed, downloads the WAR and computes its sha256. Never downgrades a cycle, and never moves one onto the version line another cycle owns.
updateJDBC For each cycle, reads the three JDBC driver versions from that cycle's own xwiki-platform POM (mysql.version / mariadb.version / pgsql.version); for any that moved forward, downloads the JAR from Maven Central and hashes it.
updateLibreOffice Resolves the supported (LTS) LibreOffice version and its per-architecture sha256, writing to versions.json and only when the version actually moved. Fails if the sha256 published for the pinned version no longer matches the pinned one.
generate / generateWorkflows Unchanged, reading the model from versions.json.
smokeTest For each cycle whose versions the update tasks moved, builds the postgres-tomcat image plus the image of any variant whose JDBC driver moved, boots each one with docker compose as its own compose project on host port 18080, waits until /rest reports the expected XWiki version, then down -v. Dumps the tail of the container logs when an instance never comes up. -Pcycles=18,17, -Pvariants=mysql-tomcat pick what to boot by hand, which is also how a template/ edit gets boot-checked; standalone it boots every cycle.
release The pre-push umbrella: updateXWikiupdateJDBCupdateLibreOfficegenerategenerateWorkflowssmokeTest.
submitOfficialImage Generates the docker-library library/xwiki file from the model and opens the official-images Pull Request from your GitHub fork (contents API, no clone). -PdryRun generates the file and shows the diff against the published one without opening a PR.

Decisions worth a review

  • A POM driver version is a floor, not a target (updateJDBC). A pin ahead of the POM is a deliberate bump made
    after that cycle was released, so applying the POM value literally would silently revert it and rebuild the images
    for nothing. This is not hypothetical: 18.6.0 declares mysql.version 9.7.0 while 18.4.4 and 17.10.12 declare
    26.7.0, the XWIKI-24657 bump having landed on the maintenance branches first. Upgrades still apply, and the ones
    crossing a driver major say so in the log since the release manager reviews the diff. updateXWiki applies the same
    rule to the XWiki version itself: a pin ahead of everything the Maven metadata lists means the metadata lost it (a
    release pulled for being broken, a repository half synced), not that the cycle should be republished backwards.
  • A pinned sha256 that stops matching is a supply chain event, not an update (updateLibreOffice). The Document
    Foundation serving different bytes under a version this repository has already published images for is exactly what
    pinning a hash exists to detect, so the task fails and names both hashes per architecture. Adopting the new hash
    would republish every image on bytes nobody looked at, and report "Updated the sha256" while doing it.
  • smokeTest builds the image locally and boots it in isolation. It is the release gate, so it is deliberately
    picky about what it boots and about what counts as green:
    • It builds rather than pulls. The generated docker-compose.yml has no build section, only an image: tag,
      and at release time that tag does not exist on DockerHub yet - the official image is only built once the
      submitOfficialImage Pull Request has been merged. Building here also means what boots is the Dockerfile that was
      just regenerated, not a previously published image.
    • With the classic builder (DOCKER_BUILDKIT=0). That is what the Docker Official Images infrastructure and the
      Docker Build workflow use, and the two builders do not accept the same Dockerfiles, so it is the one whose verdict
      counts. A check building differently from both would miss what they reject.
    • Green means XWiki initialized, not that Tomcat is up. It waits on XWiki's REST API reporting the expected
      product version rather than on an HTTP 200.
    • The version is matched between boundaries, not as a substring. One version string can be the prefix of
      another: an instance answering 17.10.12 contains 17.10.1, so a substring check would let the gate go green on
      the wrong image.
    • Each instance is its own compose project (smoketest-<cycle>-<variant>) on port 18080, not the variant
      directory's default project on 8080, so it can neither collide with an instance started by hand nor down -v
      that instance's volumes away.
    • The rewrites that achieve that isolation fail the build when they find nothing to replace. A silent no-op
      there would cause exactly the collision above, which is the one failure this indirection exists to prevent.
    • It tears the project down before starting it as well as after. The finally covers a normal run and a
      cancelled one, but not a killed daemon or a reboot, and a smoke test adopting a stale container would report on
      that one instead of on the image just built.
    • A failed boot reports the tail of the container logs before the teardown takes them away. A bare timeout is
      not something a failed release can be diagnosed from.
  • submitOfficialImage refuses to submit something unreachable: uncommitted tracked changes, HEAD not on
    master (that is the branch docker-library fetches from, being on some remote branch is not enough), or a Pull
    Request still open from a previous run for the same version, whose head branch it would otherwise delete. Untracked
    files are deliberately not part of that - a release manager's scratch note has no bearing on whether the commits we
    emit are reachable - and the offending paths are named in the message rather than left to a git status. It
    regenerates the images before that check, so a versions.json pushed without its regenerated directories shows up as
    that dirty tree: nothing else reports it, docker-build.yml going green on the stale - and therefore fine -
    Dockerfiles while the library file would publish tags they do not produce.
  • What builds the images is checked upstream, not here. docker-library builds every image on its own Pull Request
    before merging it, and that gate cannot be waived, so this repository does not pre-check the Docker Build workflow
    result before submitting. See the cleanup round below for why the check that used to be here was removed.
  • A dry run stops before the guards. They are all about not writing something wrong, and a dry run writes nothing;
    stopping there is also what makes a preview usable at the moment it is reached for - right after ./gradlew release,
    with the regenerated tree not yet committed, which is exactly the state the guards refuse to submit from. A real run
    throws on the first guard that objects.
  • A gh api call a guard reads is not allowed to fail. An empty answer there is indistinguishable from a green
    verdict - a rate-limited or unauthenticated gh would read as "no open Pull Request" and take a branch someone is
    still reviewing down with it - so a check that cannot be made is treated as a check that failed.
  • GitRepo now names xwiki/xwiki-docker, not xwiki-contrib/docker-xwiki. This is the one line by which the
    generated file differs from the published one. The published URL only resolves through the redirect GitHub kept when
    this repository moved to the xwiki organisation, and that redirect goes away the day anything else is created under
    the old name - taking the official image builds with it. Revert one line if you would rather do this as its own
    upstream Pull Request.
  • The build refuses to generate something it could not publish. An unknown role, more or less than one stable
    cycle, or a variant present in build.gradle but not in the library/xwiki generation all fail at configuration
    time. The variant one matters most: such a variant would be generated, committed and built by CI, yet never reach the
    file that publishes it, so its images would simply not exist on DockerHub.
  • Two cycles may not sit on the same version line. The tags a cycle contributes are derived from its version, so
    two cycles on one line both claim it - and docker-library rejects a library file declaring a tag twice. updateXWiki
    therefore refuses the bump that would walk into it, keeping the pin and naming the cycle that owns the line. It is
    reachable: the stable cycle resolves to the latest final of its whole major, which is still on the line an
    intermediate LTS owns until the next minor is released.
  • External commands go through Groovy's Process API, since Project.exec is gone from Gradle 9. capture is
    for output that gets parsed, stream for the docker commands, whose output is worth watching as it comes.

Testing

  • ./gradlew — regenerating produces no git diff, so gradlew-check passes.
  • ./gradlew submitOfficialImage -PdryRun — the generated library/xwiki reproduces the currently published file
    byte for byte except the GitCommit lines, which resolve to the real per-cycle last-touching commits, and the
    GitRepo line corrected above. Checked by diffing both with the GitCommit lines normalised away: the GitRepo
    line is the only other difference.
  • updateXWiki, updateJDBC, updateLibreOffice — run live against maven.xwiki.org, Maven Central and the Document
    Foundation; all four cycles resolve to what is pinned. The floor rule is load-bearing rather than defensive: run
    today it holds back five drivers the POMs declare lower than the pins — cycle 18 mysql (9.7.0 < 26.7.0) and
    mariadb (3.5.9 < 3.5.10), and all three of cycle 16 (mysql 9.6.0 < 9.7.0, mariadb 3.5.7 < 3.5.10, postgres
    42.7.9 < 42.7.13). Without it, updateJDBC would downgrade those five and rebuild cycles 18 and 16 backwards.
  • The per-cycle JDBC values in versions.json were diffed against what master's shared mysql/mariadb/postgres
    maps resolve to, for all four cycles x three drivers: identical, versions and sha256 alike.
  • versions.json round-trips byte-identically through JsonSlurper + JsonOutput.prettyPrint, so an update task
    rewrites only the values it changed rather than reformatting the file.
  • ./gradlew release --dry-run — task ordering.
  • smokeTest end to end: built cycle 18 postgres-tomcat with the classic builder, booted it, /rest reported
    18.6.0 through the boundary match, teardown removed only the smoketest-18-* resources. The failure path was forced
    with a 20s timeout (both containers' logs reported, then torn down), and the stale-instance path by planting a
    running smoketest-18-postgres-tomcat project and watching the next run reclaim it.
  • The guards, each triggered on purpose: an unknown role, two stable cycles, a fourth variant missing from
    library/xwiki, an unquoted port in a generated docker-compose.yml, an XWiki version pinned ahead of the metadata,
    and a MySQL-only driver bump on cycle 17 (which then plans 17 (postgres-tomcat, mysql-tomcat)).
  • -Pcycles and -Pvariants are validated against the cycles and variants that exist. Both ways of getting them wrong
    were silent: -Pcycles= left an empty list, which every caller reads as "no selection" and therefore as all of
    them - the opposite of narrowing anything down - and -Pvariants=mysql surfaced only later as a missing directory,
    naming a path rather than the property that named it. Checked with an empty value, an unknown cycle (19) and a
    variant typo (mysql); each names the property and lists the available values.
  • The compose rewrites: the three targets are present in all 12 generated docker-compose.yml, and docker compose config on a rewritten file reports the project-scoped container names, DB_HOST wired to the renamed database
    container, port 18080, project-scoped network and volumes, and the image tag resolved from .env.
  • Every commit of the branch configures and regenerates with no git diff, so gradlew-check passes on each.
  • Not executed: the fork / branch / PUT / Pull Request write path of submitOfficialImage against
    docker-library/official-images, since running it opens a real upstream Pull Request. Its read-only plumbing
    (gh auth status, the upstream fetch, the diff, the guards) was exercised live.

Review round (@claude review of the branch)

Six changes, all in the scripts plus README.md; no generated file moves and library/xwiki is unchanged.

  • submitOfficialImage regenerates before it checks the tree. The library file is generated from versions.json
    while docker-library builds the Dockerfiles committed at the emitted GitCommit(s), and nothing tied the two
    together: a versions.json committed without its regenerated directories would publish tags the pushed Dockerfiles
    do not produce. gradlew-check would go red on master, but no guard here read it. Depending on
    generate/generateWorkflows turns the drift into a dirty tree, which the existing guard already reports, naming
    the files.
  • updateXWiki refuses a bump onto another cycle's version line, keeping the pin and saying which cycle owns the
    line, rather than writing a versions.json that would make both cycles claim the same tags.
  • smokeTest also boots what generate rewrote. It picked its cycles from what the update tasks changed, so an
    edit to template/ - the other thing that changes every image - left the set empty. (Superseded by the cleanup
    round below, which drops the mechanism in favour of -Pcycles.)
  • The boolean flags are validated like the others. -PdryRun was a bare hasProperty test, so -PdryRun=false
    read as true - asking not to rehearse is what would have started the real submission. It now accepts no value,
    true or false, and rejects anything else by name, as -Pcycles and -Pvariants already did.
  • Two comments. One justified itself by an earlier revision of this branch ("the previous split left...") - which
    nobody reading the file later can see - and another carried a transient issue key. Both now state the reason as it
    stands.

Verified after the changes: ./gradlew and ./gradlew tasks still produce no git diff; versions.json still
round-trips byte-identically; submitOfficialImage -PdryRun still reproduces the published library/xwiki with the
GitRepo and GitCommit lines as its only differences; updateXWiki/updateJDBC/updateLibreOffice still run green
and idempotent against the live sources, with no false positive from the new line check on today's versions.json.
Each fix was also triggered on purpose: a versions.json committed without regenerating is reported as a dirty tree
naming the stale files; a cycle resolving onto another's line is refused by name; -PdryRun=false no longer dry-runs
and -PdryRun=maybe is rejected by name.

Bug found by that review round, fixed here

./gradlew then booting cycle 17 by hand: the mysql and mariadb database containers exited (1):

mysql:   ERROR: Can't initialize batch_readline - may be the input source is a directory or a block device.
mariadb: ERROR: Can't read from a directory 'stdin'

template/docker-compose.yml mounted ./init.sql, while generate copies that file to <variant>/mysql/init.sql
(the include is "${db}/*"). Docker creates a directory for a bind mount source that does not exist, and the
database entrypoints then fail to read it as their SQL script. Pre-existing on master, not introduced here:
git log -S puts it at d1e6ca1 (XDOCKER-296), which changed the mount from ./mysql/init.sql to ./init.sql without
moving the file. cd 17/mysql-tomcat && docker compose up fails on master today, in all 8 mysql/mariadb directories.

It blocks this branch specifically, because smokeTestVariants exists to boot the variant whose JDBC driver moved -
the one check that would exercise a MySQL connector crossing a major - and that boot could only ever fail. It went
unnoticed because every smokeTest reported above was run on postgres-tomcat, the one variant that mounts no script.

  • Fixed by mounting ./mysql/init.sql / ./mariadb/init.sql, which is what the template said before d1e6ca1. This
    is the only generated-file change in this Pull Request: 8 docker-compose.yml, one line each.
  • Guarded so it cannot come back unnoticed: generate now fails when a generated compose file bind mounts a
    relative path that is not a file next to it. Nothing else covers this - the Docker Build workflow builds the image
    rather than running the compose file, and smokeTest boots one variant per cycle by default, so the other two can
    stay broken for as long as nobody starts them by hand. Checking it at generation time covers all 12 variants on every
    ./gradlew, and therefore on every push and Pull Request through Gradlew Check.

Verified: smokeTest -Pcycles=17 -Pvariants=mysql-tomcat,mariadb-tomcat now boots both and /rest reports 17.10.12,
teardown leaving no container or volume behind; the init script genuinely runs, show grants for 'xwiki'@'%' reporting
the global ON *.* privileges that subwiki creation needs; and the guard fails generation on the reverted template,
naming the file and the path.

Second review round (@claude, narrowed to defects that can produce a wrong release artifact or destroy local data)

One finding, fixed here; no behaviour change and no generated file moves.

  • build.gradle held a raw NUL byte - the separator between the path and the content in the generated-directory
    snapshots. Git therefore classified the script as binary: git diff --numstat answered -/-, GitHub rendered it
    as "Binary file not shown", and a concurrent edit could only be resolved by taking one whole side rather than by a
    text merge - on the single file holding the generation logic and workflowPathFilters. Fixed by writing the
    separator as a \n escape. (The snapshots themselves are gone as of the cleanup round below.)

Verified in that round, by running rather than by reading:

  • ./gradlew at the branch head: no git diff.
  • The generated library/xwiki reproduces the currently published file byte for byte with the GitRepo and
    GitCommit lines normalised away - confirmed independently by driving generateLibraryContent and diffing against
    docker-library/official-images@master.
  • versions.json round-trips byte-identically through JsonSlurper + JsonOutput.prettyPrint: no key reordering, no
    string-to-number coercion.
  • updateXWiki / updateJDBC / updateLibreOffice against the live sources: all no-ops today, matching the "this PR
    regenerates no image" claim. The floor rule holds back the five drivers listed above, and MySQL's calendar-versioning
    jump is ordered correctly for the right reason - Maven Central publishes 26.7.0 on 2026-07-24 against 9.7.0 on
    2026-04-22, so compareVersions keeping the 26.7.0 pin over the POM's 9.7.0 is an upgrade preserved, not a downgrade
    applied.
  • GitRepo: xwiki/xwiki-docker is the canonical name, xwiki/docker-xwiki and xwiki-contrib/docker-xwiki both
    answer 301. The correction is right.
  • The compose isolation, on a rewritten file through docker compose config: project smoketest-18-mysql-tomcat,
    volumes smoketest-18-mysql-tomcat_{mysql,xwiki}-data, project-scoped network, renamed containers, port 18080, image
    tag resolved from .env. A down -v provably cannot reach a hand-started instance's mysql-tomcat_* volumes. The
    bind mount resolves to a real <variant>/mysql/init.sql, i.e. the fix above holds.
  • The destructive-command surface of the new scripts: no prune, no rm, no git reset/clean/checkout -f, no
    unscoped docker compose down; every down -v is -p-scoped.

Cleanup round: what was removed, and the guarantee it did or did not carry

The branch had accumulated machinery whose cost was review surface rather than safety. Asked which parts of gradle/
could go without losing a real guarantee, the answer came to −348 lines (98 insertions, 446 deletions); gradle/
goes 1465 → 1148 lines. Nothing generated moves, and library/xwiki is unchanged.

Removed because they guaranteed nothing:

  • runAll / stopAll, with -PbasePort and -Pfresh (≈115 lines). They boot-checked nothing that
    cd <cycle>/<variant> && docker compose up - which is what the documentation already tells users to run - does not
    already do, and nothing in release touched them. smokeTest keeps the port/name isolation they shared.
  • The Docker Build workflow check of submitOfficialImage, with -PskipCiCheck (≈112 lines, taking the exitCode
    helper with it). docker-library builds every image on its own Pull Request before merging it, unwaivably, so this
    only duplicated an upstream gate - and its own waiver existed because that workflow fails on transient
    maven.xwiki.org / download.documentfoundation.org downloads often enough that "a release cannot be held hostage
    to it". A guard whose documented usage includes routine bypass is not a guarantee. Roughly half its length was the
    fallback-run and ancestor-containment reasoning for the case where GitHub had not created the run yet, which then
    resolved to a warning and proceeded anyway.
  • The -PtargetRepo rehearsal mode (≈40 lines). It was scaffolding for developing the write path, and it left two
    modes to hold in your head through the whole submission flow.
  • The generated-directory content snapshots (≈35 lines). They read and string-compared every generated file twice
    on every ./gradlew to serve one case: release run straight after a template/ edit. smokeTest -Pcycles=...
    asks for that directly, and the README and wiki text now say so.
  • The fork-sync retry loop and the collected-problems accumulator. The first covered a once-per-machine fork
    creation race; the second let a dry run list every objection, which no longer applies now that a dry run stops
    before the guards.
  • The configuration-time duplicate-tag check (≈20 lines). Redundant twice over: updateXWiki already refuses to
    write two cycles onto one version line, and docker-library rejects the file if one ever got through. Worse, it ran
    at configuration time, so a hand-edited versions.json broke plain ./gradlew generate - the very hazard the
    updateXWiki guard was added to avoid. Verified: in that state ./gradlew now succeeds, and updateXWiki still
    refuses the bump by name.
  • compareVersions' qualifier tolerance. Its own comment conceded every version it handles is plain numbers.
    A NumberFormatException in front of a release manager at a terminal is the right failure for a version this build
    has no business ordering.

Fixed, in the opposite direction - something that looked like a guard but laundered a red flag:

  • updateLibreOffice now fails when the sha256 published for the pinned version no longer matches the pinned one.
    It used to adopt the new hash and log Updated the LibreOffice sha256 of the unchanged version 25.8.7. Different
    bytes served under a version this repository has already published images for is precisely what pinning a hash exists
    to detect; accepting it silently republishes every image on bytes nobody looked at.

Retest of every feature after the removals

Run against the live sources and a live Docker, not read:

  • ./gradlew — configures and regenerates with no git diff, so gradlew-check passes.
  • ./gradlew tasks — the surface is generate, generateWorkflows, updateXWiki, updateJDBC, updateLibreOffice,
    smokeTest, release, submitOfficialImage. runAll / stopAll are gone, and the removed flags are inert rather
    than fatal (-PbasePort=nonsense -PskipCiCheck -PtargetRepo=bogus -Pfresh configures fine).
  • ./gradlew release --dry-run — ordering still updateXWikiupdateJDBCupdateLibreOfficegenerate
    generateWorkflowssmokeTestrelease.
  • The three update tasks, live: all no-ops today (18.6.0 / 18.4.4 / 17.10.12 / 16.10.18, LibreOffice 25.8.7), the
    floor rule still holding back the same five drivers, and versions.json untouched.
  • The bump paths, forced by pinning cycle 16 back to 16.10.17, its postgres driver to 42.7.0 and LibreOffice to
    25.8.6: each moved forward, and the WAR sha256 updateXWiki computed for 16.10.18 matches the reference hash in
    versions.json byte for byte
    - sha256OfUrl proven end to end, not just exercised. The simplified
    compareVersions is what orders MySQL 26.7.0 over the POM's 9.7.0 here.
  • The new sha256 guard, forced by corrupting the pinned amd64 hash: the task fails, names the architecture and both
    hashes, and leaves versions.json unwritten.
  • updateXWiki's two guards, forced: a pin ahead of the metadata (16.99.0) is kept with a message, and a cycle
    that would move onto another's version line is refused by name.
  • The role guards, forced: an unknown role, two stable cycles and no stable cycle each fail at configuration
    time with the offending value.
  • -Pcycles= / -Pcycles=19 / -Pvariants=mysql / -PdryRun=maybe — each rejected by name, listing the available
    values; -PdryRun=false correctly reads as false.
  • verifyBindMounts, forced by pointing the template's mysql bind mount at a file that is not generated: fails and
    names the file and the path.
  • The compose rewrite guard, forced by changing how template/docker-compose.yml publishes its port: fails with
    the "no longer match what template/docker-compose.yml generates" message before booting anything.
  • submitOfficialImage -PdryRun against a deliberately dirty tree: generates the file, shows the diff and skips the
    guards
    as intended. The result still reproduces the published library/xwiki with GitRepo and the GitCommit
    lines as its only differences, emits no tag twice, and renders all four role section headers.
  • smokeTest for real, twice. Cycle 18 postgres-tomcat: built with the classic builder, came up as
    xwiki-smoketest-18-postgres-tomcat-web on port 18080 alongside unrelated containers left untouched, /rest
    reported 18.6.0, torn down. Then the release integration - cycle 16's mysql driver pinned low, updateJDBC smokeTest
    in one invocation - which planned 16 (postgres-tomcat, mysql-tomcat), i.e. the driver bump pulled in that
    database's variant on top of the baseline, and booted both to 16.10.18. No container or volume left behind.
  • versions.json, template/ and the generated tree restored and re-verified clean after every forced failure above.

Docs

  • README.md gains a "Releasing new versions" section (in this PR), updated for the removals above.
  • Not in this PR: the CLAUDE.md update, and the replacement below for the wiki's "Update Docker Images" section.
Proposed replacement for the wiki "Update Docker Images" section

Drops the "Create a JIRA issue" step and the manual sha256 download/compute/paste step, replaces the manual "Test
Docker Image" procedure with the automated smokeTest, and automates the official-images Pull Request.

Update Docker Images

This should be done only for final versions (i.e. ones that include bugfixes).

The version bumps and the DockerHub official-images Pull Request are automated as Gradle tasks. You only review, commit
and push the docker-xwiki change in between.

  1. Update and regenerate the XWiki Docker repository. Clone (or update) the repo with a clean working tree, then
    run ./gradlew release. For every supported cycle this resolves the latest final XWiki version (downloading the WAR
    and computing its sha256 when it changed), updates the JDBC drivers from that cycle's own xwiki-platform POM,
    updates the supported LibreOffice version, regenerates all the Dockerfiles/resources and the build workflow, and
    smoke-tests the cycles whose versions moved (builds the postgres-tomcat image with the classic builder, plus that
    of any variant whose JDBC driver moved, boots each and waits until its REST API reports the expected version).
    Docker must be running. Each step is also runnable on its own:
    updateXWiki, updateJDBC, updateLibreOffice, generate, generateWorkflows, smokeTest.
  2. Review, commit and push versions.json together with the regenerated directories. To look at an image by hand,
    run docker compose up in its version/variant directory as usual, or
    ./gradlew smokeTest -Pcycles=18 -Pvariants=postgres-tomcat to boot-check a specific one; the old manual "Test
    Docker Image" procedure also remains available for a deeper test.
  3. Submit the DockerHub official image PR. Ensure gh is authenticated, then run ./gradlew submitOfficialImage.
    It regenerates the library/xwiki file and opens a PR against docker-library/official-images from your fork,
    aborting if the tree is dirty, if HEAD is not on master, or if a PR from a previous run is still open. Use
    -PdryRun to generate the file and show the diff without opening a PR.

🤖 Generated with Claude Code

Comment thread 16/mariadb-tomcat/Dockerfile Outdated
ENV MARIADB_JDBC_VERSION="3.5.9"
ENV MARIADB_JDBC_SHA256="11e3bb5bbf8ef0e806ae4d6c5d5033fedf7262cc777f0190bde8a2f3c8e6bd8d"
ENV MARIADB_JDBC_VERSION="3.5.7"
ENV MARIADB_JDBC_SHA256="07bb1229dc184f3313a5aef4c5a6b3207c8dbaa09db4a26814c936f004b4c526"

@vmassol vmassol Jul 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to check those. The script is supposed to take the values from our pom (see https://github.com/xwiki/xwiki-docker/pull/92/changes#diff-49a96e7eea8a94af862798a45174e6ac43eb4f8b4bd40759b5da63ba31ec3ef7R53-R56 ).

It probably means they have not been updated properly in the pom.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it's because we use the versions used in the last release of XWiki for the branch, see https://github.com/xwiki/xwiki-docker/pull/92/changes#diff-49a96e7eea8a94af862798a45174e6ac43eb4f8b4bd40759b5da63ba31ec3ef7R462-R463

That makes sense, since we should be aligned. It just means that these will be updated after we release XWiki, when we release the docker images.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So your PR actually contains both changes in the script and changes performed by the script, right? Would feel cleaner to have a separated commit just for those changes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After rebasing on master this is now moot: the PR no longer carries any generated-file changes. Cycle 16 is back to the exact values master ships, and the per-cycle POM values (9.6.0 / 3.5.7 / 42.7.9 for 16) will be applied automatically by updateJDBC at the next release — which is when the driver versions get refreshed anyway. So the diff is now script + config only, with no mixed generated output.

One reconciliation note: master's XWIKI-24618 bumped postgres to 42.7.13 (ahead of what the cycle POMs declare). I kept that value across the cycles here rather than reverting it, and updateJDBC will realign everything with the POMs at release time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahead of what the cycle POMs declare

This is not accurate, as can be seen on https://github.com/xwiki/xwiki-platform/blob/master/pom.xml#L122.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes have been reverted so yes it's not accurate, that's the point! :) Both Simon and you asked for no change to the version in the PR, ie to defer its execution till the PR is merged and when it's used to do a release....

RE <pgsql.version>42.7.13</pgsql.version> your change was not removed.

@tmortagne tmortagne Jul 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes have been reverted so yes it's not accurate, that's the point! :)

You are mixing different things.

"Your" last comment was referring to master, so I answered that no, xwiki-docker is not ahead of master (or any other branch).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled after the rebase, in two parts.

The PR carries no generated-file change at all any more: versions.json is seeded with exactly the driver versions and sha256 that master generates from today, so ./gradlew reproduces every */*/Dockerfile byte for byte. Script plus config only.

And updateJDBC no longer downgrades a driver (commit 58f7436). Read literally, each cycle's own POM would have taken cycle 18 back from mysql 26.7.0 / mariadb 3.5.10 to the 9.7.0 / 3.5.9 the released 18.6.0 POM declares, and cycle 16 to 9.6.0 / 3.5.7 / 42.7.9 — i.e. reverting XWIKI-24656 / XWIKI-24657, which were done here after those cycles were released, and rebuilding the images for nothing. The POM value is now a floor rather than an exact target: anything older than what is pinned is reported and skipped, upgrades still apply, and one crossing a driver major says so in the log since the diff gets reviewed before being committed. Running it today is a no-op on every cycle.

@vmassol vmassol self-assigned this Jul 19, 2026
Comment thread build.gradle Outdated
@surli

surli commented Jul 21, 2026

Copy link
Copy Markdown
Member

smokeTest Boot the postgres-tomcat variant of each changed cycle with docker compose, wait for HTTP 200 on :8080, then down -v.

Two things:

  1. shouldn't have a bit more check than just the 200 status, e.g. some content check / REST API version check perhaps?
  2. it feels like another task that would do the same but starting them on different ports and keeping them up so that we can access manually to perform checks could be useful no?

Comment thread build.gradle Outdated
Comment thread build.gradle Outdated
@vmassol
vmassol force-pushed the XDOCKER-18-automate-docker-images-release branch from e940758 to 260b153 Compare July 25, 2026 09:30
@vmassol

vmassol commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@surli both good ideas:

  1. Stronger check than HTTP 200 — agreed. I can make smokeTest also hit /rest/ (or /rest/wikis/xwiki) and assert the response carries the expected XWiki version, so we verify XWiki actually initialized rather than just that Tomcat is up.
  2. A keep-them-running task — agreed too. I can add a separate runAll task that starts each cycle/variant on distinct ports (8080, 8081, …) and leaves them up for manual inspection, kept separate from smokeTest (which tears down with down -v).

Update: both are now implemented in this PR (commit e30aa1c) — see the follow-up comment below.

@vmassol

vmassol commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@surli I went ahead and implemented both suggestions in this PR after all (commit e30aa1c) — they turned out small and self-contained:

  1. Stronger smoke check. smokeTest no longer just waits for HTTP 200; it now polls /rest and asserts the response carries the expected XWiki version (the root REST resource returns <version>X.Y.Z</version>, served to guests), so we verify XWiki actually initialized rather than that Tomcat is merely up. Factored into a shared waitForXWiki(port, version, timeout) helper.
  2. Keep-them-running task. New runAll task starts each selected cycle/variant on its own host port (8080, 8081, …) as an isolated compose project and leaves them up for manual inspection (kept separate from smokeTest, which still tears down with down -v). It rewrites the generated compose on the fly to avoid port/container-name collisions when several run at once. Defaults to every cycle × variant; narrow with -Pcycles=… / -Pvariants=…. A matching stopAll tears them back down.

Verified live: booted a rewritten instance on a non-default port, XWiki reached its renamed DB and /rest reported the right version in ~50s; teardown removed volumes + network cleanly.

@manuelleduc
manuelleduc self-requested a review July 29, 2026 15:46
Comment thread build.gradle Outdated
tasks.named('generate').configure {
mustRunAfter('updateXWiki', 'updateJDBC', 'updateLibreOffice')
}
tasks.named('smokeTest').configure { mustRunAfter('generate') }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is starting to get huge. I'm wondering if we shouldn't move each method in its own file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in the rebased commit. build.gradle goes from 280 to 141 lines, keeping only the version-model loading and generate/generateWorkflows; everything else moves into scripts applied from gradle/, grouped by area rather than one file per method (a file per method would scatter helpers that are only meaningful together):

  • helpers.gradle (114 lines): the HTTP, hashing, version-compare and process helpers shared by the tasks.
  • update-versions.gradle (216): updateXWiki, updateJDBC, updateLibreOffice.
  • run-instances.gradle (204): smokeTest, runAll, stopAll.
  • official-image.gradle (217): the library/xwiki generation and submitOfficialImage.
  • release.gradle (36): the release umbrella and the task ordering.

They exchange the model and the helpers through the project's extra properties, bound at configuration time. That last part also fixed a real problem the split surfaced: reading project from inside doLast is deprecated in Gradle 9.7.1 (Invocation of Task.project at execution time … will fail with an error in Gradle 10), so smokeTest, runAll/stopAll and submitOfficialImage would have broken on Gradle 10, mid-release. The build is warning-free now.

Happy to slice it differently if you would rather have another grouping.

Automate the manual "Update Docker Images" release step as Gradle tasks, mirroring the existing
updateLibreOffice precedent:

* updateXWiki: resolve the latest final XWiki version on each existing cycle's line from XWiki's Maven
  metadata and, for the changed cycles, download the WAR and compute its sha256.
* updateJDBC: read the three JDBC driver versions from each cycle's own xwiki-platform POM and, for the
  changed ones, download the driver from Maven Central and compute its sha256. Driver versions/sha256 are
  now stored per cycle, replacing the shared mysql/mariadb/postgres maps.
* smokeTest: boot the postgres-tomcat variant of each changed cycle with docker compose, wait until its
  REST API reports the expected XWiki version, then tear it down.
* runAll/stopAll: start each selected cycle/variant on its own host port as an isolated compose project
  and leave them running for manual inspection.
* release: the pre-push umbrella chaining updateXWiki, updateJDBC, updateLibreOffice, generate,
  generateWorkflows and smokeTest.
* submitOfficialImage: generate the docker-library 'library/xwiki' file from the version data model and
  open the official-images Pull Request from the maintainer's fork (-PdryRun previews the file and the
  diff only).

The version data model (per-cycle XWiki + JDBC driver versions/sha256, per-cycle role and base image, and
the shared LibreOffice values) moves out of build.gradle into the versions.json data file, keeping the
configuration separate from the build logic and letting the update tasks persist it with JsonOutput. The
values it starts with are the ones master currently generates from, so this change regenerates no image.

The build logic itself is split out of build.gradle into scripts applied from gradle/: helpers.gradle
(HTTP, hashing and process helpers), update-versions.gradle, run-instances.gradle, official-image.gradle
and release.gradle. They exchange the model and the helpers through the project's extra properties and
bind them at configuration time, so that no task action reaches for the project while it runs (deprecated
in Gradle 9, removed in Gradle 10). External commands go through Groovy's Process API since Project.exec
is gone from Gradle 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vmassol
vmassol force-pushed the XDOCKER-18-automate-docker-images-release branch from e30aa1c to d781498 Compare August 24, 2026 12:14
* Never downgrade a JDBC driver below the pinned version

The JDBC driver version declared in a cycle's own xwiki-platform POM is a floor, not an exact target, so
updateJDBC now keeps whatever is pinned in versions.json when the POM declares something older. A pin
ahead of the POM is a deliberate bump made after that cycle was released (a security fix, or a Renovate
upgrade such as XWIKI-24657 for the MySQL connector), and applying the POM value literally would silently
revert it and rebuild the images for nothing.

Upgrades still apply, including the ones crossing a driver major, but those now say so in the log since
the release manager reviews the diff before committing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vmassol

vmassol commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Rebased on master and addressed the outstanding review comment. History is a single squashed commit (force-pushed), since master had moved 8 commits and the file layout changed.

Rebase. Rather than hand-merging the conflicts, the automation is re-applied on top of master's current files, so nothing master gained meanwhile is lost: build.gradle keeps expandTemplates + generateWorkflows (XDOCKER-422, which this branch predated) and release now chains generateWorkflows too; README.md takes master's short version with the maintainer "Releasing new versions" section appended.

No image change. versions.json is seeded with exactly the values master generates from today, so ./gradlew regenerates every version/variant directory with a zero diff. The only generated change is .github/workflows/docker-build.yml, which gains gradle/*.gradle and versions.json in its path filters so CI still triggers on the build logic.

Split of build.gradle (@manuelleduc's comment): 280 -> 141 lines, the rest in scripts applied from gradle/: helpers.gradle (HTTP/hashing/process helpers, 114), update-versions.gradle (207), run-instances.gradle (204), official-image.gradle (217), release.gradle (36).

Gradle 10 fix found while splitting. The tasks read project.hasProperty(...) / project.ext inside doLast, which Gradle 9.7.1 reports as Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. Everything is now bound at configuration time, and updateXWiki hands its changed-cycle list to smokeTest through a shared holder instead of an extra property written at execution time. The build is warning-free.

Verified in this round

  • ./gradlew -> clean, zero diff on the generated directories; tasks lists all 10 tasks; release --dry-run -> correct ordering.
  • updateJDBC run live against nexus.xwiki.org + Maven Central: works, and versions.json round-trips through JsonOutput with value-only diffs (restored afterwards, so this PR carries no version change).
  • submitOfficialImage -PdryRun: reproduces the upstream library/xwiki byte for byte except the GitCommit lines.
  • smokeTest -Pcycles=18 run for real against Docker (the path never executed before): green in 1m40s, booted 18.6.0 postgres-tomcat, /rest reported 18.6.0, down -v left nothing behind. Worth noting that its first attempt failed on a Docker Hub layer-pull retry storm, i.e. a registry hiccup fails release at that step.
  • No line over 120 characters.

updateJDBC no longer downgrades a driver (second commit). Read literally, each cycle's own POM would have taken cycle 18 from mysql 26.7.0 / mariadb 3.5.10 back to the 9.7.0 / 3.5.9 that the released 18.6.0 POM declares, and cycle 16 to 9.6.0 / 3.5.7 / 42.7.9 — silently reverting the bumps made here after those cycles were released (XWIKI-24656, XWIKI-24657) and rebuilding the images for nothing. The POM value is now treated as a floor: anything older than what versions.json pins is reported and skipped, upgrades still apply, and one crossing a driver major says so in the log since the diff is reviewed before being committed. Running it today is a no-op:

Cycle 18: mysql JDBC POM declares 9.7.0, keeping the pinned 26.7.0 which is ahead of it.
Cycle 18: mariadb JDBC POM declares 3.5.9, keeping the pinned 3.5.10 which is ahead of it.
Cycle 18: postgres JDBC already up to date (42.7.13).
...
Cycle 16: mysql JDBC POM declares 9.6.0, keeping the pinned 9.7.0 which is ahead of it.

@vmassol

vmassol commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

@surli @tmortagne @manuelleduc : all comments addressed, please let me know if you're ok to merge and test this for the next release. Thx

@manuelleduc manuelleduc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM but I let @tmortagne have the last word since he is he RM for 18.7.0 :)

@tmortagne

Copy link
Copy Markdown
Member

Sure, let's try.

vmassol and others added 7 commits August 25, 2026 12:17
* Fix the runAll/stopAll variant selection being shadowed
* The 'ext.variants' extra property holding the DB variants collided with the '-Pvariants' project property that
  'runAll'/'stopAll' read to narrow their selection down. Since the extra property is set before the script declaring
  those tasks is applied, 'project.hasProperty("variants")' was always true and 'project.property("variants")' always
  returned the variant List rather than the command line String. Both tasks thus failed with "No signature of method:
  java.util.ArrayList.tokenize()" on every invocation, with or without the flag.
* Renamed the extra property to 'imageVariants' so that it no longer shares the '-Pvariants' name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Build the image in smokeTest instead of pulling it
* The generated docker-compose.yml has no 'build' section, only 'image: xwiki:<version>-<variant>', so 'smokeTest'
  pulled that tag from DockerHub. At release time that tag does not exist yet, since the official image for a version
  is only built once the 'submitOfficialImage' pull request has been merged, which happens after 'release' runs. The
  boot therefore failed with "manifest unknown" in exactly the scenario the task exists for, and when run on an
  already published version it silently exercised that published image rather than the freshly regenerated Dockerfile.
* Build the image locally before booting it, so that the smoke test covers the Dockerfile that was just generated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix the captured command output being truncated or empty
* 'capture' consumed the process output into two writers and then merely waited for the process, which races with the
  threads draining its streams: the process can exit while they still have bytes to write, so the captured stdout came
  back short or altogether empty. Measured on a 4 MB output, 4 runs out of 50 returned an empty String.
* Everything 'capture' reads either decides something or ends up in the content submitted upstream, so a silently short
  read is not an option: 'git status --porcelain' coming back empty passes the dirty tree guard of
  'submitOfficialImage', and an empty 'git log' answer emits an empty 'GitCommit' into the official images file.
* Use 'waitForProcessOutput', which waits for the process and for both draining threads (and still lets 'exitValue' be
  read afterwards). Same measurement: 0 mismatches out of 50.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Build the images in runAll instead of pulling them
* 'runAll' rewrote the ports and the container names of the generated docker-compose.yml but left its 'image' line
  alone, so it pulled 'xwiki:<version>-<variant>' from DockerHub exactly like 'smokeTest' used to. At release time that
  tag does not exist yet, so the boot failed with "manifest unknown" precisely when the images worth looking at are the
  ones just regenerated, and on an already published version it silently ran the published image instead of them.
* Extract the build step into a 'buildImage' helper shared with 'smokeTest' and build each instance before starting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Smoke test the cycles changed by any of the update tasks
* 'smokeTest' picked the cycles to boot from what 'updateXWiki' reported, so a release bumping only a JDBC driver or the
  shared LibreOffice version regenerated images and then tested nothing at all, although those are the changes carrying
  a brand new download URL and sha256, i.e. the ones most likely to break the build.
* 'updateJDBC' now reports the cycles whose drivers it changed, and 'updateLibreOffice' reports that all of them
  changed, since the LibreOffice version is shared by every cycle. The holder they share with 'smokeTest' is renamed to
  'versionUpdate' accordingly.
* Order 'smokeTest' after the update tasks themselves and not only after the 'generate' they order before, so that it
  still sees what they changed when it is chained without 'generate' (e.g. './gradlew updateJDBC smokeTest').
* Log the cycles being tested, since which ones they are is now the result of what three tasks found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Report the docker output as it comes instead of swallowing it
* The docker commands went through 'capture', which buffers the whole output and returns it, so booting an instance
  showed nothing at all until it was over. A 'docker build' downloading the XWiki WAR and the LibreOffice archive takes
  minutes and says what it is doing all along, and none of that was reported; the log was only held in memory to be
  thrown away, or quoted in an exception once the command had already failed.
* Add a 'stream' helper next to 'capture' for the commands whose output is worth watching rather than parsing, and use
  it for 'docker build' and the 'docker compose' calls of 'smokeTest', 'runAll' and 'stopAll'. It merges stderr into
  stdout, reports each line as it arrives, and reads the stream to its end before waiting for the process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Build the images with the classic builder like the CI does
* 'smokeTest' and 'runAll' built with whatever builder the local Docker defaults to, i.e. BuildKit, while the Docker
  Official Images infrastructure uses the classic builder and the Docker Build workflow sets DOCKER_BUILDKIT=0 to match
  it. The two do not accept the same Dockerfiles: the classic builder leaves Docker's automatic platform arguments
  empty, for one, so a Dockerfile reading TARGETARCH builds fine locally and breaks where the image is really built.
* Ask for the classic builder in 'buildImage' too, so that a green boot check says something about the build that will
  actually be run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vmassol and others added 18 commits August 25, 2026 21:31
* Require the commits submitted upstream to be on master
* 'submitOfficialImage' refused an unpushed HEAD by looking for it in any remote branch at all, so running it from a
  Pull Request branch passed the check although docker-library fetches the images from the master branch of GitRepo
  alone. The GitCommit values emitted for such a run point at commits nobody but GitHub can fetch.
* Check that HEAD is on the branch docker-library actually fetches, and fetch that remote-tracking branch first since
  it goes stale as soon as the change is pushed from elsewhere (a merged Pull Request, another clone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* List the tasks in './gradlew tasks'
* None of the tasks declared a group, so they all landed in the ungrouped bucket that './gradlew tasks' only prints
  with '--all'. The plain listing showed none of the entry points the README documents, and no description of what
  they do or of the order they are meant to be run in.
* Give each of them a group and a description: 'generation' for the two tasks generating the images and the workflow,
  'release' for the version updates and the two release entry points, and 'verification' for the ones booting images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Compare the versions without failing on a qualifier
* 'compareVersions' parsed every dot-separated component as an int, so a version carrying anything but digits threw a
  NumberFormatException. Every version it is given is plain numbers today, but it decides whether a driver found in a
  POM is an upgrade over the pinned one, i.e. it reads values this repository does not control, and aborting a release
  over the day one of them gains a qualifier is a poor trade.
* Compare each component on the number it starts with, falling back to comparing the components as text when those
  numbers are equal, so that a qualifier orders instead of failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Look the JDBC driver properties up by their exact name
* The regex reading a driver version out of a cycle's POM interpolated the property name as it is, so the '.' of
  'mysql.version' was left as the regex metacharacter matching any character. Nothing in the POMs happens to match
  those patterns loosely today, but a property whose name only resembles the one being read would be picked up.
* Quote the property name so that it is matched literally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Leave versions.json alone when LibreOffice has not moved
* 'updateLibreOffice' rewrote the whole data file on every run, including when it had just resolved the very version
  and checksums already pinned, unlike the two other update tasks which only write what they changed.
* Write only when the version or one of the two checksums differs, and report a checksum changing under an unchanged
  version for what it is, since that means the archive it was read from was republished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Never close an official images Pull Request still open
* 'submitOfficialImage' names its branch after the stable version and deleted that branch on the fork before
  recreating it. Running it twice for the same version, which happens whenever something has to be corrected, deleted
  the ref of the branch the pending Pull Request was opened from, and GitHub closes a Pull Request whose head branch
  is gone. A submission still being reviewed upstream is not ours to close.
* Refuse to run when a Pull Request is still open from that branch, naming it so it can be looked at.
* Also fail with an explanation rather than a NullPointerException when no cycle declares the 'stable' role, since
  that is the version the branch and the commit message are named after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Say what binding the model at configuration time really buys
* The comments explaining why the applied scripts bind the model and the helpers into local variables claimed that no
  task action reaches for the project while it runs, which is not what the code does: 'generate' and
  'generateWorkflows' call 'copy', and the tasks booting images resolve their paths with 'file'. A comment promising
  more than the code delivers is worse than no comment, and this one would have the next reader believe the build is
  configuration cache compatible when it is not.
* State what the binding actually avoids, namely reading the project properties from inside the task actions, and say
  where the build still stands with the configuration cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give each booted instance a compose project of its own
* 'smokeTest' ran 'docker compose up -d' and 'down -v' straight in the version/variant directory, so its compose
  project was the directory basename - 'postgres-tomcat', which every cycle shares and which is also what a developer
  gets from 'cd 18/postgres-tomcat && docker compose up'. Booting cycle 18 while such an instance was running failed
  on the container name and the port 8080 already taken, and the teardown in the 'finally' then removed that
  instance's containers and, with '-v', its data volumes. A release check has no business deleting someone's wiki.
* Rename 'runAllCompose' to 'composeInstance', move it above 'smokeTest' and use it there too, so the smoke test gets
  the port and container renaming plus the '-p' project that 'runAll' already had. Both derive their project name
  through 'composeProject', which prefixes it with the task so tearing one down never reaches the other.
* Publish the smoke test on 18080 rather than 8080, so that a port a developer is already using cannot fail a boot
  for a reason unrelated to the image being checked.
* Depend on 'generate' in 'smokeTest' and 'runAll'. The image is tagged with the model's XWiki version while the
  compose file names the one in the generated '.env', so './gradlew updateXWiki smokeTest' used to boot the
  previously published image and then wait ten minutes for a version it could never report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Retry a download that failed rather than abort the release
* 'release' streams the XWiki WAR and the LibreOffice archives, hundreds of megabytes from hosts that fail
  transiently often enough that the generated Dockerfile retries its own downloads for that very reason (CURL_OPTIONS
  in template/Dockerfile). Here a single blip aborted the run, possibly after 'versions.json' had already been
  rewritten for the cycles handled before it, leaving the release manager to work out how far it got.
* Fold the redirect-following loop that 'httpGetText' and 'sha256OfUrl' each carried into a single 'httpGet' that
  hands the response stream to a consumer, and give it three attempts with a growing delay. A failure while the
  consumer is reading counts too, since that is where a large download dies.
* Only retry what can get better on its own: an IOException and the 408/429/5xx statuses. A 404 on a version that
  does not exist still fails at once instead of burning the delays first.
* Convert the command to plain Strings in 'stream'. ProcessBuilder only turns its list into an array when the process
  starts, so a GString slipped into it failed there with an ArrayStoreException rather than where it was written -
  and 'capture', which the same scripts use interchangeably, takes GStrings in its stride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Reject a cycle role that docker-library would not understand
* The 'role' of a cycle is not only ours: it drives the tag aliases and the section comments of the official Docker
  library file that 'submitOfficialImage' submits to docker-library. A typo in 'versions.json' went through
  unnoticed, emitting a literal 'null' section header, silently dropping that cycle's stable-*/lts-* aliases and
  putting '(null)' in the Pull Request body - noticed, if at all, by a reviewer upstream.
* Check the roles when build.gradle reads the model, and check that exactly one cycle is the stable one, since the
  branch and the commit message of that Pull Request are named after its version.
* Drop the check 'submitOfficialImage' made for a missing stable cycle, now that it cannot happen, and say where the
  invariant comes from.
* Also drop 'ext.versionsFile', which nothing read: 'writeVersions' closes over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Hash the WAR at the URL the image downloads it from
* 'updateXWiki' resolved the versions and computed the sha256 from nexus.xwiki.org while the generated Dockerfile
  downloads the WAR from maven.xwiki.org, on the argument that the two serve the same artifacts. They do, but pinning
  the bytes the image actually fetches is the whole point of the hash, and it costs nothing to read them from the one
  URL that matters. The JDBC and LibreOffice hashes are already taken from their own download URL.
* Read the maven-metadata.xml and the WAR from maven.xwiki.org/releases, sharing the one repository URL, and read the
  cycle POM of 'updateJDBC' from there as well rather than from a second repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Check the generated files on Pull Requests too
* The Gradlew Check workflow is what guarantees that the committed generated files are what the templates and
  versions.json produce, but it only ran on a push to master - so a Pull Request changing either was merged before
  anything verified it, and master was where it broke.
* Run it on Pull Requests as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Never downgrade the XWiki version of a cycle
* Apply to 'updateXWiki' the floor rule 'updateJDBC' already has. A pinned
  version ahead of everything the Maven metadata lists means the metadata lost
  it (a release pulled for being broken, a repository half synced), not that
  the cycle should be rebuilt and republished backwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Smoke test what a change is about, and say why it failed
* Boot, for each cycle, the variant of every database whose JDBC driver
  'updateJDBC' moved, on top of the postgres-tomcat baseline. A driver bump is
  the one DB-specific change there is, and nothing else in the build ever
  connects XWiki to a database - the Docker Build workflow only checks that the
  jar landed in WEB-INF/lib - so a MySQL connector crossing a major went out
  entirely unbooted. 'updateJDBC' now reports which database moved, and
  '-Pvariants' overrides the selection.
* Report the tail of the container logs when an instance never comes up, while
  the containers still exist: the 'down -v' takes them away, and a bare timeout
  message is not something a failed release can be diagnosed from.
* Match the version reported by the REST API between boundaries instead of as a
  substring. One version string can be the prefix of another, so an instance
  answering 17.10.12 satisfied a smoke test expecting 17.10.1, i.e. the release
  gate went green on the wrong image.
* Fail when the docker-compose.yml rewrites that isolate an instance find
  nothing to replace, instead of silently booting on 8080 in the shared project
  and tearing down, volumes included, whatever was started there by hand.
* Tear down the instance's compose project before starting it too, so that a
  stale one left by a killed daemon or a reboot is not adopted and reported on
  in place of the image just built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the official images file honest about what it declares
* Stop untracked files blocking 'submitOfficialImage'. What the guard is about
  is whether the commits it emits are reachable upstream, which a release
  manager's scratch notes have no bearing on, so it now looks at tracked
  changes only - and names them, instead of leaving 'git status' to be run by
  hand.
* Define what a cycle 'role' means in a single map, the section comment and the
  Pull Request label together, and check the model against it there. The
  labels used to live apart from the check, leaving the Pull Request body free
  to render an unknown role as 'null' while everything else looked right.
* Reject a variant that build.gradle generates but this file does not declare.
  It would be generated, committed and built by CI, yet never reach the file
  that publishes it, so the images would simply not exist on DockerHub.
* Retry syncing the fork, which GitHub creates asynchronously and which a
  first-ever run can therefore fail to find.
* Drop 'capture's working directory parameter, which no caller passes, and the
  unused ReplaceTokens import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Submit the current URL of this repository
* Emit 'GitRepo: https://github.com/xwiki/xwiki-docker.git'. The published file
  still names xwiki-contrib/docker-xwiki, which only resolves through the
  redirect GitHub kept when this repository moved to the xwiki organisation, and
  that redirect goes away the day anything else is created under the old name -
  taking the official image builds with it. This is the one line by which the
  generated file now differs from the published one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* submitOfficialImage now refuses to submit images the Docker Build workflow has not built successfully: what
  docker-library rebuilds is the GitCommit(s) emitted in the library file, so that workflow is what says they build at
  all, and without the check the first thing to notice a broken Dockerfile is the upstream Pull Request. The workflow
  only triggers on the paths the images are made of, so a HEAD that triggered no run of its own is checked against the
  latest run of the release branch instead; no run at all is reported but not fatal. '-PskipCiCheck' waives it, since
  that workflow also goes red on the transient download failures it is prone to.
* Add '-PtargetRepo=<you>/official-images' to submitOfficialImage, so that the write path - the one part a dry run
  cannot exercise - can be rehearsed against a repository of your own rather than run for the first time during a
  release. A target you own needs no fork (GitHub does not let you fork your own repository), so the branch then lives
  in it and the Pull Request is opened inside it; a rehearsal waives the guards on what is reachable upstream, that
  being the point of it.
* Drop 'gradle/*.gradle' from the paths triggering the Docker Build workflow: none of those scripts takes part in the
  generation, so a change to one cannot change an image, and building the whole matrix for it costs 12 jobs for
  nothing. That they still configure and generate is what Gradlew Check verifies, now on Pull Requests too.
* Move the ports runAll publishes off 8080 (18081 and up, '-PbasePort' to move them), which is what smokeTest already
  does and for the same reason: 8080 is the port a developer's own instance holds, and binding it fails the boot for a
  reason that has nothing to do with the image.
* Parse the '-Pcycles' / '-Pvariants' selections once, through a 'parseList' helper, instead of repeating the same
  tokenize/trim/filter in four places.
* Only tell the release manager to regenerate the images when something actually moved, rather than after every update
  task run.
* Say in the README (and next to buildImage) that smokeTest and runAll overwrite the local image tag they build, the
  published official image of that version included, and document the new flags.
* Establish, rather than assume, that a Docker Build run describes the
  images being submitted. A commit that triggered no run of its own was
  taken to have changed no image, but a run GitHub has not created yet
  looks exactly the same over the API - and pushing then submitting a few
  seconds later is how a release goes - so the check could go green on
  images nothing had built. The fallback is now the latest run of the
  history being submitted, and it is accepted only when it contains the
  GitCommit of every cycle.
* Stop the two 'gh api' guards from failing open. Both allowed the call to
  fail and read the resulting empty output as "nothing found", so a
  rate-limited or unauthenticated 'gh' passed them silently - and for the
  open Pull Request one, passing means deleting the branch, which closes
  the Pull Request the guard exists to protect.
* Refuse to emit an empty GitCommit. A cycle directory that has never been
  committed has no last-touching commit and 'git log' says so by printing
  nothing, which submitted a 'GitCommit:' line with nothing on it. Adding
  a cycle leaves exactly such an untracked directory behind.
* Run the captured commands in the project directory instead of wherever
  the build process happens to sit, since all of them ask git about this
  repository.
* Drop the 'run ./gradlew' line that 'updateJDBC' logged whether or not
  anything had changed, and which repeated the one just above it when
  something had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vmassol
vmassol force-pushed the XDOCKER-18-automate-docker-images-release branch from 49f6b9b to a821df5 Compare August 26, 2026 07:15
vmassol and others added 6 commits August 26, 2026 09:46
* Refuse to generate a library file declaring the same tag twice. The tags a
  cycle contributes are derived from its version, so two cycles on the same
  version line both claim it: a stable cycle '18' pinned to 18.4.4 while cycle
  '18.4' is on 18.4.4 too - the state right after an intermediate LTS is
  declared, if the stable line has not moved past it yet - makes both emit
  '18.4', '18.4.4' and every per-variant form of them, which docker-library
  rejects. Caught at configuration time, where the tags are decided, rather
  than upstream in front of the docker-library maintainers.
* Read a 'merge-base --is-ancestor' that cannot answer as a no rather than as
  an error, in the Docker Build check. The runs are walked newest first, so a
  single commit missing from the clone (force-pushed away, never fetched) used
  to abort a release on a run that was only going to be skipped anyway. It
  cannot make the check pass on something unbuilt either, since it only ever
  removes a run from the candidates.
* Name the web container after the whole variant in the compose rewrites,
  which is how the template names it ('xwiki-${db}-tomcat-web' in a
  '<db>-<servlet>' directory is 'xwiki-<variant>-web'), instead of hardcoding
  '-tomcat-'. 'smokeTestVariants' deliberately assumes no servlet container,
  and this now agrees with it.
* Validate '-PbasePort' and '-PtargetRepo' instead of coercing them. Both are
  read at configuration time, so a missing or malformed value used to fail
  every task of the build with a message naming neither the property nor what
  it expected - and an invalid '-PtargetRepo' was taken for a rehearsal (it is
  not the upstream repository) and only failed deep in the write path.
* Encode the submitted library file from its UTF-8 bytes rather than from the
  platform default charset.
* Disconnect the smoke test's polling connection in a 'finally': the attempt
  that throws is the common case there, a boot answering nothing for minutes.
* Say in the README that 'stopAll' removes the volumes of the instances it
  stops, and therefore the wiki content created in them.
* Default 'stream' to running in the project directory, as 'capture' and
  'exitCode' already do. A process inherits the working directory of the JVM
  that starts it, which for a Gradle build is the daemon's and therefore not
  something a script gets to assume. Every command run through it passes one
  today, so this only ever matters for the next one written without.
* Validate the '-Pcycles' and '-Pvariants' entries against the cycles and
  variants that exist, the way '-PbasePort' and '-PtargetRepo' already validate
  theirs. Both ways of getting them wrong were silent: an empty value ('-Pcycles'
  with nothing after it) left an empty list, which every caller reads as "no
  selection" and therefore as "all of them" - the opposite of narrowing anything
  down - and a typo such as '-Pvariants=mysql' surfaced only later as a missing
  directory, naming a path rather than the property that named it. The message
  lists the available values, which is what whoever got it wrong is looking for.
* Establish the submission guards before '-PdryRun' returns rather than after
  it. "Am I ready to submit?" is what a dry run is reached for, and answering
  only "here is what the file would look like" left the release manager to
  discover a dirty tree, an unpushed HEAD or a red Docker Build run on the real
  invocation instead. A dry run reports them and still succeeds where a real one
  fails on them, since the natural moment to preview the file is right after
  './gradlew release', with the regenerated tree not yet committed.
* Collect those reasons instead of throwing on the first, so that one run names
  everything that is in the way. 'checkImagesBuild' returns its reason rather
  than raising it, for the same purpose.
* Check for a Pull Request still open from the working branch in a rehearsal
  too. Unlike the guards on what is reachable upstream, that one is about not
  destroying something on the target, which a target of your own has just as
  much of.
* Say in the Docker Build check why looking at the most recent runs alone is
  enough for the two questions it asks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Regenerate the images in 'submitOfficialImage', so that a 'versions.json' pushed without its regenerated
  directories is reported as a dirty tree instead of publishing tags the pushed Dockerfiles do not produce.
* Refuse in 'updateXWiki' a bump that would put two cycles on the same version line, which the duplicate tag check
  rejects at configuration time and would therefore leave every later './gradlew' failing.
* Boot in 'smokeTest' the cycles 'generate' rewrote as well as the ones whose versions moved, so that a 'template/'
  edit is smoke-tested by 'release' too.
* Add '-Pfresh' to 'runAll', to remove each instance and its volumes before starting it.
* Validate the boolean command line flags, '-PdryRun=false' having read as true.
* Drop a comment justifying itself by an earlier revision, and a transient issue key from another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Mount the database init script from the path it is generated at ('./mysql/init.sql' and './mariadb/init.sql'
  rather than './init.sql'). Docker created a directory for the missing source, which the database images then
  failed to read as a SQL script, so the db container of every mysql and mariadb variant exited at boot and XWiki
  never came up. Broken since the compose files were reworked; only the postgres variants, which mount no script,
  were unaffected.
* Fail generation when a generated compose file bind mounts a relative path that is not a file next to it, so that
  the above cannot come back unnoticed: the image build never runs the compose file and 'smokeTest' boots one
  variant per cycle by default, so nothing else covers the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Replace the raw NUL byte separating the path from the content in
  'directorySnapshot' with a '\n' escape. The control byte in the source made
  git treat build.gradle as binary, so the script holding the whole generation
  logic was shown as "Binary file not shown" on a Pull Request and could only be
  merged by taking one whole side instead of as text.
* Drop 'runAll'/'stopAll' and their '-PbasePort'/'-Pfresh' flags: they boot-checked
  nothing that 'docker compose up' in a variant directory does not already do.
* Drop the Docker Build workflow check of 'submitOfficialImage' and its
  '-PskipCiCheck' waiver: docker-library builds every image on its own Pull Request
  before merging it, unwaivably, so this only duplicated that gate - and its own
  waiver existed because the workflow fails on transient downloads often enough.
* Drop the '-PtargetRepo' rehearsal mode: it was scaffolding for developing the
  write path, and it left two modes to read through the whole submission flow.
* Drop the configuration-time duplicate tag check: 'updateXWiki' already refuses to
  put two cycles on the same version line, and it did so at configuration time,
  where a hand-edited 'versions.json' broke plain './gradlew generate' too.
* Drop the generated-directory content snapshots: they read every generated file
  twice on every './gradlew' to pick the cycles a 'template/' edit changed, which
  'smokeTest -Pcycles=...' asks for directly.
* Have '-PdryRun' stop before the submission guards rather than collect their
  verdicts: the guards are about not writing something wrong, and a dry run writes
  nothing, so a real run now throws on the first problem instead.
* Fail 'updateLibreOffice' when the sha256 published for the pinned version no
  longer matches the pinned one, instead of adopting the new hash and reporting it
  as an update: different bytes under a version already published is what pinning a
  hash exists to detect.
* Simplify 'compareVersions' and drop the now unused 'exitCode' helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vmassol
vmassol merged commit 187b5c3 into master Aug 26, 2026
15 checks passed
@vmassol
vmassol deleted the XDOCKER-18-automate-docker-images-release branch August 26, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants