name: Zit Release Candidate
# Every artifact this workflow uploads is transit: the candidate is downloaded
# and published to the uv store and a GitHub release the day it is built, and
# nothing reads it from here afterwards. Three days of retention covers a
# publish that slips; thirty kept ~40 MB per release against a 500 MB storage
# quota, and the quota running out failed the next upload of anything at all.
on:
push:
tags:
- "version-*"
workflow_dispatch:
inputs:
version:
description: "Existing Fossil release version, without version- prefix"
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
source:
runs-on: [self-hosted, Linux, X64, linux-x64-zit]
timeout-minutes: 10
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Resolve release version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
case "${GITHUB_REF_NAME}" in
version-*) version="${GITHUB_REF_NAME#version-}" ;;
*) echo "release workflow requires a version-* tag" >&2; exit 1 ;;
esac
else
version="${REQUESTED_VERSION}"
fi
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "invalid release version: ${version}" >&2
exit 1
fi
tag="version-${version}"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
- name: Materialize exact Fossil release source
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
command -v fossil >/dev/null
fossil version
version="${VERSION}"
tag="version-${version}"
repo="${RUNNER_TEMP}/zit-${version}.fossil"
checkout="${RUNNER_TEMP}/zit-${version}-checkout"
rm -rf "${repo}" "${checkout}"
fossil clone https://fossil.craftdesign.group/zit "${repo}"
mkdir -p "${checkout}"
cd "${checkout}"
fossil open "${repo}" "${tag}"
fossil info current | tee "${RUNNER_TEMP}/fossil-info.txt"
grep -F ".version = \"${version}\"" build.zig.zon >/dev/null
grep -F "zit ${version} (targets Fossil RFC rev " src/main.zig >/dev/null
fossil tarball --name "zit-${version}" "${tag}" \
"${RUNNER_TEMP}/zit-${version}-source.tar.gz" -R "${repo}"
cd "${RUNNER_TEMP}"
sha256sum "zit-${version}-source.tar.gz" > "zit-${version}-source.tar.gz.sha256"
- name: Upload exact release source
uses: actions/upload-artifact@v4
with:
name: release-source
path: |
${{ runner.temp }}/zit-${{ steps.version.outputs.version }}-source.tar.gz
${{ runner.temp }}/zit-${{ steps.version.outputs.version }}-source.tar.gz.sha256
${{ runner.temp }}/fossil-info.txt
if-no-files-found: error
retention-days: 3
build:
needs: source
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: macOS
arch: ARM64
runner: mac-mini-zit
artifact: macos-aarch64
zig_target: aarch64-macos
zig_package_target: aarch64-macos
zig_shasum: b607e9b9234790a008116ae5bdb71c6243b84b9fb42a53a9e70fde41c06c536a
smoke_repetitions: 3
- os: Linux
arch: X64
runner: linux-x64-zit
artifact: linux-x86_64
zig_target: x86_64-linux
zig_package_target: x86_64-linux
zig_shasum: 1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026
smoke_repetitions: 10
- os: Linux
arch: ARM64
runner: linux-arm64-zit
artifact: linux-aarch64
zig_target: aarch64-linux
zig_package_target: aarch64-linux
zig_shasum: 9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8
smoke_repetitions: 10
runs-on: [self-hosted, "${{ matrix.os }}", "${{ matrix.arch }}", "${{ matrix.runner }}"]
env:
ZIG_VERSION: 0.17.0
ZIG_TARGET: ${{ matrix.zig_target }}
ZIG_PACKAGE_TARGET: ${{ matrix.zig_package_target }}
ZIG_SHASUM: ${{ matrix.zig_shasum }}
VERSION: ${{ needs.source.outputs.version }}
ARTIFACT: ${{ matrix.artifact }}
SMOKE_REPETITIONS: ${{ matrix.smoke_repetitions }}
steps:
- name: Download exact release source
uses: actions/download-artifact@v4
with:
name: release-source
path: ${{ runner.temp }}/release-source
- name: Verify and extract exact release source
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/release-source"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "zit-${VERSION}-source.tar.gz.sha256"
else
shasum -a 256 -c "zit-${VERSION}-source.tar.gz.sha256"
fi
rm -rf "${RUNNER_TEMP}/zit-${VERSION}"
tar -xzf "zit-${VERSION}-source.tar.gz" -C "${RUNNER_TEMP}"
- name: Install Zig 0.17.0
run: |
set -euo pipefail
# Same persistent, digest-verified cache as ci.yml: the runners are
# self-hosted, and a warm content-addressed Zig cache outside the
# per-run temp turns this build's unit and end-to-end steps from cold
# compiles into warm ones. The tarball is refetched whenever the
# kept copy fails the pinned digest, never trusted for being there.
cache="${HOME}/.cache/zit-ci"
# Zig never prunes its compilation cache: every source change leaves
# a generation behind, and these runners grew 9-14 GB of it in a week.
# Past the cap it is cleared WHOLE, at job start, when nothing else is
# building on this single-slot runner. Not pruned by age: Zig does not
# touch an entry it reuses, so an old entry may still be live, and a
# manifest naming a deleted output costs more than one cold compile.
cap_kb=$((8 * 1024 * 1024))
if [ -d "${cache}/zig-local" ]; then
used_kb=$(du -sk "${cache}/zig-local" | awk '{print $1}')
if [ "${used_kb}" -gt "${cap_kb}" ]; then
echo "zig cache is ${used_kb} KB, over the ${cap_kb} KB cap; clearing it"
rm -rf "${cache}/zig-local"
fi
fi
mkdir -p "${cache}/zig-local" "${cache}/zig-global" "${cache}/toolchains"
echo "ZIG_LOCAL_CACHE_DIR=${cache}/zig-local" >> "$GITHUB_ENV"
echo "ZIG_GLOBAL_CACHE_DIR=${cache}/zig-global" >> "$GITHUB_ENV"
cd "${RUNNER_TEMP}"
tarball="zig-${ZIG_PACKAGE_TARGET}-${ZIG_VERSION}.tar.xz"
kept="${cache}/toolchains/${tarball}"
digest() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
if [ ! -f "${kept}" ] || [ "$(digest "${kept}")" != "${ZIG_SHASUM}" ]; then
rm -f "${kept}"
curl -fsSL -o "${kept}.part" "https://ziglang.org/download/${ZIG_VERSION}/${tarball}"
mv "${kept}.part" "${kept}"
fi
actual="$(digest "${kept}")"
if [ "${actual}" != "${ZIG_SHASUM}" ]; then
echo "zig tarball digest mismatch: expected ${ZIG_SHASUM}, got ${actual}" >&2
rm -f "${kept}"
exit 1
fi
rm -rf "zig-${ZIG_PACKAGE_TARGET}-${ZIG_VERSION}"
tar -xf "${kept}"
echo "${RUNNER_TEMP}/zig-${ZIG_PACKAGE_TARGET}-${ZIG_VERSION}" >> "${GITHUB_PATH}"
- name: Format check
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: zig fmt --check build.zig src
- name: Build portable ReleaseFast binary
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: zig build --release=fast -Dtarget="${ZIG_TARGET}"
- name: Unit tests
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: zig build test
- name: End-to-end tests
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: zig build e2e
- name: Deterministic simulation
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: zig build dst -- --seed 1 --steps 1000
- name: Seeded fuzz sweep
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: |
set -euo pipefail
targets="$(zig build fuzz -- --list)"
[ -n "${targets}" ] || { echo "no fuzz targets enumerated" >&2; exit 1; }
for target in ${targets}; do
zig build fuzz -- "${target}" --seed 1 --rounds 100
done
- name: Fossil interoperability smoke
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: |
set -euo pipefail
for n in $(seq 1 "${SMOKE_REPETITIONS}"); do
echo "smoke ${n}/${SMOKE_REPETITIONS}"
zig build smoke
done
- name: Rebuild and package qualified ReleaseFast binary
working-directory: ${{ runner.temp }}/zit-${{ needs.source.outputs.version }}
run: |
set -euo pipefail
zig build --release=fast -Dtarget="${ZIG_TARGET}"
./zig-out/bin/zit version
dist="${RUNNER_TEMP}/dist-${ARTIFACT}"
stage="${dist}/zit-${VERSION}"
archive="zit-${VERSION}-${ARTIFACT}.tar.gz"
rm -rf "${dist}"
mkdir -p "${stage}"
install -m 0755 zig-out/bin/zit "${stage}/zit"
cp README.md LICENSE "${stage}/"
cp "docs/RELEASE-NOTES-${VERSION}.md" "${stage}/RELEASE-NOTES.md"
tar -C "${dist}" -czf "${dist}/${archive}" "zit-${VERSION}"
if command -v sha256sum >/dev/null 2>&1; then
binary_sha="$(sha256sum "${stage}/zit" | awk '{print $1}')"
archive_sha="$(sha256sum "${dist}/${archive}" | awk '{print $1}')"
else
binary_sha="$(shasum -a 256 "${stage}/zit" | awk '{print $1}')"
archive_sha="$(shasum -a 256 "${dist}/${archive}" | awk '{print $1}')"
fi
printf '%s %s\n' "${archive_sha}" "${archive}" > "${dist}/${archive}.sha256"
python3 - "${dist}/${archive}.metadata.json" "${archive}" "${ARTIFACT}" \
"${ZIG_TARGET}" "${archive_sha}" "${binary_sha}" <<'PY'
import json, pathlib, sys
output, archive, artifact, target, archive_sha, binary_sha = sys.argv[1:]
data = {
"artifact": artifact,
"binary_sha256": binary_sha,
"bytes": pathlib.Path(output).with_name(archive).stat().st_size,
"name": archive,
"qualification": "passed",
"sha256": archive_sha,
"target": target,
}
pathlib.Path(output).write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
PY
- name: Upload qualified binary package
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.artifact }}
path: |
${{ runner.temp }}/dist-${{ matrix.artifact }}/zit-${{ needs.source.outputs.version }}-${{ matrix.artifact }}.tar.gz
${{ runner.temp }}/dist-${{ matrix.artifact }}/zit-${{ needs.source.outputs.version }}-${{ matrix.artifact }}.tar.gz.sha256
${{ runner.temp }}/dist-${{ matrix.artifact }}/zit-${{ needs.source.outputs.version }}-${{ matrix.artifact }}.tar.gz.metadata.json
if-no-files-found: error
retention-days: 3
qualify-linux:
needs: [source, build]
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- artifact: linux-x86_64
emulator: x86_64
- artifact: linux-aarch64
emulator: aarch64
env:
VERSION: ${{ needs.source.outputs.version }}
ARTIFACT: ${{ matrix.artifact }}
steps:
- name: Download packaged binary
uses: actions/download-artifact@v4
with:
name: release-${{ matrix.artifact }}
path: dist
- name: Install deterministic CPU emulators
run: |
sudo apt-get update
sudo apt-get install -y qemu-user
- name: Verify package digest and low-feature execution
env:
EMULATOR: ${{ matrix.emulator }}
run: |
set -euo pipefail
cd dist
archive="zit-${VERSION}-${ARTIFACT}.tar.gz"
sha256sum -c "${archive}.sha256"
mkdir package
tar -xzf "${archive}" -C package
binary="${PWD}/package/zit-${VERSION}/zit"
if [ "${EMULATOR}" = "x86_64" ]; then
"${binary}" version
qemu-x86_64 -cpu Nehalem "${binary}" version
else
qemu-aarch64 -cpu cortex-a53 "${binary}" version
fi
qualify-macos:
needs: [source, build]
runs-on: [self-hosted, macOS, ARM64, mac-mini-zit]
timeout-minutes: 5
env:
VERSION: ${{ needs.source.outputs.version }}
steps:
- name: Download packaged binary
uses: actions/download-artifact@v4
with:
name: release-macos-aarch64
path: dist
- name: Verify packaged macOS binary
run: |
set -euo pipefail
cd dist
archive="zit-${VERSION}-macos-aarch64.tar.gz"
shasum -a 256 -c "${archive}.sha256"
# The macOS runner is a persistent machine: a rerun, or a tag pushed
# twice, finds the last run's package directory still there.
rm -rf package
mkdir package
tar -xzf "${archive}" -C package
"${PWD}/package/zit-${VERSION}/zit" version
bundle:
needs: [source, qualify-linux, qualify-macos]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Download release source
uses: actions/download-artifact@v4
with:
name: release-source
path: candidate
- name: Download qualified binary packages
uses: actions/download-artifact@v4
with:
pattern: release-*-*
path: candidate
merge-multiple: true
- name: Assemble candidate checksums
run: |
set -euo pipefail
cd candidate
cat ./*.tar.gz.sha256 | sort -k2 > SHA256SUMS
sha256sum -c SHA256SUMS
- name: Upload publication candidate
uses: actions/upload-artifact@v4
with:
name: zit-${{ needs.source.outputs.version }}-publication-candidate
path: candidate
if-no-files-found: error
retention-days: 3