Distributing Ironmind + Rust Continuous Integration

Posted on Mar 30, 2024

Featuring Cargo, Snap, and Homebrew

Believe it or not, I’d like people to use my software, especially Ironmind. I’m finally reaching a point in the project where I feel content with it.

So it’s time to share it with the world.

Unit Tests

Cargo Test

The first step was setting up automated unit tests to build and test my project each time I merged with the master branch. Fortunately, Rust’s build system, Cargo, makes this super easy. To read the documentation for it click here but to paraphrase, tests work off of Cargo’s existing module system. Tests go into the src/tests/ directory. When cargo test is run the compiler will first run the mod.rs file in the directory. Like other rust modules, supplementary files can be stored around the mod.rs file.

Inside the mod.rs you must expose a public module called tests configured as test using macros.

Like this…

// src/tests/mod.rs

#[cfg(test)]
pub mod tests {
    // tests go in here
}

Inside this tests module, you expose each test as a public function along with a call to the test macro.

Something like… Each function proceeds with the test macro will get called when cargo test runs making it very easy to organize multiple tests. Test function can return a Result or just panic. A panic or an Err variant Result type will both indicate a failure. In the documentation they use panics but I prefer using Results because you’re able to provide more information as to why a test failed.

// src/tests/mod.rs

#[cfg(test)]
pub mod tests {
    #[test]
    pub fn test_a() {
        // this test
    }

    pub fn test_b() -> Result<(), String> {
        // that test
    }
}

The tests for Ironmind are quite long, too long to fit in this blog post but you can find them here on my GitHub.

GitHub Automation

It’s as simple as adding a .github/workflows/rust.yml file to the root of your project

If you’re using GitHub you can click on the Actions tab on the web interface and configure actions for Rust. GitHub will automatically generate this file for you. The best part is the fact that every almost Cargo project uses the cargo test command to run unit tests. Which means every rust.yml file is exactly the same.

If you’d rather create it yourself or you don’t feel like using the GitHub web interface just copy this into the aforementioned path…

name: Rust

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Build
      run: cargo build --verbose
    - name: Run tests
      run: cargo test --verbose

Unit Tests Conclusion

Now whenever you push or merge with master these automated unit tests will run and alert you if anything goes wrong.

Publishing to Cargo

I’m going to start with the easiest package manager to publish to

Go to crates.io and log in with your GitHub account

Go to crates.io/me and generate a new API token for your device

Run cargo login and paste your API key

Then you have to make sure your Cargo project meets the requirements to be published

It must have all of the following must be defined in the cargo.toml file

  • License or license file
  • Description
  • Homepage
  • Repository
  • Readme

Here’s the cargo.toml for Ironmind

[package]
name = "ironmind"
version = "0.3.15"
edition = "2021"
authors = ["Jaden Arceneaux <contact@jadenarceneaux.com>"]
description = "A Brainf*ck interpreter and code execution visualizer built in Rust"
license = "MIT"
readme = "README.md"
repository = "https://github.com/Jadens-arc/Ironmind"
keywords = ["BrainFuck"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.4.10", features = ["derive"] }
cursive = "0.20.0"

Then all you have to do is run cargo publish and congrats your project is published.

Users can run cargo install <your program>. It’ll download your source code along with the dependencies, compile them into one executable on the user’s system, and move it to their bin directory. It is worth mentioning that almost no other package manager works like this. Most of them require the publisher to compile it on their system and upload the executable to a repository. Conceptually, you shouldn’t base your understanding of package managers off of Cargo. It gets away with this because it is both a package manager and a build system.

I feel like this is the point where I have to give the obligatory warning to not publish superfluous software. Your project should be somewhat unique and provide value to someone other than yourself. Don’t publish your Hello World program. No one’s going to stop you, but you should be considerate. Think of the children.

Strap in buds, it only gets steeper from here

Publishing to Homebrew

Homebrew is far more traditional.

Except for the fact that, like everything else in Apple’s universe, the terminology is weird.

For this, you only need to know two of them

WordDefinition
TapA repository
FormulaA program

A Tap is a literal repository hosted on GitHub that specifies Formulas available for installation. You can name your tap whatever you want so long that it starts with “homebrew-”. Each Formula is declared in a dedicated ruby file. The naming convention is <your project>.rb. Create a new repository and define your ruby file for your Formula.

Then you’re going to want to compile your program using cargo build --release. The executable found at target/release/<your project> is what you will distribute to users. You’re going to want to compress this executable into a tar.gz file. This can be done using tar -cvzf <your project>.tar.gz <your projects executable>.

Then you’re going to want to upload this tarball somewhere so that you can provide a URL to it for Homebrew. DO NOT JUST PUT IT IN YOUR REPOSITORY. Instead, use the Releases feature on GitHub. On the GitHub web interface, On the right sidebar, under the Releases header, click Create a new release. Attach your compressed executable to the release and set the Tag, Title, and Description. Publish the release. Copy the link to the compressed executable and save it for later ;)

You’re also going to want to get the SHA256 hash of your compressed executable so Homebrew can verify the installation. There’s some kind of way to do this automatically but I don’t remember what it was. So instead run shasum --algorithm=256 <your compressed executable> and save that hash for later as well

Inside the ruby file, you must declare a class that inherits from the Formula class. Inside that class, you must define a few values.

  • Description
  • Homepage
  • URL (Link to that compressed executable from earlier, I told you to save it)
  • SHA256 (The hash from earlier)
  • License

Then you’re going to want to define the install method. Inside that method, you’re going to want to call bin.install and pass in the name of the executable you want to move into the bin directory (this is just the name of your project).

Then you’re going to want to define your test block. Homebrew uses this to verify that your program runs. You just call system and pass in the path of your executable preceded with the bin directory (use #{bin} to insert the bin directory into the string).

Here’s what Ironmind.rb looks like

class Ironmind < Formula
  desc "A BrainF*ck interpreter written in Rust"
  homepage "https://crates.io/crates/ironmind/"
  url "https://github.com/Jadens-arc/Ironmind/releases/download/v0.3.15/ironmind.tar.gz"
  sha256 "9a8bfa2d9dec6c9c941bf34156c3d3dd427c5f50153e387666f142f259777fc3"
  license "MIT"

  def install
    bin.install "ironmind"
  end

  test do
    system "#{bin}/ironmind"
  end
end

Commit these changes and push them to the repository for your tap and now users can add your tap and install the Formulae defined in it

My tap is in a repository called jadens-arc/homebrew-tap

To add my tap run

brew tap jadens-arc/tap

To install Ironmind run

brew install ironmind

Publishing to Snap

If you know me IRL, you know that I shit on Snap for being bloated. I stand on that, it’s bloated, just run lsblk and see for yourself. But it is also really convenient. For beginners and for professionals I’d honestly recommend Ubuntu and it’s piles of shit tools including Snap. If you’re a beginner it makes it easy to get into Linux. If you’re a developer new to a job it makes it really quick to get a workstation set up and get up and running wherever you are. Regardless of who you are, you get excellent documentation and mountains of StackOverflow answers.

Snap works by creating isolated environments for your code to run in, hence the need for loopback devices. By default, snaps only have access to the files they’re distributed with and cannot access very much at the system level.

Publishing to Snap only requires one file to start with. A snapcraft.yaml file in the root of your project.

Snapcraft.yaml

You can generate it by creating a new directory outside of your project and using snapcraft init. Then inside that directory, there will be another directory called snap and in that directory, you’ll have your snapcraft.yaml file. Copy it to the root of your project.

After declaring the basic information from name to description you must specify the grade of your project. This gives snap insight into the development status of your project. There are only two options:

  • devel for projects still in development
  • stable for complete projects ready to be installed

Then you specify the confinement strategy for your project. There are three options devmode, classic, and strict. Ideally, all software should be published in strict mode. This means the software will only have access to a limited number of interfaces needed for it to function. Devmode is for snap in development, allowing developers new to snap the ability to quickly iterate on designs. Snaps published in devmode must be installed using the --devmode flag and they cannot be published as stable. Classic confinement is for programs that legitimately need full access to the system. These packages must be installed using the --classic flag (e.g. snap install code --classic for VSCode)

Then you specify the architectures to build for when the package is published. While you can install Snapcraft on a Mac this is only to develop Snap packages. There is no support for installing them.

Under parts -> <your app> you configure the build instructions for your package. Under build-packages, you specify any system dependencies needed for your app to build. Under source you specify the repository to build from. Under plugin you specify the build plugin for your program. For Ironmind we use the Rust plugin which builds our project using Cargo. To see all available plugins run snapcraft list-plugin

Under apps -> <your app> you first set the command which is the path to the executable binary.

The final step is to configure the interfaces your app needs to run. This allows your app to leave the isolated environment it is installed in.

name: ironmind # you probably want to 'snapcraft register <name>'
base: core22 # the base snap is the execution environment for this snap
version: '0.3.15' # just for humans, typically '1.2+git' or '1.3.2', I like to match this to my cargo.toml version
summary: Brainf*ck interpreter and code execution visualizer # 79 char long summary
description: |
  Built in Rust.
  Written by Jaden Arceneaux   

grade: stable # must be 'stable' to release into candidate/stable channels
confinement: strict # use 'strict' once you have the right plugs and slots

architectures:
- build-on: amd64
- build-on: arm64
- build-on: armhf

parts:
  ironmind:
    build-packages:
      - libncurses5-dev
      - libncursesw5-dev
    source: https://github.com/Jadens-arc/Ironmind.git
    # See 'snapcraft plugins'
    plugin: rust

apps:
  ironmind:
    command: bin/ironmind
    plugs:
      - home
      - removable-media
      - desktop

Publishing Application

From the command line, you want to run

snapcraft login
snapcraft register <your app>
snapcraft

This will authenticate your account, register your application with the Snap store, and generate a Snap Package from your snapcraft.yaml file.

To test it out, you can run snap install ./<your app>.snap

Then run

snapcraft upload <path to your app>.snap

From there you can go to snapcraft.io to manage your package.

Using the web interface you can connect your Snap package to a GitHub repository such that each time a merge into master occurs snap will automatically build and distribute your package

Conclusion

I really hope this helps someone. If you’d like to suggest any corrections or adjustments feel free to email me at contact@jadenarceneaux.com

I hope you all have a lovely day.