Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Releasing Plenty

A release is one vX.Y.Z tag on main. The maintainer creates and pushes it with cargo-release; the tag push starts .github/workflows/release.yml, which builds, tests, and publishes the GitHub Release. Users install it as described in installing a release.

Only the maintainer releases, after the changes to ship have been reviewed and merged under the local PR policy. Agents and PR branches never run cargo release --execute, create release tags, or push.

Versions

PackageVersion policy
plentyThe language release version. cargo release bumps it in Cargo.toml and Cargo.lock
plenty-runtimePrivate and embedded in the compiler. Keeps its own version; plenty-runtime/release.toml excludes it, so it is never bumped or tagged
plenty-dataframe-engineSeparately built workspace; its crate version is internal. Release archives bundle it under the Plenty version on Linux, macOS ARM64 and Windows x86-64, with the ABI generation in the shared-library filename
plenty-linalg-engineSeparately built faer workspace with the same release-version and ABI-generation policy

plenty --version reports the plenty package version. Tags are vX.Y.Z, or vX.Y.Z-PRE for a pre-release; asset names use the same version.

The root Cargo.toml is the source for the current compiler version. The CLI, LSP server information, source-cache paths, compilation fingerprints, and the minimum compiler version written by plenty new/plenty init use Cargo’s CARGO_PKG_VERSION at build time. Release scripts read the same manifest to name archives and fill the packaged README; packaging rejects a compiler whose reported version disagrees. The Python engine packager reads [package].version directly with tomllib, without invoking Bash with a native Windows path; check-version.sh remains the shell interface for archive/tag checks. Rebuild executables after bumping the manifest.

Do not globally replace older version strings. The private runtime has an independent version; new user packages start at 0.1.0; example manifests, minimum-version requirements, test fixtures, dependency pins, and historical documentation references describe their own versions. Compiler-output examples use X.Y.Z placeholders so they do not require edits on every release.

The documentation release catalog is separate from a version bump. Admit a version only after its release is published, preserving earlier archive pins. Development labels identify their source revision automatically; archived labels come from the selected release.

Tools

release.toml was tested with cargo-release 1.1.6:

cargo install cargo-release --version 1.1.6 --locked

The release build itself needs no local tools: it runs on GitHub Actions. To rehearse it, Docker is enough (see checking locally).

Prepare a clean release checkout

cargo-release refuses a dirty tree and commits with git commit -a. The primary checkout often has a modified issues.db, the shared issue register described in CLAUDE.md, which must not be reset, stashed, or committed to make a release. Release from a separate clone of main instead, whose origin is the primary checkout’s origin:

PRIMARY=/path/to/plenty            # the primary checkout
git clone --branch main "$PRIMARY" ~/plenty-release
cd ~/plenty-release
git remote set-url origin "$(git -C "$PRIMARY" remote get-url origin)"
git fetch origin
git status --short --branch    # must be clean and level with origin/main

Bring main level with origin/main first, through the usual review flow, so the release push is a fast-forward. Before releasing, run the full checks on that commit:

just release-check

This runs every Linux CI step, both optional engines, the Docker reverse-proxy tests, license notices for every release target, the documentation checks, and scripts/release/local-check.sh --test. It continues past a failing step, writes each step’s log to target/local-ci/, and prints a summary. scripts/local_ci.sh --help lists the steps; just ci STEP... reruns selected ones. The macOS and Windows release jobs, and the host’s other Linux architecture, still run only in CI.

Release

Preview, then execute:

cargo release patch                # dry run: prints the bump, commit, tag, push
cargo release patch --execute      # bump, commit "Release Plenty X.Y.Z", tag vX.Y.Z, push
LevelExample from 0.3.0
patch0.3.1
minor0.4.0
major1.0.0
rc, beta, alpha0.3.1-rc.1, then 0.3.1-rc.2
releaseDrop the pre-release: 0.3.1-rc.2 becomes 0.3.1
X.Y.ZExactly that version

The dry run changes nothing. The configuration rejects branches other than main, never publishes to crates.io, and pushes the commit and tag together (git push --atomic).

Afterwards, bring the release commit into the primary checkout without touching issues.db:

cd "$PRIMARY"
git fetch ~/plenty-release main --tags
git merge --ff-only FETCH_HEAD

Then publish the matching coding-agent skill to plentylang/skills, tagged with the same version:

git clone git@github.com:plentylang/skills.git ~/plenty-skills   # once
cd "$PRIMARY"
just skill-sync ~/plenty-skills
cd ~/plenty-skills
git add -A
git commit -m "Plenty X.Y.Z"
git tag vX.Y.Z
git push --atomic origin main vX.Y.Z

The v0.8.9 glibc verification records downloaded-artifact symbol audits and glibc 2.28 execution evidence for both Linux architectures.

What the workflow does

JobWork
Verify tagRequires the tagged commit to be on main and the tag to equal v plus the Cargo.toml version (scripts/release/check-version.sh)
TestThe full test suite and release-script tests on native Ubuntu x86-64 and ARM64 runners, with the pinned Rust
Build and testFor each architecture in its pinned glibc 2.28 image: installs the pinned Rust, builds both executables with --locked, runs the test suite, audits the compiler, then builds/audits both engines and packages them in the compiler archive
Smoke testFor each target and its pinned distribution images: installs only the documented prerequisites, disconnects the network, and exercises all archives, including app-only engine relocation with the compiler/original engine removed (smoke-in-container.sh)
Build and test (experimental)On macos-15 and windows-2025 with the pinned Rust: builds both executables, runs the platform-boundary and compiler/runtime unit tests, audits the executables (check-native.py), and packages the archive (build-native.sh), including the validated dataframe dylib on macOS or DLL on Windows
Smoke test (experimental)On a fresh runner of the same kind, with Rust, Cargo and PLENTY* variables removed from the environment: checks the archive, then type-checks, compiles and runs a program from paths with spaces, before and after moving the installation (smoke-native.py)
PublishTag pushes only. Creates a draft release with every target’s archives and checksums, then publishes it (publish.sh)

Native Ubuntu test runners and the x86-64 baseline build install the SHA-256-verified mold version pinned in scripts/release/toolchain.env, without requiring root. They check a linked program’s .comment section before running the suite, so a missing linker fails early rather than silently changing executable sizes. The ARM glibc 2.28 build and its local rehearsal use the GNU linker already pinned by the build image: upstream’s ARM mold 3.0.0 binary imports gettid@GLIBC_2.30 and cannot run in that image. This preserves the existing glibc floor. Size budgets measured with mold require care when comparing GNU ld outputs, which can retain additional unused runtime code and panic strings. The clean distribution smoke tests also exercise system linkers; mold is not a release-installation dependency.

Builds use the tagged source, not moving main, a host build, or a cache from another environment. Publication waits for every job to pass. Pre-release tags (vX.Y.Z-PRE) publish GitHub pre-releases that are not marked latest. Runs for the same tag are serialized. Only the publish job can write repository contents, through the workflow token; no crates.io or other credential is used.

scripts/release/target.sh defines each target and scripts/release/matrix.py turns the list into job matrices:

TargetRunnerArchives
x86_64-unknown-linux-gnuubuntu-24.04compiler with dataframe and linalg engines (.tar.gz)
aarch64-unknown-linux-gnuubuntu-24.04-armcompiler with dataframe and linalg engines (.tar.gz)
aarch64-apple-darwin (experimental)macos-15compiler with dataframe dylib (.tar.gz), macOS 11 or later
x86_64-pc-windows-msvc (experimental)windows-2025compiler with dataframe DLL (.zip), Windows 10 or later

Each target publishes one asset, plenty-VERSION-TRIPLE.EXT, and its checksum. release_engines in target.sh lists the engines bundled for a target. Linux bundles both ELF engines; Windows bundles the dataframe DLL, built and validated natively by build-native.sh. macOS bundles the dataframe dylib, also built and validated natively, with a macOS 11 deployment minimum, @rpath/libplenty_dataframe_v1.dylib identity, system-only dependencies and verified ad-hoc signature. Native archive modes distinguish the two compiler executables (0755) from engine libraries and notices (0644). Windows packaging audits x86-64 PE headers, Windows/MSVC dependencies and all facade exports. The native clean smoke tests check a Parquet query, compiler relocation, --dist application relocation and failure without the engine. Native archives are not notarized or signed with a distribution identity; macOS executables and the dataframe dylib carry linker-generated ad-hoc signatures, and the Windows build links the dynamic MSVC runtime that the documented Build Tools prerequisite installs. archive.py writes those archives with sorted entries, fixed owners and modes, and SOURCE_DATE_EPOCH timestamps. The release notes mark them experimental. The ARM harness records the emulated evidence gathered before native ARM runners. A Linux archive contains bin/libplenty_NAME_v1.so beside the compiler and bin/NAME-notices/ with licenses, dependency notices and engine.json identifying the release/source, target and library checksum. build.sh builds each engine and package.sh --engine NAME=LIBRARY installs it through package-engine.py; the archive README covers using and shipping the engines. package-engine.py NAME rejects an incorrect SONAME, embedded search paths, unsupported native dependencies/symbol versions, and missing symbols required by that checkout’s stdlib facade. This catches stale engines with the same ABI-generation name. The workflow builds all components from the tagged source; it never substitutes an engine downloaded from another release.

Normal compiler builds remain independent of Polars and faer, and the compiler links neither; programs load an engine only when they reach it. The release engine is built once for reuse when compiling and running many applications. The engine’s native dependency graph supplies its own notices; it is not a copy of the compiler’s dependency inventory.

The release build environment

scripts/release/toolchain.env pins every input that is not in the lockfile:

  • BUILD_IMAGE and BUILD_IMAGE_AARCH64: architecture-matched quay.io/pypa/manylinux_2_28_x86_64 and manylinux_2_28_aarch64 images by digest. They provide AlmaLinux 8 with glibc 2.28 and gcc-toolset, so C/Rust objects are built against that baseline. ELF symbol auditing and execution in the oldest smoke environment must confirm the result for each target.
  • RUST_VERSION, architecture-specific checksummed rustup-init, and mold.
  • SMOKE_IMAGES and SMOKE_IMAGES_AARCH64: separate pinned inventories. x86 retains its six distributions; ARM uses AlmaLinux 8 and Debian 13. A distribution without a matching ARM image is not silently treated as tested.

Update these deliberately: change the pin, run scripts/release/local-check.sh, and update the tested distribution list in scripts/release/README.dist.md and the installation page when it changes. check-elf.sh fails the build if a change raises the glibc requirement.

Recovering from a failed release

Never move or delete a pushed release tag, and never change a published release.

  • A job failed before publishing, for example a flaky test or an unavailable image: rerun the failed jobs. No release exists yet, or only a draft.
  • Publishing was interrupted: rerun the publish job. publish.sh finds the draft, replaces its assets, and publishes it, without creating a second release.
  • A rerun after publishing: succeeds without changes when both published checksums match, and fails otherwise, including a missing engine checksum.
  • The tagged source is wrong: fix it on main through review and release the next version. Delete an unpublished draft for the bad tag in the GitHub interface if one remains; the tag stays.

Dry runs

Run the workflow manually to test it before tagging. A manual run builds and smoke-tests the selected targets from the chosen branch and never publishes; the publish job runs only for tag pushes. It skips the tag checks and reads the version from Cargo.toml.

gh workflow run release.yml --ref BRANCH -f targets=all    # or linux, native
gh run watch

Or use Actions → Release → Run workflow in the GitHub interface. The built archives remain downloadable from the run’s artifacts for 30 days.

Checking locally

scripts/release/local-check.sh runs the build and smoke jobs with Docker, without tagging, uploading, or publishing. It builds in the pinned image into target/release-container-ARCH/, separate from host builds, and smoke-tests the archives in that target’s pinned distributions. By default it selects the host architecture; PLENTY_RELEASE_TARGET explicitly selects one of the two triples. The native release build refuses a mismatched host rather than quietly relying on emulation. For example, on an ARM host:

PLENTY_RELEASE_TARGET=aarch64-unknown-linux-gnu scripts/release/local-check.sh --test

The engine builder accepts --target TRIPLE and --validate; a successful cross-build alone does not validate engine execution. Options:

OptionEffect
--testAlso run the full test suite in the build image
--skip-smokeOnly build, audit, and package

scripts/release/smoke-test.sh ARCHIVE runs the archive checks on the host. bash scripts/release/smoke-engine.sh ARCHIVE dataframe checks the bundled engine’s files and modes, optional linkage, discovery beside the compiler, a Parquet query, --dist relocation without the compiler, and the missing runtime library diagnostic. smoke-engine.sh ARCHIVE linalg runs the same checks with a linear solve. smoke-in-container.sh runs both after smoke-test.sh. Containers are labelled org.plenty.release-build and org.plenty.release-smoke and removed when the check ends.

Licenses in the archive

See release archive licenses for inventories and maintenance requirements.