Skip to content

Publish a rolling nightly prerelease from main - #824

Merged
yogthos merged 3 commits into
jolt-lang:mainfrom
burinc:feat/nightly-prerelease
Sep 2, 2026
Merged

yogthos merged 3 commits into
jolt-lang:mainfrom
burinc:feat/nightly-prerelease

Conversation

@burinc

@burinc burinc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

A rolling prerelease built from main daily, so a downstream project can test against unreleased jolt without building it from source.

Why

A break lands on main and nobody downstream sees it until the next tag. jolt moved 45 commits in about 30 hours between v0.8.0 and this branch's base — that is a long window in which a regression is only visible to whoever builds from source.

Two recent breaks make the case concretely. ffi/write's argument order moved in #802, and the two spellings are indistinguishable at runtime because an offset and a value are both integers, so an unmigrated call writes to the wrong place and reports nothing. And #813's provider-declaration change means a library that has not declared :jolt/provides fails to resolve its classes — where a consuming project's namespace is dropped from a test suite that then exits 0. Both are the kind of thing a downstream suite catches immediately and a release cadence surfaces late.

What it does

schedule at 17:00 UTC builds main and publishes it as a draft prerelease at the rolling tag vnightly, replaced daily.

The tag name is not arbitrary. jolt's own install script does tag="v${version#v}" and filename="jolt-${tag}-${target}.tar.gz", so vnightly makes --version nightly resolve to jolt-vnightly-<target>.tar.gz — a nightly is installable with the shipped installer, unchanged.

It publishes as a draft first, exactly as a real release does and for the same reason: a nightly that fails the gates stays a draft nobody can install, and the previous nightly remains the newest thing available.

The tagged-release path is untouched. publish and homebrew are already guarded to refs/tags, so a scheduled run never reaches them.

releases/latest is unaffected — verified, not assumed

The nightly sets prerelease: true, and GitHub's releases/latest returns the most recent non-prerelease, non-draft release. Checked empirically rather than from the docs, against a repo that actually publishes both:

neovim/neovim releases:
  nightly   prerelease=true   published=2026-09-02   <- newest
  v0.12.5   prerelease=false  published=2026-08-23

neovim/neovim releases/latest:
  v0.12.5                                            <- the stable one

This matters downstream rather than in the abstract: at least one project in this ecosystem installs jolt in CI via a script that resolves releases/latest. A nightly that changed what that returns would silently move every such CI job onto an unreleased build. It does not.

Second commit: making it testable before it merges

Two gaps that made the feature impossible to try.

It was reachable only from the schedule, so the only way to exercise it was to merge and wait for cron. workflow_dispatch now takes a nightly boolean, and a single JOLT_NIGHTLY expression feeds every guard so they cannot drift apart.

The install script hardcoded jolt-lang/jolt, so a nightly built on a fork was unreachable by the very command the release notes tell people to run — --version nightly looked in the upstream repo, where a fork's rolling release does not exist. It now honours JOLT_REPO and a --repo flag, defaulting exactly as before:

JOLT_REPO=someone/jolt install --version nightly

That is useful beyond forks: it is what lets anyone test a candidate build without it first being published upstream, which is the same reason the nightly exists.

Notes for review

The cron time, the vnightly tag name, and whether a rolling prerelease is wanted at all are the maintainer's calls — happy to change any of them or to drop this if the cadence is not what you want.

Not verified: I have not observed a scheduled run complete end to end, since that requires the workflow to be on a branch the schedule reaches. workflow_dispatch in the second commit exists precisely so that can be tried before merging.

Downstream projects can currently only test against tagged releases. The
install script takes --version and resolves it to releases/download/v<x>/..., so
an unreleased commit cannot be installed at all, and there are no prereleases.
Anyone wanting to know whether jolt's main still builds their project has to
build jolt from source against Chez on every platform they care about, which is
enough work that in practice nobody does it, and a breaking change is found
after the release rather than before it.

So: a daily scheduled build of main, published as a rolling `vnightly`
prerelease.

The tag name is chosen to need no installer change. install does
`tag="v${version#v}"` and `filename="jolt-${tag}-${target}.tar.gz"`, so tag
`vnightly` means `--version nightly` already resolves to
`jolt-vnightly-<target>.tar.gz`:

    curl -sSL .../install | bash -s -- --version nightly

The tagged-release path is untouched. `publish` and `homebrew` were already
guarded to refs/tags, so a scheduled run never reaches either, and the only
change to an existing line is Package taking its name from a new JOLT_TAG,
which is github.ref_name for a tag push exactly as before.

Nightlies go through the same gates as a release: they upload as a DRAFT from
the build matrix, and publish-nightly un-drafts only after libraries,
libraries-db, examples and bench all pass. A nightly that breaks them stays
invisible and the previous one remains newest.

Two details worth stating:

  - --latest=false when publishing, so a prerelease never displaces the newest
    real release. A bare `install` and the Homebrew tap both read the latest
    PUBLISHED release, and neither should ever pick up a nightly.

  - the vnightly tag is moved with an explicit git-ref PATCH.
    softprops/action-gh-release creates the tag at the current sha when it does
    not exist but leaves it alone when it does, so without this the tag drifts
    behind the assets attached to it.

JOLT_VERSION is deliberately left as-is, which means it stays empty on a
scheduled run and the binary self-reports its git-describe version
(v0.8.0-N-g<sha>) rather than the string "nightly". That is more useful in a
bug report than a label that cannot identify the commit.

Verified: the workflow parses as YAML and the new run body passes bash -n.
Not verified end to end, because that needs a scheduled run on the real repo.
Two gaps in the commit below, both of which made the feature impossible to try
before merging it.

The nightly path was reachable only from the schedule, so the single way to
exercise it was to merge and wait for the cron. workflow_dispatch now takes a
`nightly` boolean, and one JOLT_NIGHTLY expression feeds every guard so they
cannot drift apart.

The install script hardcoded jolt-lang/jolt, so a nightly built on a fork was
unreachable by the very command the release notes tell people to run: --version
nightly looks in the upstream repo, where a fork's rolling release does not
exist. It now honours JOLT_REPO and a --repo flag, defaulting exactly as before.

That matters beyond forks. It is what lets anyone test a candidate build of jolt
without it first being published upstream, which is the same reason this
nightly exists.

    JOLT_REPO=someone/jolt install --version nightly
    install --repo someone/jolt --version nightly

Verified against a real release, all three paths installing v0.8.0: the env
override, the flag, and the untouched default.
@burinc

burinc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Exercised this via workflow_dispatch on a fork, and it immediately found something that makes the case better than the PR description did.

The nightly caught three first-party libraries broken on current main

A dispatch run against 5e0e7a96 failed three library gates, all with the same root cause:

library db             No dependency provides Locale — RFC 0014
library db (postgres)  No dependency provides Locale — RFC 0014
library transit-jolt   No dependency provides java.time.format.DateTimeFormatter — RFC 0014

498006c0 (#813) moved host-class provision to per-library declaration, and jolt-lang/time has not declared its :jolt/provides. Every library that formats or zones a date fails to resolve. Reproduced locally on v0.8.0-45-g5e0e7a96:

$ jolt -e '(println (java.time.ZoneId/systemDefault))'
Unhandled exception: No dependency provides java.time.ZoneId — ... (RFC 0014)

Builds, examples and benchmarks all passed in the same run. Only the library gates caught it.

Why that is an argument for this PR specifically

The library gates live only in release.yml, which triggers on push: tags: v* and workflow_dispatch. tests.yml runs on every push and PR and contains no library gates.

So library breakage on main is invisible until a tag is cut — at which point the release itself fails its own gates, during a release. This PR's schedule trigger is what moves that discovery from release time to the next morning.

That is not a hypothetical benefit argued from first principles. It is the first thing the feature did when run.

The draft gate also worked, on a failing run

The failing run left vnightly as a draft with its 8 assets attached and unpublished — exactly the behaviour the PR describes. A nightly that fails the gates stays uninstallable, and whatever was previously newest remains so. Worth noting the failure path is now exercised, not just the happy one.

What I have not shown

I have not completed an end-to-end install of a published nightly, because the gates fail on current main and the draft gate therefore — correctly — refuses to publish one. That test needs either a green main or the jolt-lang/time declaration landing first.

So: the build, packaging, draft-upload and gate-refusal paths are exercised. The publish and install paths are not yet.

Separately

The jolt-lang/time gap looks like the thing to fix regardless of this PR. Its own statics! calls in src/jolt/time/{zones,zoned,fmt}.clj name what it should declare, and host-static.ss's comment already says everything naming or formatting a zone belongs to the library. Happy to open that on jolt-lang/time if useful.

One symptom there is worth flagging on its own: a consuming project's namespace that touches java.time is dropped from its test suite rather than failing it, and the runner exits 0. A downstream suite went from 29 tests / 78 assertions to 26 / 67, green. The only tell was the count.

@burinc

burinc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my comment above. I wrote that jolt-lang/time has not declared its :jolt/provides. That is wrong — it declared them on 2026-09-01 in 33c5e983 ("Declare the java.time classes jolt.time provides", #11), covering Locale, DateTimeFormatter, ZoneId and the rest. I read a local checkout that was four weeks stale instead of the remote. Apologies.

The library failures are real, and the cause is one step over.

jolt-lang/db and jolt-lang/transit-jolt both pin time at 70dfb798 (2026-07-29), which predates the declaration:

pinned:    70dfb798  2026-07-29  Declare the two forward-referenced vars (#3)
           -> no :jolt/provides at that tree, verified
declares:  33c5e983  2026-09-01  Declare the java.time classes jolt.time provides (#11)

So they resolve a time from before the migration and get no provider for Locale or DateTimeFormatter. library time passed in the same run, because the gate checks out that repo's default branch, which has the declaration.

jolt-lang/db's deps.edn even carries the comment "jolt-lang/time is needed even though nothing here asks for java.time" — the dependency is there precisely to supply the classes, and the pin is what defeats it.

The fix is a pin bump in each consumer rather than anything in time. transit-jolt has not been touched since 2026-07-29 and also declares no :jolt/provides of its own.

What this does not change, and arguably sharpens.

The library gates still caught a real break that a push to main cannot surface, because those gates live only in release.yml behind push: tags: v* and workflow_dispatch, while tests.yml has none of them. A stale transitive pin is exactly the kind of breakage nobody discovers until a release runs — and it is arguably a better argument for a nightly than a missing declaration would have been, since a declaration gets fixed once and a stale pin can reappear in any consumer at any time.

It also makes the case for running the library matrix daily rather than only at tag time, independently of whether the nightly artifact itself is wanted.

And a correction to my own claim about what I verified. My local reproduction of No dependency provides java.time.ZoneId was run against a stale checkout of time as well, so it demonstrated the same pin-shaped problem rather than a missing upstream declaration. The CI failures stand on their own; that local repro does not add to them.

The nightly step in the build matrix uploaded to the vnightly release per
platform row. softprops looks that release up by tag, so from the second night
on it finds yesterday's PUBLISHED nightly and swaps its assets out one platform
at a time, an hour before the gates run; a red gate left the broken build live.
Now the rows hand their assets to publish-nightly as artifacts and that job is
the only writer: it stages a draft with every asset, deletes the old release,
publishes under the tag and moves the tag. A red gate leaves the previous
nightly untouched, and the uninstallable window is one API call.

A meta job decides what a run is (release, nightly, dry-run) and the version
the binary bakes. Job-level if: can't read env, so the guards were going to be
spelled out per job; and the version needs tags plus history that the build
rows' shallow checkout doesn't have, so a nightly reported a bare short sha,
which :jolt/min-version reads as a version when it starts with a digit (a sha
starting with 0 fails every floor). An unchanged main is not rebuilt.

tools/version.sh is now the one place git describe runs (bin/jolt,
build-jolt.ss, the workflow), against release tags only: vnightly is the
nearest tag from main, so every clone that fetched it would have reported
"vnightly". Gated by make versionsmoke.

Also: the push trigger is v[0-9]* so the rolling tag can't enter the release
path, a nightly only publishes from main, and the README had the install
directory wrong (~/.local/bin for non-root, not /usr/local/bin).

Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
@yogthos

yogthos commented Sep 2, 2026

Copy link
Copy Markdown
Member

Merged with a fix commit on top (46eadbb); its message has the details. Short version of what the review found: softprops/action-gh-release v2 looks the release up by tag, so from the second night on the matrix rows would have edited the live published nightly in place before the gates ran; the build rows' shallow checkout has no tags, so the nightly's binary would have reported a bare short sha, which :jolt/min-version reads as a version when it starts with a digit; and a vnightly tag at main's head is the nearest tag from main, so every clone that fetched it would have reported jolt --version as vnightly. The nightly is now published only by publish-nightly after the gates (stage draft, delete old, publish, move tag), a meta job decides the run kind and version once, and tools/version.sh is the single git describe. Thanks for the PR and the write-up, both made the review straightforward.

@yogthos
yogthos merged commit b083c7d into jolt-lang:main Sep 2, 2026
3 checks passed
burinc added a commit to jlt-commons/ci-builds that referenced this pull request Sep 2, 2026
Both files asserted that jolt ships no prereleases or nightlies, and that
testing main would mean building it from source against Chez on two operating
systems. That was true when written on 2026-09-02 at 08:35Z. jolt-lang/jolt#824
merged the same day and the first vnightly published at 19:45Z, about eleven
hours later. v0.8.1's announcement confirms it: "main getting built every night
as a rolling prerelease, so you can test unreleased jolt without building it
yourself."

The old text even predicted its own fix, saying a rolling dev release would
make this a one-line change. It is smaller than that: every project already
takes a jolt-version input, so `-f jolt-version=nightly` is the whole feature.
Run 33691047983 did it, all five green, each log showing "Successfully
installed jolt vnightly".

Nightly is still NOT in the matrix, and the new text says why, because the
reason is not cost. jolt publishes the nightly by staging a draft, deleting the
old release, then moving the tag, and only after its own downstream-library
gates pass. A lagging library withholds the new artifact while the previous
vnightly stays up and installs fine. A green nightly row could not tell "main is
healthy" from "main broke on Tuesday and we are still testing Tuesday's binary".
That needs a published_at freshness check first. A release row has none of this
problem, since a published release is immutable.
yogthos added a commit that referenced this pull request Sep 3, 2026
Publish a rolling nightly prerelease from main
@burinc
burinc deleted the feat/nightly-prerelease branch October 14, 2026 16:05
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.

2 participants