Skip to content

chore(release): 0.7.6 — promote rc2, fold Unreleased, bump codegen (#… #33

chore(release): 0.7.6 — promote rc2, fold Unreleased, bump codegen (#…

chore(release): 0.7.6 — promote rc2, fold Unreleased, bump codegen (#… #33

Workflow file for this run

# Tag-triggered release workflow. Pushing a tag matching the project's
# bare-numeric scheme (e.g. `0.6.2`, no `v` prefix) runs `tools/make_release.sh`
# on ubuntu-22.04 and uploads the resulting tarball + `.deb` + per-asset
# `.sha256` files to a fresh GitHub Release.
#
# Why ubuntu-22.04 and not ubuntu-latest:
# ubuntu-22.04 ships glibc 2.35. Binaries linked against an older glibc
# load on every later glibc — Ubuntu 22.04 LTS, 24.04 LTS, 26.04, and
# Debian 12 / 13. Building on ubuntu-latest (currently 24.04) would
# exclude Ubuntu 22.04 LTS users, who are still on the most widespread
# long-term release.
#
# Manual release procedure: see docs/release/RELEASING.md.
name: Release
on:
push:
tags:
# Bare X.Y.Z — matches the repo's existing tag history (no v-prefix).
# Auto-publishes a public release (see the publish step).
- '[0-9]+.[0-9]+.[0-9]+'
# Pre-release / RC tags, e.g. 0.7.4-rc1 (a valid semver pre-release, so it
# matches the Cargo.toml version — make_release.sh's version check passes
# and the artifacts are named `…-0.7.4-rc1-…`). Published as a DRAFT
# prerelease for cross-OS testing, not a public release (publish step).
- '[0-9]+.[0-9]+.[0-9]+-*'
permissions:
# softprops/action-gh-release needs to write the Release object.
contents: write
jobs:
# Generate {plain,latex}.YYYY.dump.txt for the 5-year TL window in
# pinned TL containers (see release-dumps.yml). The dumps are NOT in
# git — the release binary embeds what this job produces.
dumps:
uses: ./.github/workflows/release-dumps.yml
# Assemble THIRD-PARTY-NOTICES ONCE, here, and hand it to every packaging leg
# as an artifact (F7 in docs/release/LICENSE_INVENTORY.md). Previously only the
# `release` job ran gen_notices.sh, so the macOS/arm64-linux tarballs fell back
# to the committed hand-authored sections 1-4 and shipped with NO Rust-crate
# license texts at all.
#
# One file is valid for every target: about.toml sets no `targets`, and krates
# treats an empty target filter as include_all_targets — so the generated
# section 5 is the union over all platforms, not the host's subset. (Verified
# in krates' builder.rs: `include_all_targets = target_filters.is_empty()`.)
# Do NOT add a `targets` key to about.toml without re-checking this job.
notices:
name: Assemble THIRD-PARTY-NOTICES
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
# cargo-about only reads cargo metadata + crate files; it never compiles
# the workspace, so this leg needs no libxml2/TeX/system deps.
- name: install cargo-about
run: cargo install cargo-about --features cli
# Re-run the blind-spot audit here, not just in CI's `lint` job: CI.yml
# triggers on `push: [main]` + `pull_request`, so a TAG push never runs it.
# Without this, a release could ship a newly-vendored native library that
# no gate ever looked at. Cheap (seconds) and every release passes through
# this job.
- name: audit vendored native code + embedded resources
run: python3 tools/audit_vendored_natives.py --verbose
- name: assemble notices
run: bash tools/gen_notices.sh THIRD-PARTY-NOTICES.dist
# Fail loudly rather than ship a truncated notice. Guards the three
# hand-authored things that have silently gone missing before: the vendored
# native libs (section 3.3/3.4), the generated Rust appendix (section 5),
# and the copyleft texts the static LGPL links require (section 6).
- name: verify assembled notices are complete
run: |
set -euo pipefail
f=THIRD-PARTY-NOTICES.dist
for needle in \
"2.2 SVG 1.1 RELAX NG schema" \
"World Wide Web Consortium" \
"3.2 libkpathsea" \
"3.3 libmarpa" \
"3.4 mimalloc" \
"5. RUST DEPENDENCY LICENSES" \
"6. COPYLEFT LICENSE TEXTS" \
"7. SOURCE PROVENANCE" \
"This License Agreement applies to any software library or other" \
"0. Additional Definitions." \
"16. Limitation of Liability."; do
grep -qF "$needle" "$f" || { echo "::error::THIRD-PARTY-NOTICES is missing: $needle"; exit 1; }
done
# The last three needles are sentences from the BODIES of LGPL-2.1, LGPL-3.0
# and GPL-3.0 respectively, each unique to its own file (verified). They used
# to be the section titles "GNU LESSER GENERAL PUBLIC LICENSE v2.1" etc --
# strings that appear in NO license text and match only the title gen_notices.sh
# echoes above each one. Three 0-byte files in licenses/ therefore passed this
# gate while shipping no license text at all. Grep the text, not the scaffolding.
# Section 7's relink pointer must name real commits: an unresolved revision
# means we shipped a promise (3.5) we cannot keep. `commit: unknown` is the
# same failure wearing a different word -- it is what gen_notices.sh writes
# when `git rev-parse` cannot answer. Scoped to the section-7 lines so a
# future crate's license text containing "unresolved" cannot fail a release.
if sed -n '/^7\. SOURCE PROVENANCE/,$p' "$f" | grep -qE "unresolved|commit: unknown"; then
echo "::error::THIRD-PARTY-NOTICES section 7 has an unresolved source revision"
exit 1
fi
# Section 5 must actually ATTRIBUTE CRATES, not merely carry its heading.
# This replaces a whole-file `wc -l < 1000` floor that could never fire: the
# section 6 license texts alone are 1341 lines, so a notices file attributing
# ZERO of the ~66 shipped crates still measured ~1706 lines and passed. The
# heading needle above cannot cover it either -- about.hbs emits
# "5. RUST DEPENDENCY LICENSES" as literal template text, outside {{#each}},
# so it proves the template ran, not that it rendered anything. Count the
# per-crate "Applies to:" lines inside section 5's own range instead.
# Floor 50 vs ~66 today: a legitimate drop below it should stop a release and
# make a human look, which is the point.
crates="$(sed -n '/^5\. RUST DEPENDENCY LICENSES/,/^6\. COPYLEFT LICENSE TEXTS/p' "$f" \
| grep -c '^Applies to:' || true)"
if [ "${crates:-0}" -lt 50 ]; then
echo "::error::THIRD-PARTY-NOTICES section 5 attributes only ${crates:-0} crates (expected ~66) — cargo-about emitted little or nothing"
exit 1
fi
echo "OK: notices complete ($(wc -l < "$f") lines, section 5 attributes ${crates} crates)"
- uses: actions/upload-artifact@v4
with:
name: third-party-notices
path: THIRD-PARTY-NOTICES.dist
if-no-files-found: error
# macOS (Apple Silicon) release tarball. Native binaries are never
# cross-OS — Linux emits ELF, macOS emits Mach-O — so the macOS asset is
# built on its own runner, NOT cross-compiled from the Linux leg. We build
# natively on the arm64 `macos-15` runner (the same arch the CI.yml macOS
# job validates, #217). Intel macOS (x86_64-apple-darwin) is published from a
# separate native `build-macos-intel` leg below — see docs/release/RELEASING.md
# "Release asset strategy". The TL-window dumps are OS-agnostic gzipped text, so this leg
# embeds the exact bytes the `dumps` job produced for the Linux leg.
build-macos:
name: Build macOS arm64 ${{ github.ref_name }}
runs-on: macos-15
# maxperf is fat-LTO / codegen-units=1 — the slowest, most RAM-hungry
# build. macos-15 free-tier runners are 3 vCPU / 7 GB; allow generous
# wall-clock. If the LTO link OOMs on 7 GB, the documented mitigation is a
# larger runner or relaxing the macOS leg's codegen-units (RELEASING.md).
timeout-minutes: 90
needs: [dumps, notices]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# libxml2 + libxslt are keg-only on Homebrew; install them and surface
# their pkgconfig dirs so the libxml/libxslt build scripts find them.
# libkpathsea is built from source and STATICALLY linked (see the
# build-static-kpathsea step below): Homebrew ships no static `.a`, and a
# static link makes the binary self-contained — in-process lookups with NO
# runtime libkpathsea dependency, so it launches on MacTeX (which ships no
# libkpathsea) and degrades to empty lookups where no TeX is present.
- name: install dependencies
run: |
command -v pkg-config >/dev/null || brew install pkgconf
brew install libxml2 libxslt
- name: surface keg-only pkg-config paths
run: |
echo "PKG_CONFIG_PATH=$(brew --prefix libxml2)/lib/pkgconfig:$(brew --prefix libxslt)/lib/pkgconfig" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: release-maxperf-macos
# Same TL-window dumps as the Linux leg (OS-agnostic gzipped text).
- name: collect TL-window dumps
uses: actions/download-artifact@v6
with:
pattern: dumps-*
path: resources/dumps/
merge-multiple: true
- name: verify dump window completeness
run: |
ls -la resources/dumps/
for year in 2022 2023 2024 2025 2026; do
for f in plain.$year.dump.txt latex.$year.dump.txt texlive.$year.version; do
if [ ! -s "resources/dumps/$f" ]; then
echo "::error::release dump window incomplete: missing resources/dumps/$f"
exit 1
fi
done
done
# Static, self-contained libkpathsea (in-process lookups, no runtime dep).
# Sets KPATHSEA_LIB_DIR + KPATHSEA_STATIC for the build below.
- name: build static libkpathsea from source
run: bash tools/build_static_kpathsea.sh
# Static, self-contained libxml2 + libxslt + libexslt (no runtime
# libxml2/libxslt .dylib dependency, so the binary is independent of the
# host's libxml2 SONAME — libxml2 2.14 bumped .so.2 -> .so.16). Sets
# PKG_CONFIG_PATH + LIBXML2_STATIC + LIBXSLT_STATIC for the build below;
# supersedes the brew libxml2/libxslt above (kept only as a fallback).
- name: build static libxml2 + libxslt from source
run: bash tools/build_static_libxml.sh
# make_release.sh bundles THIRD-PARTY-NOTICES.dist when present, else it
# silently falls back to the committed sections 1-4 (no Rust appendix).
- name: fetch assembled THIRD-PARTY-NOTICES
uses: actions/download-artifact@v4
with:
name: third-party-notices
- name: build macOS release tarball
run: bash tools/make_release.sh
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
RELEASE_TARGET: aarch64-apple-darwin
# Fail the release if the binary still dynamically links a C library we
# bundle — the point is SONAME-independence + no host libxml2/libxslt.
- name: verify self-contained binary
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
echo "checking $bin"; otool -L "$bin"
if otool -L "$bin" | grep -Eiq 'libxml2|libxslt|libexslt|libkpathsea'; then
echo "::error::release binary dynamically links a library that must be static"; exit 1
fi
echo "OK: no dynamic libxml2/libxslt/kpathsea"
# Ad-hoc signature gate + launch smoke. make_release.sh re-signs the binary
# after `strip` (dash = ad-hoc, no Apple Developer cert); an arm64 Mach-O
# with a broken signature is killed at exec. `codesign --verify` asserts
# the signature survived, and actually running `--version` proves the
# binary launches on this arch end-to-end (the real Killed:9 gate — this
# is what lets the release auto-publish, below, without a manual "download
# and confirm it runs" step). Conversion paths (static libxml2/libxslt,
# XSLT, RelaxNG) are covered by CI.yml's macOS test job on the same arch.
# NOT notarization — ripgrep-style posture, see RELEASING.md "macOS
# Gatekeeper & code signing".
- name: verify code signature + launch smoke
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
codesign --verify --verbose "$bin" \
|| { echo "::error::macOS binary has no valid code signature — would be Killed:9 on arm64"; exit 1; }
"$bin" --version \
|| { echo "::error::macOS binary failed to launch (--version) — un-runnable artifact"; exit 1; }
echo "OK: valid (ad-hoc) signature and the binary launches"
- name: upload macOS artifact
uses: actions/upload-artifact@v5
with:
name: macos-tarball
path: |
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz.sha256
if-no-files-found: error
# macOS (Intel / x86_64) release tarball — for Intel Macs (e.g. the 2018
# MacBook Air). A separate NATIVE leg from build-macos (arm64): a native
# binary is never cross-arch here (we do not cross-compile the static C
# deps), so x86_64 gets its own runner. GitHub retired the `macos-13` Intel
# image on 2025-12-04; `macos-15-intel` is the last free-tier x86_64 macOS
# image, available until ~Fall 2027, after which GitHub Actions drops Intel
# macOS entirely (revisit then — universal/lipo or a self-hosted Intel Mac).
# MACOSX_DEPLOYMENT_TARGET=10.13 keeps the binary runnable on OLDER Intel
# Macs even though the runner's SDK is macOS 15: the 2018 Air shipped with
# 10.14 and tops out at Sonoma 14 (it cannot run Sequoia 15).
build-macos-intel:
name: Build macOS x86_64 ${{ github.ref_name }}
runs-on: macos-15-intel
# Intel runners are slower than Apple Silicon; give fat-LTO generous
# wall-clock (build-macos uses 90 on arm64).
timeout-minutes: 120
needs: [dumps, notices]
env:
MACOSX_DEPLOYMENT_TARGET: "10.13"
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: install dependencies
run: |
command -v pkg-config >/dev/null || brew install pkgconf
brew install libxml2 libxslt
- name: surface keg-only pkg-config paths
run: |
echo "PKG_CONFIG_PATH=$(brew --prefix libxml2)/lib/pkgconfig:$(brew --prefix libxslt)/lib/pkgconfig" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: release-maxperf-macos-intel
# Same TL-window dumps as the other legs (OS/arch-agnostic gzipped text).
- name: collect TL-window dumps
uses: actions/download-artifact@v6
with:
pattern: dumps-*
path: resources/dumps/
merge-multiple: true
- name: verify dump window completeness
run: |
ls -la resources/dumps/
for year in 2022 2023 2024 2025 2026; do
for f in plain.$year.dump.txt latex.$year.dump.txt texlive.$year.version; do
if [ ! -s "resources/dumps/$f" ]; then
echo "::error::release dump window incomplete: missing resources/dumps/$f"
exit 1
fi
done
done
- name: build static libkpathsea from source
run: bash tools/build_static_kpathsea.sh
- name: build static libxml2 + libxslt from source
run: bash tools/build_static_libxml.sh
# make_release.sh bundles THIRD-PARTY-NOTICES.dist when present, else it
# silently falls back to the committed sections 1-4 (no Rust appendix).
- name: fetch assembled THIRD-PARTY-NOTICES
uses: actions/download-artifact@v4
with:
name: third-party-notices
- name: build macOS release tarball
run: bash tools/make_release.sh
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
RELEASE_TARGET: x86_64-apple-darwin
- name: verify self-contained binary
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
echo "checking $bin"; otool -L "$bin"
if otool -L "$bin" | grep -Eiq 'libxml2|libxslt|libexslt|libkpathsea'; then
echo "::error::release binary dynamically links a library that must be static"; exit 1
fi
echo "OK: no dynamic libxml2/libxslt/kpathsea"
# Ad-hoc signature gate + launch smoke (see build-macos leg). x86_64 does
# not require a signature to run the way arm64 does, but make_release.sh
# re-signs both legs uniformly; assert it here too, and run `--version` so
# this Intel artifact — the one NOT covered by CI.yml's arm64 macOS test
# job — is at least proven to launch before it auto-publishes.
- name: verify code signature + launch smoke
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
codesign --verify --verbose "$bin" \
|| { echo "::error::macOS binary has no valid code signature"; exit 1; }
"$bin" --version \
|| { echo "::error::macOS Intel binary failed to launch (--version) — un-runnable artifact"; exit 1; }
echo "OK: valid (ad-hoc) signature and the binary launches"
- name: upload macOS Intel artifact
uses: actions/upload-artifact@v5
with:
name: macos-intel-tarball
path: |
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz.sha256
if-no-files-found: error
# aarch64 (arm64) Linux release tarball + .deb — for ARM Linux (AWS Graviton,
# Ampere, Raspberry Pi OS 64-bit, Apple Silicon Linux VMs). A separate NATIVE
# leg: an arm64 ELF is never cross-compiled here (the static C deps build
# natively), so it gets its own arm64 runner. `ubuntu-22.04-arm` ships glibc
# 2.35 for the same broad-compatibility reason as the x86_64 leg (see header).
# A full build+gate peer of the publishing `release` job below — same static
# linkage, self-contained ldd check, conversion + embedded-resource smokes,
# and size budget — minus the publish (its assets are collected there).
build-linux-arm64:
name: Build Linux arm64 ${{ github.ref_name }}
runs-on: ubuntu-22.04-arm
timeout-minutes: 90
needs: [dumps, notices]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Same build-time + smoke-test apt set as the x86_64 `release` leg (TeX
# Live is needed for the conversion smoke below).
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxml2-dev libxslt1-dev libkpathsea-dev libkpathsea6 \
mold \
dpkg-dev \
texlive texlive-latex-extra texlive-science \
texlive-bibtex-extra texlive-publishers \
poppler-utils
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: release-maxperf-linux-arm64
# Built from source (not a prebuilt binary) for the same glibc reason as
# the x86_64 leg — the arm64 runner is ubuntu-22.04 (glibc 2.35).
- name: install cargo-deb
run: cargo install cargo-deb --locked
- name: collect TL-window dumps
uses: actions/download-artifact@v6
with:
pattern: dumps-*
path: resources/dumps/
merge-multiple: true
- name: verify dump window completeness
run: |
ls -la resources/dumps/
for year in 2022 2023 2024 2025 2026; do
for f in plain.$year.dump.txt latex.$year.dump.txt texlive.$year.version; do
if [ ! -s "resources/dumps/$f" ]; then
echo "::error::release dump window incomplete: missing resources/dumps/$f"
exit 1
fi
done
done
- name: build static libkpathsea from source
run: bash tools/build_static_kpathsea.sh
- name: build static libxml2 + libxslt from source
run: bash tools/build_static_libxml.sh
# make_release.sh bundles THIRD-PARTY-NOTICES.dist when present, else it
# silently falls back to the committed sections 1-4 (no Rust appendix).
- name: fetch assembled THIRD-PARTY-NOTICES
uses: actions/download-artifact@v4
with:
name: third-party-notices
# cargo-deb derives `Architecture: arm64` from the native host; make_release
# labels the FILENAME arm64 from RELEASE_TARGET. Produces the tarball + .deb.
- name: build release artifacts
run: bash tools/make_release.sh
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
RELEASE_TARGET: aarch64-unknown-linux-gnu
- name: binary size budget
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
size=$(stat -c%s "$bin"); mb=$(( size / 1024 / 1024 ))
echo "latexml_oxide (stripped, arm64): ${mb} MB (${size} bytes)"
budget=64
if [ "$mb" -gt "$budget" ]; then
echo "::error::binary ${mb} MB exceeds ${budget} MB budget (RELEASE_CRITERIA §2)"; exit 1
fi
echo "OK: within ${budget} MB size budget"
# Structural (no bundled C lib stays dynamic) + functional (a real
# conversion exercises the RelaxNG/XPath/XSLT paths through our static
# libxml2/libxslt on arm64 — catches a mis-trimmed static config that
# links but fails at runtime).
- name: verify self-contained binary
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
echo "checking $bin"; ldd "$bin" || true
if ldd "$bin" | grep -Eiq 'libxml2|libxslt|libexslt|libkpathsea'; then
echo "::error::release binary dynamically links a library that must be static"; exit 1
fi
echo "OK: glibc-only (no dynamic libxml2/libxslt/kpathsea)"
out="$(mktemp -d)"
"$bin" --destination="$out/hello.html" latexml_oxide/tests/hello/hello.tex 2>"$out/log" \
|| { echo "::error::release binary failed to run a conversion"; cat "$out/log"; exit 1; }
errs=$(sed 's/\x1b\[[0-9;]*m//g' "$out/log" | grep -acE '^(Error|Fatal):' || true)
if [ "$errs" != "0" ] || [ ! -s "$out/hello.html" ]; then
echo "::error::conversion smoke: $errs Error/Fatal, $(wc -c <"$out/hello.html" 2>/dev/null || echo 0) bytes out"
sed 's/\x1b\[[0-9;]*m//g' "$out/log" | grep -E '^(Error|Fatal):' | head; exit 1
fi
echo "OK: hello.tex converts clean on arm64 ($(wc -c <"$out/hello.html") bytes, 0 errors)"
- name: embedded-resource smoke (no own-resource disk read)
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
mv resources resources.hidden
out="$(mktemp -d)"; set +e
"$bin" --destination="$out/hello.html" latexml_oxide/tests/hello/hello.tex 2>"$out/log"
rc=$?; set -e
mv resources.hidden resources
if [ "$rc" -ne 0 ] || [ ! -s "$out/hello.html" ]; then
echo "::error::conversion failed with resources/ removed — self-containment regression"
cat "$out/log"; exit 1
fi
if ! grep -q 'ltx_' "$out/hello.html"; then
echo "::error::output missing ltx_ classes — embedded CSS/XSLT not applied"; exit 1
fi
echo "OK: converts with resources/ removed (fully self-contained)"
- name: upload Linux arm64 artifacts
uses: actions/upload-artifact@v5
with:
name: linux-arm64-artifacts
path: |
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz.sha256
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_arm64.deb
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_arm64.deb.sha256
if-no-files-found: error
# Windows (x86_64-pc-windows-msvc) — a single self-contained `latexml_oxide.exe`,
# shipped in a `.zip` (+ `.sha256`) so the binary travels with its license
# notices; there is no .deb equivalent. Uses the proven
# windows-ci-manual-trigger.yml recipe (vcpkg static libxml2/libxslt) but builds
# the publish-grade `maxperf` binary. (It was a bare `.exe` through 0.7.4-rc3,
# which made it the one download carrying no THIRD-PARTY-NOTICES — see
# LICENSE_INVENTORY F7.)
# `kpathsea` is linked IN-PROCESS via a STATIC libkpathsea built from source
# (`kpathsea-build-from-source`; see the build step) — no runtime
# `kpathsealibw64.dll`. At runtime `select_kpaths` keeps the fast in-process
# backend on TeX Live and falls back to subprocess `kpsewhich` on MiKTeX (whose
# MPM fndb a static libkpathsea can't read). The `.exe` is FULLY STATIC —
# static C libs (x64-windows-static) + static CRT (+crt-static) — so it imports
# only core OS DLLs and runs on any Windows with no VC++ redistributable.
# The TL-window dumps are OS-agnostic gzipped text, embedded at build time like
# the other legs (no TeX Live needed on THIS leg — dumps come from `dumps`).
build-windows:
name: Build Windows x86_64 ${{ github.ref_name }}
runs-on: windows-latest
# First-ever Windows release artifact; maxperf (fat-LTO) is heavier than the
# bring-up job's `ci` profile — give generous wall-clock. Windows Actions
# minutes are metered 2x on this private repo (see windows-ci-manual-trigger).
timeout-minutes: 120
needs: [dumps, notices]
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
RUST_BACKTRACE: "1"
# Fully static: static C libraries AND static CRT. `x64-windows-static`
# builds libxml2/libxslt against the static CRT (/MT), matching the
# `-C target-feature=+crt-static` Rust flag on the build step below — the
# whole image is one CRT model. Result: the `.exe` imports NO
# VCRUNTIME140.dll and runs on ANY Windows with no VC++ redistributable
# (ripgrep's posture; unlike the earlier static-md = dynamic-CRT build,
# which needed the redist). Flipping the triplet also rotates the vcpkg
# cache key below, forcing a fresh static-CRT build of the C libs.
VCPKG_TRIPLET: x64-windows-static
VCPKGRS_TRIPLET: x64-windows-static
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: release-maxperf-windows
- name: cache vcpkg installed tree
uses: actions/cache@v4
with:
path: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed
key: vcpkg-${{ env.VCPKG_TRIPLET }}-libxml2-libxslt-v1
- name: vcpkg install libxml2 + libxslt (static)
run: |
& "$env:VCPKG_INSTALLATION_ROOT\vcpkg" install `
"libxml2:$env:VCPKG_TRIPLET" "libxslt:$env:VCPKG_TRIPLET"
- name: export vcpkg root
run: '"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV'
# Same TL-window dumps as the other legs (OS-agnostic gzipped text).
- name: collect TL-window dumps
uses: actions/download-artifact@v6
with:
pattern: dumps-*
path: resources/dumps/
merge-multiple: true
- name: verify dump window completeness
shell: bash
run: |
ls -la resources/dumps/
for year in 2022 2023 2024 2025 2026; do
for f in plain.$year.dump.txt latex.$year.dump.txt texlive.$year.version; do
if [ ! -s "resources/dumps/$f" ]; then
echo "::error::release dump window incomplete: missing resources/dumps/$f"
exit 1
fi
done
done
# make_release.sh bundles THIRD-PARTY-NOTICES.dist when present, else it
# silently falls back to the committed sections 1-4 (no Rust appendix).
- name: fetch assembled THIRD-PARTY-NOTICES
uses: actions/download-artifact@v4
with:
name: third-party-notices
# make_release.sh runs under Git Bash (present on windows-latest); its
# os_family=windows branch builds maxperf and packages latexml_oxide.exe
# into a `.zip` alongside the notices.
# libxml2/libxslt are discovered from the vcpkg env set above.
- name: build Windows release .zip
shell: bash
run: bash tools/make_release.sh
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
RELEASE_TARGET: x86_64-pc-windows-msvc
# IN-PROCESS kpathsea via a STATIC libkpathsea built from source
# (`kpathsea-build-from-source`). This removes the subprocess
# `ls-R`-cache-build cost (~0.5 s/conversion — the RC's dominant
# Windows overhead on simple docs; WINDOWS_COMPATIBILITY_PLAN Phase 5.1)
# AND stays self-contained: a STATIC link has no runtime
# `kpathsealibw64.dll` dependency, so it launches on any Windows / any
# TeX distro — unlike the dynamic-DLL path (the Phase-2 launch landmine)
# that the old `KPATHSEA_NO_LINK=1` subprocess backend avoided at a perf
# cost. `kpathsea_sys` fetches the pinned kpathsea source at build time
# (git on the runner; LGPL — see THIRD-PARTY-NOTICES, same posture as the
# Linux/macOS `build_static_kpathsea.sh` legs).
# * RELEASE_EXTRA_FEATURES → make_release.sh appends the feature.
# * KPATHSEA_SKIP_TOOLCHAIN_CHECK=1 — this runner has no TeX; a linked
# (in-process) build needs no build-time `kpsewhich`.
# Combined with the `+crt-static` / x64-windows-static leg config below
# (drops VCRUNTIME140), this yields a fully-static, redistributable `.exe`.
RELEASE_EXTRA_FEATURES: kpathsea-build-from-source
KPATHSEA_SKIP_TOOLCHAIN_CHECK: "1"
# Static CRT: statically link the VC runtime + UCRT so the shipped
# `.exe` has NO VCRUNTIME140.dll dependency and launches on a clean
# Windows with no VC++ redistributable. MUST pair with the
# x64-windows-static vcpkg triplet above (one CRT model across Rust
# std + the C libs; mixing /MT and /MD is unsupported). RUSTFLAGS env
# REPLACES `.cargo/config.toml` rustflags, so re-list the
# frontend-parallelism flags to keep them.
RUSTFLAGS: "-Zthreads=8 -Zunstable-options -C target-feature=+crt-static"
# Launch smoke + self-containment guard. `--version` alone is NOT enough:
# the runner has the VC++ redist, so even a dynamic-CRT `.exe` would launch
# here. So we ALSO assert (via dumpbin) that the import table carries no
# VCRUNTIME — the actual property that lets the `.exe` run on a clean
# Windows. Conversion paths aren't exercised (no TeX Live on this leg);
# windows-ci-manual-trigger.yml runs the full suite with TeX Live.
- name: launch smoke + self-containment guard
shell: bash
run: |
set -euo pipefail
exe=$(find target/release-artifacts -name '*.exe' -type f | head -1)
echo "checking $exe"
"$exe" --version || { echo "::error::Windows .exe failed to launch (--version)"; exit 1; }
echo "OK: the Windows .exe launches"
# `--version` alone is NOT enough: the runner has the VC++ redist + TeX
# nowhere near, so even a non-portable exe launches. Assert (via dumpbin,
# located through vswhere) that the import table carries NONE of the
# things that would tie the exe to this machine: the VC runtime
# (+crt-static), kpathsea's DLL (build_from_source static, not the
# dynamic kpathsealibw64.dll path), or libxml2/libxslt (vcpkg static).
vswhere="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
vsroot=$("$vswhere" -latest -property installationPath)
dumpbin=$(find "$vsroot/VC/Tools/MSVC" -name dumpbin.exe -ipath '*hostx64*x64*' | head -1)
deps=$("$dumpbin" //DEPENDENTS "$exe")
if echo "$deps" | grep -qiE 'vcruntime|kpathsea|libxml|libxslt'; then
echo "$deps"
echo "::error::the .exe imports a non-OS DLL (VCRUNTIME / kpathsea / libxml*) — it is not fully static (check +crt-static, x64-windows-static, and kpathsea-build-from-source all took effect)"
exit 1
fi
echo "OK: imports only core OS DLLs — fully static, runs on any Windows / any TeX distro"
- name: upload Windows artifact
uses: actions/upload-artifact@v5
with:
name: windows-zip
path: |
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip.sha256
if-no-files-found: error
release:
name: Build and publish ${{ github.ref_name }}
runs-on: ubuntu-22.04
timeout-minutes: 60
needs: [dumps, notices, build-macos, build-macos-intel, build-linux-arm64, build-windows]
steps:
- uses: actions/checkout@v6
with:
# Full history so CHANGELOG.md slicing can see prior entries
# if we extend the release-body extraction later.
fetch-depth: 0
# Runtime + build-time apt deps. Matches CI.yml's set, minus
# language packs the release build doesn't exercise.
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxml2-dev libxslt1-dev libkpathsea-dev libkpathsea6 \
mold \
dpkg-dev \
texlive texlive-latex-extra texlive-science \
texlive-bibtex-extra texlive-publishers \
poppler-utils
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
# Release builds use `--profile maxperf`; keep its target dir
# warm across re-runs of the same tag (rare, but possible
# when fixing the workflow itself before re-tagging).
cache-on-failure: true
key: release-maxperf
# Build cargo-deb from source rather than using a prebuilt binary
# (taiki-e/install-action): the prebuilt cargo-deb is linked against a
# newer glibc (2.39) than this ubuntu-22.04 runner ships (2.35, chosen for
# broad binary compatibility — see the header), so it fails at runtime with
# `GLIBC_2.39 not found`. Compiling here links it against the runner's own
# glibc. rust-cache keeps the cargo registry warm across re-runs.
- name: install cargo-deb
run: cargo install cargo-deb --locked
# Collect the TL-window dumps generated by the `dumps` job into
# resources/dumps/ so the maxperf build embeds all of them
# (latexml_engine/build.rs scans this directory). Strict
# completeness gate: 5 years x (plain + latex + version stamp).
- name: collect TL-window dumps
uses: actions/download-artifact@v6
with:
pattern: dumps-*
path: resources/dumps/
merge-multiple: true
- name: verify dump window completeness
run: |
ls -la resources/dumps/
# The full 5-year moving window. 2026 rejoined it 2026-07-23 (the
# TL2026 container and the zero-error init gate both cleared — see
# the matrix comment in release-dumps.yml), so a TL2026 host now gets
# an exact-year dump instead of falling back to the 2025 one.
for year in 2022 2023 2024 2025 2026; do
for f in plain.$year.dump.txt latex.$year.dump.txt texlive.$year.version; do
if [ ! -s "resources/dumps/$f" ]; then
echo "::error::release dump window incomplete: missing resources/dumps/$f"
exit 1
fi
done
done
# Static, self-contained libkpathsea (in-process lookups, no runtime
# libkpathsea6 dependency — dropping it from the .deb's $auto deps).
# Built from source with --with-pic: the system libkpathsea-dev .a is
# non-PIC and is rejected when our proc-macro cdylib links kpathsea.
# Sets KPATHSEA_LIB_DIR + KPATHSEA_STATIC for the build below.
- name: build static libkpathsea from source
run: bash tools/build_static_kpathsea.sh
# Static, self-contained libxml2 + libxslt + libexslt. Source-built PIC (the
# system libxml2.a is non-PIC and our proc-macro cdylib links libxml; libxslt
# ships no system .a at all). Makes the binary independent of the host libxml2
# SONAME (.so.2 -> .so.16 at libxml2 2.14) and drops libxml2/libxslt from the
# .deb's $auto deps. Sets PKG_CONFIG_PATH + LIBXML2_STATIC + LIBXSLT_STATIC.
- name: build static libxml2 + libxslt from source
run: bash tools/build_static_libxml.sh
# The complete THIRD-PARTY-NOTICES (hand-authored sections 1-4 +
# cargo-about's per-crate licenses + the section 6 copyleft texts) is
# assembled ONCE by the `notices` job and shared with every packaging leg,
# so all platforms ship byte-identical notices. make_release.sh bundles it
# into the tarball/.deb, and it publishes as a standalone asset below.
# See docs/release/LICENSE_INVENTORY.md §A / RELEASE_CRITERIA §4 (F4/F7).
- name: fetch assembled THIRD-PARTY-NOTICES
uses: actions/download-artifact@v4
with:
name: third-party-notices
- name: build release artifacts
run: bash tools/make_release.sh
env:
# make_release.sh verifies this matches the Cargo.toml version
# and refuses to build on mismatch.
GITHUB_REF_NAME: ${{ github.ref_name }}
# Include the macOS install sections in the shared release body. The
# tarball files themselves are collected from the build-macos /
# build-macos-intel artifacts in a later step — make_release.sh wipes
# target/release-artifacts/ at start, so the downloads MUST follow it.
RELEASE_MACOS_TARBALL: latexml-oxide-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz
RELEASE_MACOS_INTEL_TARBALL: latexml-oxide-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz
RELEASE_LINUX_ARM64_TARBALL: latexml-oxide-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz
RELEASE_WINDOWS_ZIP: latexml-oxide-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip
# Artifact-property gate (RELEASE_CRITERIA §2/§7): fail the release if the
# stripped binary blows the size budget (a runaway-growth alarm), and stage
# the assembled notices for publishing. (Corpus smoke + telemetry are
# deliberately NOT run in CI — too expensive.)
- name: binary size budget + stage notices
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
size=$(stat -c%s "$bin"); mb=$(( size / 1024 / 1024 ))
echo "latexml_oxide (stripped): ${mb} MB (${size} bytes)"
budget=64
if [ "$mb" -gt "$budget" ]; then
echo "::error::binary ${mb} MB exceeds ${budget} MB budget (RELEASE_CRITERIA §2)"; exit 1
fi
echo "OK: within ${budget} MB size budget"
cp THIRD-PARTY-NOTICES.dist target/release-artifacts/THIRD-PARTY-NOTICES
# Fail the release if the Linux binary still dynamically links a C library we
# bundle — the point is a glibc-only, SONAME-independent artifact. Runs before
# the macOS tarball is downloaded, so `find` inspects the Linux binary.
- name: verify self-contained binary
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
echo "checking $bin"; ldd "$bin" || true
if ldd "$bin" | grep -Eiq 'libxml2|libxslt|libexslt|libkpathsea'; then
echo "::error::release binary dynamically links a library that must be static"; exit 1
fi
echo "OK: glibc-only (no dynamic libxml2/libxslt/kpathsea)"
# Functional gate (complements the structural ldd above): our static
# libxml2/libxslt are built with a deliberately minimal feature set, so a
# mis-trimmed config could drop something LaTeXML needs — that would LINK
# fine but fail at conversion. Convert a real document (exercises the
# RelaxNG / XPath / XSLT paths) and assert zero Error/Fatal. TeX Live is
# installed on this leg.
out="$(mktemp -d)"
"$bin" --destination="$out/hello.html" latexml_oxide/tests/hello/hello.tex 2>"$out/log" \
|| { echo "::error::release binary failed to run a conversion"; cat "$out/log"; exit 1; }
errs=$(sed 's/\x1b\[[0-9;]*m//g' "$out/log" | grep -acE '^(Error|Fatal):' || true)
if [ "$errs" != "0" ] || [ ! -s "$out/hello.html" ]; then
echo "::error::conversion smoke: $errs Error/Fatal, $(wc -c <"$out/hello.html" 2>/dev/null || echo 0) bytes out"
sed 's/\x1b\[[0-9;]*m//g' "$out/log" | grep -E '^(Error|Fatal):' | head; exit 1
fi
echo "OK: hello.tex converts clean ($(wc -c <"$out/hello.html") bytes, 0 errors)"
# Prove the binary reads NONE of its own resources from disk: move the
# dev-tree resources/ aside and re-run a conversion. Dumps, XSLT, RelaxNG,
# and CSS must all come from the embedded tables (RELEASE_CRITERIA §7;
# complements tests/001_single_binary_smoke.rs, which only isolates cwd).
- name: embedded-resource smoke (no own-resource disk read)
run: |
bin=$(find target/release-artifacts -name latexml_oxide -type f | head -1)
mv resources resources.hidden
out="$(mktemp -d)"; set +e
"$bin" --destination="$out/hello.html" latexml_oxide/tests/hello/hello.tex 2>"$out/log"
rc=$?; set -e
mv resources.hidden resources
if [ "$rc" -ne 0 ] || [ ! -s "$out/hello.html" ]; then
echo "::error::conversion failed with resources/ removed — self-containment regression"
cat "$out/log"; exit 1
fi
if ! grep -q 'ltx_' "$out/hello.html"; then
echo "::error::output missing ltx_ classes — embedded CSS/XSLT not applied"; exit 1
fi
echo "OK: converts with resources/ removed (fully self-contained)"
# Pull the macOS tarball (+ sidecar) built by the build-macos job into
# the staging dir so they publish alongside the Linux artifacts.
- name: collect macOS tarball
uses: actions/download-artifact@v6
with:
name: macos-tarball
path: target/release-artifacts/
- name: collect macOS Intel tarball
uses: actions/download-artifact@v6
with:
name: macos-intel-tarball
path: target/release-artifacts/
# Pull the arm64 Linux tarball + .deb (+ sidecars) built by
# build-linux-arm64 into the staging dir so they publish alongside the
# x86_64 Linux + macOS assets.
- name: collect Linux arm64 artifacts
uses: actions/download-artifact@v6
with:
name: linux-arm64-artifacts
path: target/release-artifacts/
# Pull the single Windows .exe (+ sidecar) built by build-windows.
- name: collect Windows .zip
uses: actions/download-artifact@v6
with:
name: windows-zip
path: target/release-artifacts/
# PUBLISH. Behaviour is chosen by the tag shape (see draft/prerelease below):
# * Final tag X.Y.Z -> a PUBLIC release, zero manual steps.
# * RC tag X.Y.Z-rcN -> a DRAFT prerelease for cross-OS testing.
# Auto-publishing a final tag is safe because every asset is gated before
# it gets here: each build leg verifies static linkage (ldd/otool), runs a
# real conversion smoke (Linux legs) or a launch smoke + code-signature
# check (macOS legs) or a launch smoke (Windows leg), and enforces the size
# budget; macOS conversion paths are additionally covered by CI.yml's macOS
# test job. The build stays tied to a release tag (no workflow_dispatch, no
# "random" builds). If a published asset is ever found broken, delete the
# Release + tag, bump the patch, and re-tag (RELEASING.md "Failure recovery").
- name: publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body_path: target/release-artifacts/RELEASE_BODY.md
# Append GitHub's auto-generated "What's Changed" — every merged PR
# since the previous tag — below our curated RELEASE_BODY summary
# (softprops pre-pends `body` to the generated notes). Brief, high-level
# highlights up top + the full per-PR list underneath.
generate_release_notes: true
files: |
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz.sha256
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_amd64.deb
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_amd64.deb.sha256
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz.sha256
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_arm64.deb
target/release-artifacts/latexml-oxide_${{ github.ref_name }}-1_arm64.deb.sha256
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz.sha256
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz.sha256
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip
target/release-artifacts/latexml-oxide-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip.sha256
target/release-artifacts/THIRD-PARTY-NOTICES
# RC / pre-release tags (containing a '-', e.g. 0.7.4-rc1) publish as a
# DRAFT prerelease — a maintainer tests the attached executables on each
# OS, then clicks Publish (or deletes the draft). Final tags (bare
# X.Y.Z) auto-publish a public release.
draft: ${{ contains(github.ref_name, '-') }}
prerelease: ${{ contains(github.ref_name, '-') }}
fail_on_unmatched_files: true
# Container images for a FINAL tag, gated on the release above actually
# publishing (`needs: release`) — so `:latest` never points at a version with no
# downloadable release. RC tags are skipped here: their draft is unreviewed, and
# an RC must not take `:latest`. To build images for an RC, publish its draft
# (docker.yml's `release: published` trigger) or dispatch docker.yml directly;
# both leave `:latest` alone.
#
# Why a call and not a `release: published` listener inside docker.yml: this job
# publishes with GITHUB_TOKEN, and GitHub will not start a workflow from a
# GITHUB_TOKEN-authored event (recursion guard). 0.7.4's images were missing for
# exactly that reason, with no failure to notice. See docs/release/RELEASING.md.
containers:
name: Container images ${{ github.ref_name }}
needs: release
if: ${{ !contains(github.ref_name, '-') }}
# The workflow-level grant is `contents: write`; pushing to GHCR additionally
# needs `packages: write`, and a called workflow cannot escalate past what the
# calling job hands it — so it must be granted right here.
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker.yml
with:
ref: ${{ github.ref_name }}
latest: true