diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 250aa835..9fd90e79 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -91,3 +91,15 @@ blocks: commands: - "just lint-light --show-diff-on-failure --all" - "just lint-heavy" + - name: "Tag" + dependencies: [] + run: + when: "tag =~ '^v[[:digit:]]+$'" + task: + jobs: + - name: "Build & stamp tagged Opsqueue binary" + commands: + # Build the binary with the release tag stamped in. The Rust compilation is + # pulled from cachix (built by the regular Build block); only the fast + # binary-patching step runs here. + - "nix-store -qR --include-outputs $(nix-store -qd $(just nix-build-bin-tagged \"$SEMAPHORE_GIT_TAG_NAME\")) | grep -v '\\.drv$' | cachix push channable" diff --git a/justfile b/justfile index c73f5c6f..bc3eeeb0 100644 --- a/justfile +++ b/justfile @@ -111,5 +111,10 @@ nix-build-bin: (_nix-build "opsqueue") [group('nix')] nix-build-python: (_nix-build "python.pkgs.opsqueue_python") +# Build Nix-derivation of binary with a release tag stamped in without recompiling (release profile) +[group('nix')] +nix-build-bin-tagged TAG: + nix build --expr "(import ./nix/nixpkgs-pinned.nix {}).opsqueue.withTag \"{{TAG}}\"" --print-out-paths --print-build-logs --no-link --option sandbox true + _nix-build +TARGETS: nix build --file nix/nixpkgs-pinned.nix --print-out-paths --print-build-logs --no-link --option sandbox true {{TARGETS}} diff --git a/opsqueue/opsqueue.nix b/opsqueue/opsqueue.nix index 95e8ac77..65396c80 100644 --- a/opsqueue/opsqueue.nix +++ b/opsqueue/opsqueue.nix @@ -44,16 +44,35 @@ let # serially, this is the slowest part on CI. We have other CI linting steps, i.e. `cargo clippy`, # that report these errors earlier. cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { cargoCheckCommand = "true"; }); -in -craneLib.buildPackage ( - commonArgs - // { - inherit cargoArtifacts; - # Needed for the SQLx macros: - env = { - DATABASE_URL = "sqlite:///build/opsqueue/opsqueue/opsqueue_example_database_schema.db"; - }; + opsqueuePkg = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; - } -) + # Needed for the SQLx macros: + env = { + DATABASE_URL = "sqlite:///build/opsqueue/opsqueue/opsqueue_example_database_schema.db"; + }; + } + ); +in +opsqueuePkg.overrideAttrs (old: { + passthru = (old.passthru or { }) // { + # Stamp a release tag into the pre-compiled binary without recompiling Rust. + # + # Usage (in a downstream Nix derivation): + # pkgs.opsqueue.withTag "v1.2.3" + # + # The `pkgs.opsqueue` derivation is built once and cached; only the fast + # binary-patching step reruns when the tag changes. + withTag = + tag: + pkgs.runCommand "${opsqueuePkg.name}-tagged" { nativeBuildInputs = [ pkgs.python3 ]; } '' + mkdir -p $out/bin + cp ${opsqueuePkg}/bin/opsqueue $out/bin/opsqueue + chmod +w $out/bin/opsqueue + python3 ${./stamp-tag.py} "$out/bin/opsqueue" ${lib.escapeShellArg tag} + ''; + }; +}) diff --git a/opsqueue/src/lib.rs b/opsqueue/src/lib.rs index 7adfd4c8..d49cfbef 100644 --- a/opsqueue/src/lib.rs +++ b/opsqueue/src/lib.rs @@ -43,10 +43,25 @@ pub mod config; /// as written in the Rust packages's `Cargo.toml` pub const VERSION_CARGO_SEMVER: &str = env!("CARGO_PKG_VERSION"); -#[allow(clippy::const_is_empty)] +/// Fixed-size buffer holding the release tag stamped into the binary during the Nix build. +/// +/// Layout: `OPSQUEUE_TAG:` (13 bytes, marker) + tag value (up to 51 bytes, null-padded). +/// The Nix build patches the 51-byte tag field in the pre-compiled binary via [`stamp-tag.py`] +/// without recompiling Rust, keeping the compilation cached across tag-only changes. +/// +/// [`stamp-tag.py`]: ../../stamp-tag.py +#[used] +#[unsafe(no_mangle)] +pub static OPSQUEUE_RELEASE_TAG: [u8; 64] = + *b"OPSQUEUE_TAG:dev\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + #[must_use] pub fn version_info() -> String { - format!("v{VERSION_CARGO_SEMVER}") + const MARKER_LEN: usize = b"OPSQUEUE_TAG:".len(); + let tag = std::str::from_utf8(&OPSQUEUE_RELEASE_TAG[MARKER_LEN..]) + .unwrap_or("invalid-utf8") + .trim_end_matches('\0'); + format!("v{VERSION_CARGO_SEMVER} ({tag})") } /// Shared constant with the migrations that all the tests can reference, to avoid the generated diff --git a/opsqueue/stamp-tag.py b/opsqueue/stamp-tag.py new file mode 100644 index 00000000..c2995c59 --- /dev/null +++ b/opsqueue/stamp-tag.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Patch the OPSQUEUE_TAG: marker in a compiled opsqueue binary. + +Usage: stamp-tag.py + +The binary must contain the 64-byte OPSQUEUE_RELEASE_TAG symbol: + - Bytes 0..12 : b'OPSQUEUE_TAG:' (marker, unchanged) + - Bytes 13..63 : tag value, null-padded to 51 bytes (patched here) + +This script is called by the Nix `withTag` build to stamp a release tag +into the pre-compiled binary without recompiling Rust. +""" + +import sys + +MARKER = b"OPSQUEUE_TAG:" +TOTAL_FIELD_LEN = 64 +TAG_FIELD_LEN = TOTAL_FIELD_LEN - len(MARKER) # 51 bytes + + +def main() -> None: + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + binary_path = sys.argv[1] + tag = sys.argv[2] + + tag_bytes = tag.encode("utf-8") + if len(tag_bytes) > TAG_FIELD_LEN: + print( + f"Error: tag is {len(tag_bytes)} bytes, max is {TAG_FIELD_LEN}", + file=sys.stderr, + ) + sys.exit(1) + + with open(binary_path, "rb") as f: + data = f.read() + + pos = data.find(MARKER) + if pos == -1: + print(f"Error: marker {MARKER!r} not found in {binary_path}", file=sys.stderr) + sys.exit(1) + + second = data.find(MARKER, pos + 1) + if second != -1: + print( + f"Error: marker {MARKER!r} found more than once (at {pos} and {second})", + file=sys.stderr, + ) + sys.exit(1) + + padded_tag = tag_bytes.ljust(TAG_FIELD_LEN, b"\x00") + patched = data[: pos + len(MARKER)] + padded_tag + data[pos + TOTAL_FIELD_LEN :] + + if len(patched) != len(data): + print("Error: patched binary has a different size", file=sys.stderr) + sys.exit(1) + + with open(binary_path, "wb") as f: + f.write(patched) + + print(f"Stamped tag {tag!r} into {binary_path}") + + +if __name__ == "__main__": + main()