diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bfeab3..773c695 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,21 @@ jobs: - name: Build run: nix build ".#packages.${{ matrix.system }}.${{ matrix.package }}" + - name: Verify reported version + if: matrix.package == 'default' + run: | + set -euo pipefail + expected="brainpod $(tr -d '[:space:]' < VERSION)" + if ! reported="$(./result/bin/brainpod --version 2>&1)"; then + echo "::error::could not run the ${{ matrix.system }} binary on ${{ matrix.runner }}: $reported" + exit 1 + fi + if [ "$reported" != "$expected" ]; then + echo "::error::binary reports '$reported' but VERSION expects '$expected'" + exit 1 + fi + echo "$reported" + - name: Package Unix binary if: matrix.package == 'default' run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5f171b..7bf1c07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,24 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Resolve version from release tag + id: version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + if [ -z "${TAG:-}" ]; then + echo "::error::release tag_name is empty, refusing to build an unversioned release" + exit 1 + fi + version="${TAG#v}" + if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::release tag '$TAG' is not a semver version, refusing to ship a wrong version" + exit 1 + fi + printf '%s\n' "$version" > VERSION + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} @@ -59,6 +77,22 @@ jobs: - name: Build run: nix build ".#packages.${{ matrix.system }}.${{ matrix.package }}" + - name: Verify reported version + if: matrix.package == 'default' + env: + EXPECTED: brainpod ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if ! reported="$(./result/bin/brainpod --version 2>&1)"; then + echo "::error::could not run the ${{ matrix.system }} binary on ${{ matrix.runner }}: $reported" + exit 1 + fi + if [ "$reported" != "$EXPECTED" ]; then + echo "::error::binary reports '$reported' but the release tag expects '$EXPECTED'" + exit 1 + fi + echo "$reported" + - name: Package Unix binary if: matrix.package == 'default' run: | diff --git a/Cargo.lock b/Cargo.lock index faf7c8c..90ae9ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,7 +246,7 @@ dependencies = [ [[package]] name = "brainpod-cli" -version = "0.1.0" +version = "0.0.0" dependencies = [ "anyhow", "axum 0.8.9", diff --git a/Cargo.toml b/Cargo.toml index 2ea6afb..66e8108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "brainpod-cli" -version = "0.1.0" +version = "0.0.0" edition = "2024" [[bin]] diff --git a/README.md b/README.md index 0565de0..2422155 100644 --- a/README.md +++ b/README.md @@ -373,3 +373,21 @@ Create and deploy: brainpod resource create --file resources.json --json brainpod deploy --summary "Configure application resources" --json ``` + +## Versioning + +`brainpod --version` reports the release the binary was built from. The release workflow derives that version from the published git tag, so no version field has to be bumped by hand: + +```sh +brainpod --version +brainpod 0.0.6 +``` + +The tag is written to `VERSION`, read by `flake.nix`, and passed to the build as `BRAINPOD_VERSION`. Builds that the release workflow did not stamp report `0.0.0-dev`, suffixed with the commit they were built from when git metadata is available: + +```sh +brainpod --version +brainpod 0.0.0-dev+d4ac667a1b2c +``` + +`Cargo.toml` deliberately stays at `0.0.0`; it is not the release version. Both the build and release workflows run the binary they just built and fail if its reported version disagrees. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..cb676de --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.0-dev diff --git a/build.rs b/build.rs index 4714397..9697787 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,18 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=proto"); + println!("cargo:rerun-if-env-changed=BRAINPOD_VERSION"); + + let version = std::env::var("BRAINPOD_VERSION") + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(development_version); + println!("cargo:rustc-env=BRAINPOD_VERSION={version}"); + let descriptors = protox::compile( [ "proto/brainpod/tunnel/v1/broker.proto", @@ -14,3 +28,52 @@ fn main() -> Result<(), Box> { Ok(()) } + +/// Version for builds the release workflow did not stamp, carrying the commit +/// it was built from when git metadata is reachable, so a local build is never +/// mistaken for a published release. +fn development_version() -> String { + let base = format!( + "{}-dev", + std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_owned()) + ); + + match commit_sha() { + Some(sha) => format!("{base}+{sha}"), + None => base, + } +} + +fn commit_sha() -> Option { + watch_git_head(Path::new(".git")); + + let output = Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let sha = String::from_utf8(output.stdout).ok()?.trim().to_owned(); + (!sha.is_empty()).then_some(sha) +} + +fn watch_git_head(git_dir: &Path) { + let head = git_dir.join("HEAD"); + if !head.exists() { + return; + } + + println!("cargo:rerun-if-changed={}", head.display()); + + let Ok(contents) = fs::read_to_string(&head) else { + return; + }; + if let Some(reference) = contents.strip_prefix("ref: ") { + println!( + "cargo:rerun-if-changed={}", + git_dir.join(reference.trim()).display() + ); + } +} diff --git a/flake.nix b/flake.nix index 6af1e20..3019e10 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,11 @@ overlays = [ (import rust-overlay) ]; eachSystem = nixpkgs.lib.genAttrs supportedSystems; + # The release workflow rewrites ./VERSION from the release tag before + # `nix build`. It is deliberately outside the crane source filter below, + # so stamping a release does not invalidate cargoArtifacts. + version = nixpkgs.lib.head (nixpkgs.lib.splitString "\n" (builtins.readFile ./VERSION)); + rustTargetFor = pkgs: if pkgs.stdenv.hostPlatform.isWindows then @@ -108,7 +113,8 @@ craneLib.buildPackage ( craneArgs // { - inherit cargoArtifacts; + inherit cargoArtifacts version; + BRAINPOD_VERSION = version; meta.mainProgram = if pkgs.stdenv.hostPlatform.isWindows then "brainpod.exe" else "brainpod"; } // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { diff --git a/src/describe.rs b/src/describe.rs index bb6796c..2ed66c2 100644 --- a/src/describe.rs +++ b/src/describe.rs @@ -28,7 +28,7 @@ pub fn generate(mut root: Command, path: &[String]) -> Result { Ok(json!({ "schemaVersion": 1, - "cliVersion": root.get_version().unwrap_or(env!("CARGO_PKG_VERSION")), + "cliVersion": root.get_version().unwrap_or(env!("BRAINPOD_VERSION")), "command": describe_command(command, &resolved_path), "globalArguments": global_arguments, "outputContract": { diff --git a/src/main.rs b/src/main.rs index 630be05..87f42b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ const UPGRADE_URL: &str = "https://console.brainpod.io/onboarding?upgrade=1"; #[derive(Debug, Parser)] #[command( name = "brainpod", - version, + version = env!("BRAINPOD_VERSION"), about = "Manage Brainpod deployments, images, resources, and database tunnels", after_help = "For machine-readable command metadata, run `brainpod describe --json`." )]