diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c68571..87b2816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,116 +1,35 @@ - -name: Release Binary +name: Release on: push: tags: + # Match semantic version tags (e.g. v1.2.3, v10.11.12, v12.3.7-beta7) - 'v*.*.*' -env: - REPO_NAME: ${{ github.event.repository.name }} - jobs: - build: + goreleaser: runs-on: ubuntu-latest - strategy: - matrix: - include: - - os: linux - arch: x86_64 - target: x86_64-unknown-linux-gnu - ext: "" - - os: linux - arch: aarch64 - target: aarch64-unknown-linux-gnu - ext: "" - - os: windows - arch: x86_64 - target: x86_64-pc-windows-msvc - ext: ".exe" - - os: windows - arch: aarch64 - target: aarch64-pc-windows-msvc - ext: ".exe" - - os: macos - arch: x86_64 - target: x86_64-apple-darwin - ext: "" - - os: macos - arch: aarch64 - target: aarch64-apple-darwin - ext: "" - - os: freebsd - arch: x86_64 - target: x86_64-unknown-freebsd - ext: "" - - os: freebsd - arch: aarch64 - target: aarch64-unknown-freebsd - ext: "" - - os: openbsd - arch: x86_64 - target: x86_64-unknown-openbsd - ext: "" - - os: openbsd - arch: aarch64 - target: aarch64-unknown-openbsd - ext: "" steps: - - uses: actions/checkout@v4 - - uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 - with: - toolchain: stable - profile: minimal - override: true - - name: Cache cross binary - uses: actions/cache@v4 - with: - path: ~/.cargo/bin/cross - key: cross-${{ runner.os }} - - name: Install cross - run: cargo install cross --git https://github.com/cross-rs/cross - - name: Cache cargo registry - uses: actions/cache@v4 + - name: Checkout + uses: actions/checkout@v4 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - name: Build release binary - run: cross +stable build --release --target ${{ matrix.target }} - - name: Prepare artifact - run: | - mkdir -p artifacts/${{ matrix.os }}-${{ matrix.arch }} - cp target/${{ matrix.target }}/release/${{ env.REPO_NAME }}${{ matrix.ext }} \ - artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} - sha256sum artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} > \ - artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}.sha256 - - name: Upload release artifact - uses: actions/upload-artifact@v4 + fetch-depth: 0 + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable with: - name: ${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }} - path: artifacts/${{ matrix.os }}-${{ matrix.arch }} - - release: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: softprops/action-gh-release@v1 + toolchain: stable + targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu + cache: cargo + - name: Set up Go + uses: actions/setup-go@v5 with: - generate_release_notes: true - - uses: actions/download-artifact@v4 + go-version: '1.21' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 with: - path: artifacts - - run: | - for dir in artifacts/${{ env.REPO_NAME }}-*; do - for file in "$dir"/*; do - gh release upload "${{ github.ref_name }}" "$file" - done - done + distribution: goreleaser + # Use a fixed version to ensure reproducibility + version: v1.24.0 + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/Cargo.toml b/Cargo.toml index 0c02f38..2609a02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ path = "src/lib.rs" [dependencies] serde = { workspace = true } +serde_yaml = { workspace = true } serde_json = { workspace = true } [dev-dependencies] @@ -22,7 +23,6 @@ yaque = { workspace = true } wiremock = "0.6" octocrab = { workspace = true } test-support = { path = "test-support" } -serde_yaml = "0.9" [[test]] name = "cucumber" @@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1.0" thiserror = "1.0" ortho_config = { git = "https://github.com/leynos/ortho-config.git", tag = "v0.4.0" } +serde_yaml = "0.9" tempfile = "3.10" [lints.clippy] diff --git a/docs/automated-cross-platform-packaging.md b/docs/automated-cross-platform-packaging.md index 631686d..ac71089 100644 --- a/docs/automated-cross-platform-packaging.md +++ b/docs/automated-cross-platform-packaging.md @@ -4,8 +4,9 @@ This guide provides a step-by-step process for configuring a GitHub Actions workflow to automatically build and package the `comenq` client and `comenqd` -daemon for Linux (Fedora, Ubuntu) and macOS. We will use GoReleaser to manage -the entire process, from building the Rust binaries to creating platform-native +daemon for Linux (Fedora, Ubuntu) and macOS. macOS packaging is currently on +hold, so the workflow focuses on Linux targets only. GoReleaser manages the +entire process, from building the Rust binaries to creating platform-native packages (`.rpm`, `.deb`) and a Homebrew formula. The core of this process involves creating a `.goreleaser.yaml` file that @@ -262,7 +263,7 @@ name: Release on: push: tags: - - 'v*' + - 'v[0-9]*.[0-9]*.[0-9]*' jobs: goreleaser: @@ -277,23 +278,19 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable - targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin + targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu + cache: cargo - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.21' - - name: Install GoReleaser - uses: goreleaser/goreleaser-action@v5 - with: - install-only: true - - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser - version: latest + version: v1.24.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/comenq-design.md b/docs/comenq-design.md index a7e1686..452834c 100644 --- a/docs/comenq-design.md +++ b/docs/comenq-design.md @@ -721,7 +721,9 @@ To simplify installation, the project uses GoReleaser. The declarative hooks. The `nfpms` section produces signed `.deb` and `.rpm` packages for Fedora and Ubuntu, embedding the hardened `systemd` service unit and lifecycle scripts that create the `comenq` user. This keeps packaging logic version -controlled and repeatable. +controlled and repeatable. A GitHub Actions workflow triggers on version tags +to run GoReleaser. It builds Linux packages and uploads them to a draft +release. Mac support is currently deferred, so the workflow targets Linux only. ## Section 5: Complete Source Code and Project Manifest diff --git a/docs/roadmap.md b/docs/roadmap.md index 4292ea6..4761c69 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -105,15 +105,15 @@ and macOS, simplifying installation and improving security and maintainability. - [x] Configure GoReleaser's `nfpms` section to build and sign `.rpm` and `.deb` packages. -- [ ] **Automate the Release Workflow** +- [x] **Automate the Release Workflow** - - [ ] Implement a GitHub Actions workflow that triggers on new version tags + - [x] Implement a GitHub Actions workflow that triggers on new version tags (e.g., `v*`). - - [ ] The workflow will orchestrate the entire release: checking out the + - [x] The workflow will orchestrate the entire release: checking out the code, installing dependencies, and executing GoReleaser. - - [ ] GoReleaser will then build the binaries, create all packages, publish + - [x] GoReleaser will then build the binaries, create all packages, publish the Homebrew formula, generate a changelog from git history, and upload all assets to a draft GitHub Release. diff --git a/src/lib.rs b/src/lib.rs index 7562f44..36eb3e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! and daemon. use serde::{Deserialize, Serialize}; +pub mod workflow; /// Request sent from the client to the daemon. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/workflow.rs b/src/workflow.rs new file mode 100644 index 0000000..0d09fc6 --- /dev/null +++ b/src/workflow.rs @@ -0,0 +1,59 @@ +//! Utilities for inspecting GitHub workflow files. + +use serde_yaml::Value; + +/// Return `true` if the workflow steps include the `GoReleaser` action. +/// +/// # Errors +/// +/// Returns an error if the YAML cannot be parsed. +pub fn uses_goreleaser(yaml: &str) -> Result { + let doc: Value = serde_yaml::from_str(yaml)?; + let Some(jobs) = doc.get("jobs") else { + return Ok(false); + }; + let Some(map) = jobs.as_mapping() else { + return Ok(false); + }; + for job in map.values() { + let Some(steps) = job.get("steps") else { + continue; + }; + let Some(arr) = steps.as_sequence() else { + continue; + }; + for step in arr { + if step + .get("uses") + .and_then(|u| u.as_str()) + .is_some_and(|s| s.starts_with("goreleaser/goreleaser-action")) + { + return Ok(true); + } + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + #![expect(clippy::expect_used, reason = "simplify test output")] + use super::uses_goreleaser; + + #[test] + fn detects_goreleaser() { + let yaml = r" + jobs: + goreleaser: + steps: + - uses: goreleaser/goreleaser-action@v5 + "; + assert!(uses_goreleaser(yaml).expect("parse")); + } + + #[test] + fn missing_goreleaser() { + let yaml = "jobs: {}"; + assert!(!uses_goreleaser(yaml).expect("parse")); + } +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs index eba6d6a..5def2b1 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -8,13 +8,15 @@ mod support; mod util; use cucumber::World as _; use steps::{ - CliWorld, ClientWorld, CommentWorld, ConfigWorld, ListenerWorld, PackagingWorld, WorkerWorld, + CliWorld, ClientWorld, CommentWorld, ConfigWorld, ListenerWorld, PackagingWorld, ReleaseWorld, + WorkerWorld, }; #[tokio::main] async fn main() { tokio::join!( CliWorld::run("tests/features/cli.feature"), + ReleaseWorld::run("tests/features/release.feature"), ClientWorld::run("tests/features/client_main.feature"), CommentWorld::run("tests/features/comment_request.feature"), ConfigWorld::run("tests/features/config.feature"), diff --git a/tests/features/release.feature b/tests/features/release.feature new file mode 100644 index 0000000..b65566a --- /dev/null +++ b/tests/features/release.feature @@ -0,0 +1,11 @@ +Feature: Release workflow + + Scenario: goreleaser step present + Given the release workflow file + When it is parsed as YAML + Then the workflow uses goreleaser + + Scenario: triggers on version tags + Given the release workflow file + When it is parsed as YAML + Then the workflow triggers on tags diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index ed49745..6ae9030 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -1,7 +1,7 @@ -pub mod client_main_steps; -pub use client_main_steps::ClientWorld; pub mod cli_steps; pub use cli_steps::CliWorld; +pub mod client_main_steps; +pub use client_main_steps::ClientWorld; pub mod comment_steps; pub use comment_steps::CommentWorld; pub mod config_steps; @@ -10,5 +10,7 @@ pub mod listener_steps; pub use listener_steps::ListenerWorld; pub mod packaging_steps; pub use packaging_steps::PackagingWorld; +pub mod release_steps; +pub use release_steps::ReleaseWorld; pub mod worker_steps; pub use worker_steps::WorkerWorld; diff --git a/tests/steps/release_steps.rs b/tests/steps/release_steps.rs new file mode 100644 index 0000000..5726318 --- /dev/null +++ b/tests/steps/release_steps.rs @@ -0,0 +1,47 @@ +//! Behavioural steps for the release workflow. +#![expect(clippy::expect_used, reason = "simplify test failure output")] + +use comenq_lib::workflow::uses_goreleaser as workflow_uses_goreleaser; +use cucumber::{World, given, then, when}; +use serde_yaml::Value; +use std::fs; + +#[derive(Debug, Default, World)] +pub struct ReleaseWorld { + content: Option, + yaml: Option, +} + +#[given("the release workflow file")] +fn the_workflow_file(world: &mut ReleaseWorld) { + let text = fs::read_to_string(".github/workflows/release.yml").expect("read workflow"); + world.content = Some(text); +} + +#[when("it is parsed as YAML")] +fn parse_yaml(world: &mut ReleaseWorld) { + let text = world.content.as_deref().expect("file loaded"); + world.yaml = Some(serde_yaml::from_str(text).expect("parse yaml")); +} + +#[then("the workflow uses goreleaser")] +fn assert_uses_goreleaser(world: &mut ReleaseWorld) { + let content = world.content.as_ref().expect("file still loaded"); + assert!(workflow_uses_goreleaser(content).expect("parse")); +} + +#[then("the workflow triggers on tags")] +fn triggers_on_tags(world: &mut ReleaseWorld) { + let yaml = world.yaml.as_ref().expect("yaml parsed"); + let on = yaml.get("on").expect("on"); + let push = on.get("push").expect("push"); + let tags = push + .get("tags") + .expect("tags") + .as_sequence() + .expect("sequence"); + assert!( + tags.iter() + .any(|t| t.as_str() == Some("v[0-9]*.[0-9]*.[0-9]*")) + ); +}