Skip to content

Assemble a release from four runners instead of four machines - #21

Merged
ShawnChen-Sirius merged 6 commits into
mainfrom
fix/issue-15-release-workflow
Sep 9, 2026
Merged

ShawnChen-Sirius merged 6 commits into
mainfrom
fix/issue-15-release-workflow

Conversation

@ShawnChen-Sirius

@ShawnChen-Sirius ShawnChen-Sirius commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #15. Refs #10 — moves it as far as it can move without publishing anything.

Based on v1/release-engineering (#13), not main, because the release profile and the
unstaged-native refusal it builds on live there.

The workflow

.github/workflows/release.yml, triggered by push: tags: ["v*"] or workflow_dispatch
(inputs: channel = snapshot|release, default snapshot; dry_run, default true).

  1. preflight — reads the version from Maven, resolves the channel, refuses a SNAPSHOT POM on
    the release channel and a non-SNAPSHOT POM on the snapshot channel, refuses a tag that
    disagrees with the POMs, and checks via gh api that the build workflow concluded success
    for this exact SHA. Hard for a release, advisory for a snapshot, because build filters on
    branches and a tag push never runs it.
  2. stage — matrix of four (ubuntu-22.04, ubuntu-22.04-arm, macos-15, macos-15-intel).
    Reuses build's engine cache key, stages with build-native.sh /
    build-native-in-container.sh, runs the integration suite against the freshly staged bytes on
    the floor JDK, uploads target/native/ as native-<platform> with if-no-files-found: error,
    and debug symbols for 90 days.
  3. deploy — environment: maven-central as the first human gate. setup-java writes the
    central server entry and imports the GPG key; downloads all four artifacts, restores the
    executable bit, asserts each platform is complete and not engine.source=local, re-verifies
    sha256sums.txt after the artifact round trip, prints df -h, then mvn -Prelease deploy (or
    verify for a dry run). autoPublish=false is unchanged, so the bundle waits in the portal —
    the second gate.

The three decisions the issue asked for

  • Version bump lives in git, not in CI. The workflow never runs versions:set; it verifies
    tag ≡ POM. versions:set in CI publishes bytes corresponding to no commit, so git show <tag>
    cannot say what shipped and a re-run of the same tag can produce different POMs.
  • Snapshots come from the same workflow, and are the default for manual runs. It is the only
    way to move Release gate: nothing has consumed the driver as a published artifact #10, and running the release path for every snapshot is what makes it already-tested
    when a real release needs it. Confirmed from the plugin descriptor that
    central-publishing-maven-plugin 0.11.0 has a centralSnapshotsUrl parameter, so SNAPSHOT
    deploys route to the snapshot repository instead of building a portal bundle.
  • Reproducibility: measured, and then not chased. Two clean macos-aarch64 builds are not
    byte-identical, but the gap is fully characterised: same length, 116 differing bytes — 16 of
    LC_UUID, 4 that are the four N_OSO debug-map timestamps (one per .cpp; .o mtimes
    1788931405 vs 1788931408), and ~96 of ad-hoc code signature covering those. __text,
    __cstring, __const and __data are byte-identical; the nlist entries were decoded to
    confirm rather than assumed. Content reproduces, metadata does not. The shim is ~130 KB of a
    ~326 MB package whose engine half is SHA-256-pinned, so the workflow records hashes instead of
    pursuing determinism.

Side finding, reported and not fixed: those N_OSO strings embed the builder's absolute
source and object paths in the shipped macOS dylib — LC_ALL=C grep '/Users/…' libchdb_java_jni.dylib
hits four times. Linux strips its debug info via objcopy --strip-debug; macOS does not, and
build-native.sh's existing absolute-path check only inspects load commands. Fixing it means
strip -S plus a deterministic UUID and re-testing dylib loading — real risk, low payoff given
releases come from CI where the path is /Users/runner/work/…. It does mean a locally staged
package carries the builder's username.

scripts/verify-consumer.sh — #10 as an executable check

mvn deploy -DaltDeploymentRepository=…file:// into a temp dir, then a consumer project outside
the checkout with its own empty local repository, importing the BOM and declaring exactly one
platform package with no version. Wired into build.yml's native job on all four platforms.
What it found:

  • BOM plus transitive chdb-jdbc already worked. Now asserted rather than assumed.
  • A snapshot deploy writes chdb-jdbc-26.7.0.1-20260909.053707-1.jar plus
    maven-metadata.xml
    , not -SNAPSHOT.jar. Without that metadata a consumer 404s on a version
    that is demonstrably present. No reactor test touches that indirection.
  • Real defect: "wrong platform package" was indistinguishable from "declared none."
    NativePackage.find only looks for the current platform's resources, so a foreign JAR looked
    like no JAR — someone with chdb-native-linux-x86_64-gnu in their POM was told to add a
    platform package. The loader now names the chdb-native-* packages on the classpath when they
    are for another machine. Reproduced without staging a second platform by overriding
    -Dos.arch, which is the branch Platform.detect() actually takes.
  • Real defect: <waitUntilValidated> is not a parameter of central-publishing-maven-plugin
    0.11.0.
    The mojo declares waitUntil / waitMaxTime / waitPollingInterval /
    waitForPublishCompletion. Verified empirically that Maven drops unknown configuration
    elements rather than failing, so it was a no-op. Replaced with <waitUntil>VALIDATED</waitUntil>
    — also the default, so the behaviour was right by accident.
  • Not coverable locally: whether a ~100–166 MB artifact survives a real network transfer.
    file:// is a copy.

Verified locally vs CI-only

Ran for real: actionlint on both workflows (only the four intentional $MAVEN_ARGS SC2086s,
matching build.yml's existing eight); shellcheck clean on the new script; the macos-aarch64
stage twice; the release profile's unstaged refusal, with the right message, and its staged pass;
the deploy job's completeness and sha256sum -c loop body; the preflight version/tag gate across
all six branches; 150 unit tests; 92 integration tests; verify-consumer.sh end to end.

CI-only: the matrix itself (especially macos-15-intel), artifact upload/download of ~1.3 GB,
executable-bit restoration, setup-java's GPG import, the gh api build-conclusion gate,
environment: maven-central approval, and any actual deploy.

Nothing was deployed to any remote. docs/release-readiness.md carries the remaining eight steps
in order, including what is still genuinely blocked on the org.chdb namespace claim.

🤖 Generated with Claude Code

Note

Assemble release from four platform runners via release.yml workflow

  • Adds .github/workflows/release.yml with preflight, four-platform staging matrix, and a deploy job that downloads staged artifacts, reconstructs native directories, and runs the release Maven profile to sign and publish
  • Preflight validates channel/version rules, checks the release tag points at the built commit via scripts/check-release-tag.sh, and requires the build workflow to have succeeded for releases
  • Adds scripts/verify-consumer.sh invocation to .github/workflows/build.yml so each platform verifies repository consumption of driver, BOM, POMs, and native libraries
  • Improves NativeLibraryLoader failure diagnostics to list foreign-platform chdb-native-* packages found on the classpath, using new NativePackage.foreignPackages and Platform.resourcePrefixFor helpers
  • Updates pom.xml to use waitUntil set to VALIDATED for the central publishing plugin, and rewrites release docs to reflect the workflow-based release path
  • Risk: the deploy job in release.yml reconstructs native module directories from uploaded artifacts and sets executable permissions; a missing or corrupted platform artifact fails the completeness gate in .github/workflows/release.yml deploy job

Macroscope summarized af890b8.

Comment thread docs/release-readiness.md Outdated
Comment thread docs/release-readiness.md Outdated
Comment thread docs/publishing.md
Comment thread docs/release-readiness.md Outdated
Comment thread docs/release-readiness.md Outdated
Comment thread .github/workflows/release.yml Outdated
@ShawnChen-Sirius
ShawnChen-Sirius force-pushed the fix/issue-15-release-workflow branch from f372fba to c846c9c Compare September 9, 2026 07:44
Comment thread chdb-jdbc/src/main/java/org/chdb/jdbc/ShutdownCleanup.java
Comment thread docs/release-readiness.md Outdated
@ShawnChen-Sirius
ShawnChen-Sirius changed the base branch from v1/release-engineering to main September 9, 2026 07:51
Comment thread docs/native-loading.md Outdated
Comment thread scripts/verify-consumer.sh Outdated
Comment thread .github/workflows/release.yml Outdated
ShawnChen-Sirius and others added 5 commits September 9, 2026 20:11
A release needs all four platform packages staged in one working directory, and no
machine can produce them: build-native.sh refuses to cross-build on purpose, and
macos-x86_64 needs an Intel Mac. `build` already builds all four on their own runners
and throws the staged trees away at the end of every run. release.yml keeps them.

Three things the issue asked to decide, decided:

The version lives in git. The workflow never runs versions:set; a release is a commit
carrying the release version plus a tag on that commit, and preflight refuses to run
if the two disagree. versions:set in CI publishes bytes that correspond to no commit,
so `git show <tag>` cannot say what went out and a re-run of the same tag can produce
different POMs.

The same workflow publishes snapshots, and that is the default for a manual run. It is
the only way to move issue #10, and exercising the release path on every snapshot is
what makes it already-tested when a release depends on it.

Reproducibility was measured rather than left open. Two clean builds of the
macos-aarch64 shim from the same source on the same machine differ in 116 bytes of a
same-length file: 16 bytes of LC_UUID, four bytes holding the per-translation-unit
N_OSO debug-map timestamps (the .o mtimes, three seconds apart), and ~96 bytes of
ad-hoc code signature covering those. __text, __cstring, __const and __data are
byte-identical. So the shim's content reproduces and only build metadata does not,
and since the shim is ~130 KB of a ~326 MB package whose engine half is pinned by
SHA-256, chasing the rest buys nothing a consumer can act on. The workflow records
what it shipped instead: every staged library's SHA-256 in the job summary.

Also replaces the release profile's <waitUntilValidated> with <waitUntil>VALIDATED</waitUntil>.
central-publishing-maven-plugin 0.11.0 has no waitUntilValidated parameter -- the mojo
declares waitUntil, waitMaxTime, waitPollingInterval and waitForPublishCompletion --
and Maven drops configuration elements a mojo does not declare rather than failing on
them, so it had been doing nothing. VALIDATED is also the default, so the behaviour was
right by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every test to date builds the driver in the same reactor, where the POMs come from
target/ and the native libraries are read from a directory beside them. Issue #10's
gate is that nothing has ever depended on this as a published artifact, and the value
of the gate is the list of things that only fail when consumed. That list did not
exist. scripts/verify-consumer.sh produces it without publishing anything: it deploys
to a throwaway file:// repository, builds a consumer project outside the checkout with
its own empty local repository, imports the BOM, declares exactly one platform package
with no version, and runs a query.

What it found on the first run:

The BOM and the transitive dependency were already fine, and are now asserted rather
than assumed.

A snapshot deploy does not write the filenames a reactor build uses. It writes
chdb-jdbc-26.7.0.1-20260909.053707-1.jar and a maven-metadata.xml mapping -SNAPSHOT
onto that timestamp; without the metadata a consumer gets a 404 for a version that is
demonstrably there. Nothing exercised that indirection before.

"Declared the wrong platform package" was indistinguishable from "declared none". Both
produced the same message: three locations searched, none found, here is the coordinate
to add. The advice is right either way, but somebody looking at
chdb-native-linux-x86_64-gnu in their own POM while being told to add a platform
package has no way to work out what they did wrong. The loader now names the
chdb-native-* packages that are on the classpath when they are for another machine.
NativePackage.find only ever looks for the current platform's resources, so a foreign
JAR was indistinguishable from no JAR; foreignPackages() is how the two are told apart,
and it is diagnostics only -- nothing there makes a foreign package loadable.

Wired into `build` on every platform, because a verification nobody runs is a
verification that rots. The one question it cannot answer is whether a ~100 MB artifact
survives a real network transfer: a file:// deploy is a copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main` already has docs/release-readiness.md, from #6. This branch was cut from
v1/release-engineering, where it did not exist, so it grew a second copy. Folded
rather than replaced: 129 of main's 253 lines are kept verbatim, including the
Track 1/2/3 structure, the licence ownership table, the untested-gap table and
"What is already covered". What is added is the eight-step running order and what
the consumption gate actually found.

Also corrects four places where main's copy had come to disagree with
docs/publishing.md, which is the reference for all of them. Two copies of a number
is how this kind of drift survives, so this file now keeps none of them and points
there instead:

- §1.1 listed sources jars, javadoc jars, GPG signatures, <scm> and <developers>
  as absent. All five shipped in #13, which is in main.
- §1.3 offered io.github.chdb-io as a namespace fallback. Sonatype does not
  register io.github.<organisation> automatically, so there is no fallback and DNS
  access to chdb.org is a hard prerequisite.
- §1.4 said the publishing limits are compared as a rolling three-month average and
  concluded that a 2-3 month cadence divides the release size by two or three.
  Sonatype is explicit that each metric resets monthly and is not averaged; a slower
  cadence does not make a release month cheaper, it makes the other months empty.
- §2.2 counted 156 contrib submodules by scanning licence files and found seven LGPL
  and eight GPL. Querying system.licenses in the binary we actually ship finds 968
  components and twelve copyleft ones in two families. A filename scan calls every
  dual-licensed component GPL and describes a checkout rather than an artifact.

Two review findings on the earlier copy, both about the soak test, are addressed
here because they are the same paragraph: the soak has to be concurrent, since the
milestone it discharges is that cancel, timeout, early close and cascading close
"leak nothing and never deadlock" and a deadlock cannot occur in one thread; and it
blocks a release, so it is no longer in a table the surrounding prose calls
parallel and non-blocking. Track 3 is now split into §3.1 blocking and §3.2 not.

The test count is measured rather than carried over: 257, from 150 unit and 107
integration on macos-aarch64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…elease calling itself a dry run

Two review findings on the release workflow, one of which had a back door in it.

The tag requirement was enforced on one path only. `preflight` compared the tag name
to the POM version out of `github.ref_name`, which is a tag on a push and a *branch*
on a workflow_dispatch. So `channel=release` with `dry_run=false`, run by hand,
accepted any non-SNAPSHOT POM and went straight to `mvn -Prelease deploy` with no tag
anywhere -- the same "bytes on Central that correspond to no commit" outcome the design
claims to prevent, reached by the other door. docs/publishing.md promised the guarantee
without the qualifier, so the documentation was wrong rather than the workflow being
merely incomplete.

Fixed in the workflow rather than by downgrading the doc to describe only the push
path. The rule is worth having because `git show <tag>` should be able to say what was
published; a rule with a manual bypass does not give anyone that, and the bypass is the
path a person reaches for under time pressure. A new preflight step, gated on
`channel == 'release'`, looks up `v<POM version>` through the API and refuses unless it
exists and points at the commit being built.

It dereferences annotated tags, which is not incidental: `refs/tags/<x>` for a
`git tag -a` names a tag object, not a commit, so without the extra hop the gate would
have rejected every release cut the way our own runbook recommends. Resolved through the
API rather than git because actions/checkout fetches the triggering ref, not the tag
namespace.

The other finding: the deploy summary's heading used ${DRY_RUN:+, dry run: $DRY_RUN},
which tests for non-empty rather than for true, so a real release printed "dry run:
false" in its own heading. The reader of that summary is deciding whether to confirm an
irreversible upload, so it now says which of the two things happened and nothing else.

Both gates exercised locally against the same shell text: the version/channel and tag
steps over seven cases including "manual release, no tag" (now rejected) and both tag
shapes, and the heading over both values of dry_run.

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

Four review findings, one of which reopened the hole the previous commit closed.

THE TAG WINDOW. `preflight` checked that v<version> names the commit being built, and
then the run went on to four-platform staging -- ten minutes and more -- and the
`maven-central` environment's approval gate, which can hold it for as long as it takes
somebody to look. A tag can be force-moved or deleted inside that window, and nothing
would have noticed: the run would publish one commit under a version whose tag now
names another, permanently, because a Central release cannot be unpublished. Checking
once at the start describes the start, not the upload.

The check is now scripts/check-release-tag.sh, called twice: in `preflight`, where a
mistake costs nothing, and in `deploy` as the last step before Maven runs. Extracted to
a script rather than duplicated inline so that the two call sites cannot drift and so it
can be exercised locally against a stubbed `gh`.

Two checks shrink the window to the seconds between the second one and the upload; they
do not close it. Only an immutable tag does, so docs/release-readiness.md step 8 now
carries the tag-protection ruleset as the organisational half of the same guarantee,
with a note that the workflow cannot verify it exists.

Writing the harness found a second defect in the new step: `script | tee -a
$GITHUB_STEP_SUMMARY` returns tee's status, so without `set -o pipefail` the gate could
never fail. Measured both ways -- with pipefail exit 1, without it exit 0.

FINDING BY COORDINATE, NOT BY FILE NAME. The classpath assertion matched
"chdb-jdbc-${VERSION}.jar". The review argued that a snapshot resolves under a
timestamped name so the script would exit before reaching the two error paths
docs/native-loading.md says it reproduces. That is not what happens -- Maven's local
repository holds *both* names, and build-classpath emits the base-version one, so the
script does reach them, verified end to end on 26.7.0.1-SNAPSHOT and again on
26.7.2-rc.2.1-SNAPSHOT. But depending on which of two names a resolver happens to emit
is a bad thing for this assertion to depend on, and the failure mode is the one the
review described: it would not fail, it would skip both error paths and still report
that all consumer checks passed. Now located by org/chdb/chdb-jdbc/, which cannot
drift, with "exactly one" and "non-empty" assertions and the classpath printed on
failure.

LOGS SURVIVE FAILURE. The EXIT trap deleted $WORK, including the deploy.log the die
message had just told the reader to go and look at. Kept on failure with the paths
printed, removed on success. Verified by breaking the build on purpose: the retained log
had the whole 81-line Maven failure.

THE PUBLISHING-LIMIT STEP was complete once Sonatype "replied". A refusal is a reply,
and so is a request for more detail; either leaves the release liable to be rejected at
upload. Now complete only when the numbers are inside the thresholds or an exception
covers the numbers we actually publish.

Test count in that file re-measured on this baseline: 266, from 156 unit and 110
integration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ShawnChen-Sirius
ShawnChen-Sirius force-pushed the fix/issue-15-release-workflow branch from c846c9c to bce4034 Compare September 9, 2026 08:22
Comment thread scripts/verify-consumer.sh
A <repository> in a POM is added to the Super POM's `central`, not substituted for it, so
the consumer project had two places an org.chdb artifact could come from. That defeats the
whole script. Once we publish, a broken artifact just deployed to the throwaway repository
could be answered for by a good one on Central, and the check would report that consuming
the driver from a repository works -- which is the single thing it exists to find out.

Simulated locally rather than argued about: a throwaway repository with chdb-jdbc removed,
a second repository under the id `central` that has it, standing in for Central holding a
previously published release.

  central live      BUILD SUCCESS, provenance chdb-jdbc-...jar>central=
  central disabled  BUILD FAILURE, "Could not find artifact org.chdb:chdb-jdbc:jar:
                    26.7.2-rc.2.1-SNAPSHOT in chdb-verify (file:///tmp/mask/broken)"

So `central` is now declared with both releases and snapshots disabled, and a URL that
names nothing, so re-enabling it by accident fails rather than reaching the network.

Under its own id rather than by renaming our block to `central`, which is the other way to
do it. Reusing the id would make the file repository indistinguishable from the real one in
_remote.repositories, and it collides with the plugin repositories, which still carry the
id `central` pointing at the real Central -- that bookkeeping is keyed by id, not by URL, so
an artifact arriving through the plugin path would afterwards look available from `central`
to project-scope resolution too.

Keeping the id distinct buys a second, independent check, because the configuration should
not be trusted just for being written down: every downloaded org.chdb file's recorded
provenance is now read back out of _remote.repositories and has to say `chdb-verify`.
Exercised both ways -- it rejects the masked repository above ("served by a repository other
than the throwaway one ... >central=") and accepts a correctly isolated one.

That assertion is also what covers the case configuration cannot: a <mirrorOf>*</mirrorOf>
in the caller's settings.xml intercepts even a file repository. The script does not suppress
settings.xml, because in the environments that have such a mirror it is also the only route
to Central for Maven's own plugins; instead the check fails there, loudly, with the -s
workaround in the message. Failing beats reporting a pass it cannot justify.

pluginRepositories are left inherited on purpose and the header now says why: plugin
repositories are consulted for plugins and their dependencies, org.chdb is neither, and
vendoring surefire and the dependency plugin into the throwaway repository to close a hole
nothing can come through would only make the script unrunnable. The header now states the
whole isolation model -- what is isolated, what is not, and which check enforces which --
since this finding was the second in two rounds to turn on exactly that question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ShawnChen-Sirius
ShawnChen-Sirius merged commit 83417b9 into main Sep 9, 2026
29 checks passed
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.

No way to assemble a four-platform release: staging is manual and CI discards it

1 participant