Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ jobs:
# targets, and the matrix in libzu.yml held to them. This runs as
# a test too, and here for the same reason as the two above.
- run: cargo run -p xtask -- platforms
# And the table on the end of the release: what a tag publishes,
# which eight repositories fetch by version. release.yml assembles
# from it and verifies the directory back against it, so an
# artifact that stopped being produced fails the release that
# dropped it rather than the eight that wanted it.
- run: cargo run -p xtask -- artifacts
# A release is compression and copying and costs what those cost.
# What this measures is the bookkeeping either side of it, whose
# per-artifact cost has to stay flat as the contract grows past
# the seven platforms it has today.
- run: cargo bench -p xtask --bench artifacts

deny:
runs-on: ubuntu-latest
Expand Down
28 changes: 21 additions & 7 deletions .github/workflows/libzu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,30 @@ jobs:
--default-toolchain $toolchain
export PATH=/w/.cargo/bin:\$PATH
scripts/libzu-build.sh ${{ matrix.target }} ${{ matrix.smoke }}"
# What a consumer of the C ABI needs, flat in one directory named
# for the target, because this directory is the release artifact:
# `cargo xtask artifacts --assemble` packs it as
# libzu-<target>.tar.zst and an unpacked release should be four
# files rather than four levels of somebody's build path. The
# import library goes beside the DLL because a C caller on Windows
# links against that and not against the DLL, and it exists
# nowhere else, which is why the copy is conditional.
- name: Stage the artifact
shell: bash
env:
out: dist/libzu-${{ matrix.target }}
from: target/${{ matrix.target }}/release
run: |
set -eux
mkdir -p "$out"
cp "$from/${{ matrix.lib }}" "$from/${{ matrix.exe }}" "$out/"
if [ -f "$from/zu.dll.lib" ]; then cp "$from/zu.dll.lib" "$out/"; fi
cp crates/zu-capi/include/zu.h "$out/"
# One artifact per platform, named for the target, so that a
# release assembles what CI built rather than building it again
# somewhere else. The import library goes with the DLL because a C
# caller on Windows links against that and not against the DLL.
# somewhere else.
- uses: actions/upload-artifact@v4
with:
name: libzu-${{ matrix.target }}
if-no-files-found: error
path: |
target/${{ matrix.target }}/release/${{ matrix.lib }}
target/${{ matrix.target }}/release/${{ matrix.exe }}
target/${{ matrix.target }}/release/zu.dll.lib
crates/zu-capi/include/zu.h
path: dist/libzu-${{ matrix.target }}
113 changes: 113 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: release

# The release train of dx/14 section 6: one version number, one day,
# one orchestrated run across nine repositories. This is the skeleton of
# it, and what is real here is deliberate. The build is the same matrix
# every pull request runs, the assemble step gathers exactly the rows of
# artifacts.toml, and the verify step reads the directory back against
# the same table. Every publish step is a no-op that says what it would
# do.
#
# The ordering is the part worth having this early, because it is the
# part that is expensive to discover late: crates.io lands before the
# repositories that build against it, and the Go tag is last of the
# registries because pushing a tag is the one publish that cannot be
# taken back. A skeleton that runs the real order on every rehearsal is
# how the order stops being a paragraph in a specification.

on:
push:
tags: ["v*"]
# A rehearsal, on a branch, with no tag. The train is the thing that
# must not be run for the first time on the day of a release.
workflow_dispatch:
inputs:
version:
description: The version to rehearse as
required: false
default: 0.0.0

concurrency:
# Two releases at once is two versions publishing to nine registries
# in an order neither of them chose, and cancelling the one already
# part way through is worse than queueing behind it.
group: release
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always

jobs:
# The seven tier 1 platforms, called rather than repeated, so a
# release ships the artifacts CI has been building all along.
libzu:
uses: ./.github/workflows/libzu.yml

assemble:
needs: libzu
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@v2
# One directory per platform, under the name that platform's job
# uploaded it as, which is the name the release publishes it as.
- uses: actions/download-artifact@v4
with:
pattern: libzu-*
path: built
# The tag is the version, with the `v` off it, because a git tag
# is written `v0.5.0` and a package is not. A rehearsal says which
# version it is rehearsing instead.
- name: Assemble the release
shell: bash
run: |
set -eux
tag="${{ inputs.version || github.ref_name }}"
version="${tag#v}"
cargo run -p xtask -- artifacts --assemble dist --built built --version "$version"
cargo run -p xtask -- artifacts --verify dist --version "$version"
- uses: actions/upload-artifact@v4
with:
name: release
if-no-files-found: error
path: dist

publish:
needs: assemble
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v4
with:
name: release
path: dist
- name: What is being published
shell: bash
run: ls -l dist
# Each step below is the step that will exist, in the order it
# will run, doing nothing. They are separate steps rather than one
# loop because the run's own step list is then the ordering, which
# is what a person reads when a release stops half way through.
- name: GitHub release
run: echo "no-op: upload dist/ to the release for ${{ github.ref_name }}, signed and attested (dx/14 section 8, DX5)"
- name: crates.io
run: echo "no-op: publish zudb, zudb-async and zu-cli, first because everything else builds against them"
- name: PyPI
run: echo "no-op: publish the wheels zu-python built against these artifacts"
- name: npm
run: echo "no-op: publish zudb and its platform packages, then the JSR re-export"
- name: Maven Central
run: echo "no-op: publish dev.zudb, which is a staging repository that has to be closed and released"
- name: NuGet
run: echo "no-op: publish ZuDb"
- name: Go module tag
run: echo "no-op: push the tag on zu-go, last of the registries because a Go module cannot be unpublished"
- name: Docs and release notes
run: echo "no-op: deploy zu-web against this version, publish the notes, bump Homebrew, Scoop and AUR"
- name: What this run did not do
run: |
echo "The dispatches to the eight repositories are the conductor's, which is the next item of DX0."
echo "Every publish above is idempotent when it is real, so a partial release is resumed and not restarted."
72 changes: 72 additions & 0 deletions artifacts.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
schema = 1
doc = "What a release of zu publishes, one table for nine repositories."
audited = "2026-08-16"

# A release is a tag on this repository and a run that drives the eight
# others (dx/14 section 6). Each of them builds against what this one
# published, so the list of what gets published is a contract rather
# than a step in a workflow: a binding that fetches `model.json` by
# version and finds nothing has no way to tell a release that dropped
# the artifact from a version that never had it.
#
# So the list lives here, once, and the release workflow has none of its
# own. `cargo xtask artifacts --assemble` gathers exactly these rows and
# `--verify` reads the directory back, which means an artifact that
# stopped being produced fails the release that dropped it rather than
# the eight repositories that wanted it.
#
# `made` says where a row comes from and there are four answers. `file`
# is a path this tree already holds and the release copies. `corpus` is
# packed by the corpus packer. `platform` is one row per tier 1 target
# of platforms.toml, so the seven move with that table and not with this
# one. `later` is an artifact the contract names and nothing makes yet,
# with the milestone that makes it: writing it down early is the point,
# since a consumer needs to know what a release will eventually carry
# and the alternative is eight repositories each guessing.

[[artifact]]
name = "libzu-<target>.tar.zst"
made = "platform"
consumers = ["zu-c", "zu-go", "zu-java", "zu-dotnet", "zu-kit"]
doc = "The shared library, the CLI and the header for one tier 1 target, as the platform's job built them. The five repositories here reach the engine through the C ABI rather than compiling against it, so this archive is the whole of what they link."

[[artifact]]
name = "zu.h"
made = "file"
from = "crates/zu-capi/include/zu.h"
consumers = ["zu-c", "zu-go", "zu-java", "zu-dotnet", "zu-kit", "zu-web"]
doc = "The C ABI, published beside the libraries as well as inside each of them, because a consumer generating bindings needs the header without downloading a platform it does not build for. tamnd/zu-c deliberately does not hold a copy (dx/18 section 2)."

[[artifact]]
name = "model.json"
made = "file"
from = "docs/api/model.json"
consumers = ["zu-c", "zu-python", "zu-node", "zu-go", "zu-java", "zu-dotnet", "zu-kit", "zu-web"]
doc = "The public Rust surface as data. Every binding checks its api-map.toml against the model of the version it builds against, and the site renders the reference pages from it, so it is fetched by version rather than read from this repository's main branch."

[[artifact]]
name = "conformance-<version>.tar.zst"
made = "corpus"
consumers = ["zu-c", "zu-python", "zu-node", "zu-go", "zu-java", "zu-dotnet", "zu-kit"]
doc = "The cross-client conformance corpus for this exact version. A client pins an engine version and needs the cases that shipped with it, not the cases on this branch, which are the cases for a version it has not adopted (dx/15 section 2)."

[[artifact]]
name = "cli.json"
made = "later"
milestone = "D1"
consumers = ["zu-web"]
doc = "Every command, flag and exit code of the CLI, which D1 renders twelve reference pages from. It arrives with the `--format json` surface of DX1, since the generator reads the CLI's own description of itself rather than its help text."

[[artifact]]
name = "gql.json"
made = "later"
milestone = "D2"
consumers = ["zu-web"]
doc = "Statements, functions, types and signatures, which the language reference is generated from and which an agent reads directly (dx/16 section 2). D2 is where it can first exist, because it is the shipped grammar and registry rather than a hand-written list."

[[artifact]]
name = "errors.json"
made = "later"
milestone = "D2"
consumers = ["zu-python", "zu-node", "zu-web"]
doc = "Every condition with its GQLSTATUS, its meaning, its fix and whether retrying it is sensible. The bindings are consumers as well as the site: an SDK that maps a status to an exception class is the same list read a second way, and reading it from the artifact is what keeps the two spellings the same."
4 changes: 4 additions & 0 deletions crates/xtask/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ harness = false
name = "pins"
harness = false

[[bench]]
name = "artifacts"
harness = false

[dependencies]
crc32c.workspace = true
zstd.workspace = true
Expand Down
139 changes: 139 additions & 0 deletions crates/xtask/benches/artifacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! What holding a release to the artifact contract costs.
//!
//! Two numbers, and neither of them is the release itself: assembling
//! is compression and copying, and it costs what those cost. What is
//! measured here is the bookkeeping around them, because that is the
//! part that runs on every pull request as well as on release day, and
//! because it is the part with a shape that can go wrong.
//!
//! The second column is the one that matters. Verifying reads the
//! release directory once and then looks each expected name up, so the
//! cost per artifact is flat. The failure worth catching is the
//! accidental square, a scan of the directory per row, which costs
//! nothing at ten artifacts and is the reason a check gets skipped at
//! several hundred, and several hundred is where this goes when tier 2
//! platforms and per-binding artifacts arrive.
//!
//! Run: cargo bench -p xtask --bench artifacts

use std::hint::black_box;
use std::path::{Path, PathBuf};
use std::time::Instant;

use xtask::artifacts::{PATH, Table, tier1};

fn main() {
println!("{:>9} {:>9} {:>9}", "rows", "parse ms", "us/row");
let mut per_row = None;
for rows in [8usize, 32, 128, 512] {
let text = table(rows);
let ms = best(|| {
black_box(Table::parse(black_box(&text)).expect("the generated table parses"));
});
let us = ms * 1e3 / rows as f64;
println!("{rows:9} {ms:9.3} {us:9.2}");

// Parsing is a pass over the lines and a lookup per row. Four
// times the cost per row is a table whose own validation went
// quadratic, which is what a name checked against every name
// before it looks like.
if let Some((before, was)) = per_row {
assert!(
us < was * 4.0,
"parsing went from {was:.2} us/row at {before} rows to {us:.2} us/row at {rows}, \
which is not linear"
);
}
per_row = Some((rows, us));
}

println!("\n{:>9} {:>9} {:>9}", "files", "verify ms", "us/file");
let mut per_file = None;
for rows in [8usize, 32, 128, 512] {
let text = table(rows);
let table = Table::parse(&text).expect("the generated table parses");
let dir = release(&table, rows);
let ms = best(|| {
let (shipped, faults) = table
.verify(black_box(&dir), "0.5.0", &[])
.expect("the release is readable");
assert!(faults.is_empty(), "{faults:?}");
black_box(shipped);
});
let us = ms * 1e3 / rows as f64;
println!("{rows:9} {ms:9.3} {us:9.2}");

// One read of the directory and one lookup per name. Four times
// the cost per file is a lookup that walks the directory again.
if let Some((before, was)) = per_file {
assert!(
us < was * 4.0,
"verifying went from {was:.2} us/file at {before} files to {us:.2} us/file at \
{rows}, which is not linear"
);
}
per_file = Some((rows, us));
let _ = std::fs::remove_dir_all(&dir);
}

// Cargo runs a bench from the package directory, so the tree is two
// levels up.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let committed = Table::load(&root.join(PATH)).expect("the committed table loads");
let targets = tier1(&root).expect("the platform table loads");
let ms = best(|| {
black_box(
committed
.check(black_box(&root))
.expect("the tree is readable"),
);
});
println!(
"\ncommitted contract: {} rows, {} names for one release, {ms:.2} ms",
committed.artifacts.len(),
committed.names("0.5.0", &targets).len(),
);
}

/// A contract of `rows` artifacts, all of them published, which is the
/// shape that costs the most: a row nothing makes yet is a row neither
/// the assemble nor the verify step looks at.
fn table(rows: usize) -> String {
let mut text =
String::from("schema = 1\ndoc = \"What the bench publishes.\"\naudited = \"2026-08-16\"\n");
for n in 0..rows {
text.push_str(&format!(
"\n[[artifact]]\nname = \"thing{n}-<version>.json\"\nmade = \"file\"\nfrom = \
\"docs/thing{n}.json\"\nconsumers = [\"zu-python\", \"zu-web\"]\ndoc = \"An artifact \
the bench generated, which a release publishes like any other.\"\n"
));
}
text
}

/// A release directory holding exactly what that table publishes, which
/// is the case with no faults in it and therefore the one that does the
/// most work before answering.
fn release(table: &Table, rows: usize) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("zu-artifacts-bench-{}-{rows}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("the scratch dir is writable");
for name in table.names("0.5.0", &[]) {
std::fs::write(dir.join(name), b"{}\n").expect("writes");
}
dir
}

/// The best of seven, in milliseconds. The best rather than the mean
/// because the thing being measured is the work, and every sample above
/// the floor is the machine doing something else.
fn best(mut body: impl FnMut()) -> f64 {
let mut best = f64::MAX;
for _ in 0..7 {
let start = Instant::now();
body();
best = best.min(start.elapsed().as_secs_f64() * 1e3);
}
best
}
Loading
Loading