Loading... Search articles

Search for articles

Sorry, but we couldn't find any matches...

But perhaps we can interest you in one of our more popular articles?
A header image to illustrate Cross-compiling Dart CLI applications with Codemagic

Cross-compiling Dart CLI applications with Codemagic

Feb 1, 2023

This article is written by Dmitry Zhifarsky

CLI stands for command-line interface, a text-based interface you can use to input commands and perform various operations. If you’ve ever used the dart or flutter commands in your console, you’ve used a CLI.

Sometimes, though, you may end up writing a CLI of your own.

Why write a CLI in Dart?

When might you need to write a CLI? If you want to statically analyze your code for potential issues (like dart_code_metrics does), automate some of your daily repeated actions (for example, scaffolding a template, like very_good_cli does), or refactor a bunch of code at once (which is called a codemod), writing a CLI is a good option to start with.

You’d likely want it to work across multiple operating systems, so it would be best to use a language that allows you to compile your CLI for all of them without any problems. The Dart programming language would be an excellent choice for this situation. So, in this article, we will explain how to write a CLI in Dart and cross-compile it for various platforms using Codemagic.

While Dart is mostly used for writing Flutter apps, it’s a general-purpose language that you can use to create other types of apps as well. For example, you can use Dart to write back-end apps (using packages like dart_frog or shelf) and CLI apps. VGV’s and DCM’s examples already prove that Dart can and has been used to write very good (pun intended) CLIs.

Cross-compilation: Getting executables for all major platforms

Currently, there are three ways to call a Dart CLI application from the console:

  • The first is to add a package with the executable as a dependency to the target package and then call dart run package_name:executable_name <args>. While this is a good option to start with, when you call the CLI app this way, it will need some time to compile before the execution. Depending on the capabilities of the device you run the code on, it might take tens of seconds just to compile the executable.

  • The second option is to activate the package executable globally via dart pub global activate. This command needs compilation only once after the activation (and for each version update) but requires additional awareness of the package version you want to activate. This is because, by default, it activates the latest version of the package available. So, if you use a package this way on your CI/CD and the package has a newer major version with a breaking change, it might break your pipeline. Also, the platform should have the Dart SDK available to activate and compile the package.

  • The third option is to provide a compiled executable via dart compile exe that can be downloaded and added to the PATH environment directly or distributed via package managers like Homebrew, Chocolatey, or apt. While this approach takes more time to set up, compiling the package this way offers better performance compared with other options.

The problem is that Dart doesn’t support cross-compilation itself, meaning that the executable you get after calling dart compile exe can only be run on the same platform you compile it on. That’s why we need four different platforms to compile our executable: Windows, Linux, macOS Intel, and macOS Arm. But instead of buying physical devices or setting up multiple virtual machines, we can use CI/CD platforms like Codemagic that provide all of them while also integrating the compilation process into our development flow.

So, let’s see how we can configure Codemagic to compile our Dart CLI apps.

Codemagic configuration for Dart CLI apps

To complete this task, we need a Codemagic account. If you don’t have one, you can sign up here.

Sign up

We will be using the codemagic.yaml configuration.

Every codemagic.yaml file should start with the workflows section. We need four workflows (one for each target platform) to compile our executable.

workflows:
  compile-mac:
    
  compile-mac-m1:
    
  compile-windows:
    
  compile-linux:
    

Let’s take a closer look at how the compile-mac workflow is defined:

compile-mac:
    instance_type: mac_pro
    max_build_duration: 10
    environment:
      flutter: stable

First, we need to set instance_type to mac_pro since we’re compiling for Intel Macs, limit our build time (to avoid builds that take unexpectedly long, burning the time for nothing) with max_build_duration, and set up the environment to have Flutter available. We don’t need Flutter itself, but we do require the dart executable to be available so that we can later call dart compile exe.

Next, let’s define when the compilation happens. Since we don’t need to compile the executable on every push, let’s use tags as a trigger for compilation.

compile-mac:
  
  triggering:
      events:
        - tag
      tag_patterns:
        - pattern: "*"
          include: true
      cancel_previous_builds: true

We’ve added cancel_previous_builds, which allows us to cancel previous builds if we push a new tag before the compilation is completed, saving some build time.

And that’s all we need to do to set up the environment.

Now, let’s take a look at the scripts section:

  scripts:
      - name: Install dependencies
        script: dart pub get

      - name: Create target directory
        script: mkdir macos

      - name: Compile executable
        script: dart compile exe <path_to_dart_file> -o macos/compiled_executable

The first step installs the dependencies for our app. Then we create a target directory where the compiled executable will be placed. The last step compiles the executable.

Note: If you have a monorepository, just running a dart pub get command won’t work for you. To set up dependency resolution for monorepo, you can use the very_good_cli package. Here’s how the setup might look:

      - name: Install Very Good CLI
        script: dart pub global activate very_good_cli

      - name: Install dependencies
        script: very_good packages get -r

Last but not least, we’ll deal with artifacts. The artifacts section allows you to download the compiled executable from a Codemagic build page.

artifacts:
      - macos/**

Full workflow code:

  compile-mac:
    instance_type: mac_pro
    max_build_duration: 10
    environment:
      flutter: stable
    triggering:
      events:
        - tag
      tag_patterns:
        - pattern: "*"
          include: true
      cancel_previous_builds: true
    scripts:
      - name: Install dependencies
        script: dart pub get

      - name: Create target directory
        script: mkdir macos

      - name: Compile executable
        script: dart compile exe <path_to_dart_file> -o macos/compiled_executable

    artifacts:
      - macos/**

Publishing to GitHub Releases

By default, our executable files will be available as a ZIP archive. But what if we want to have them published to another platform, such as GitHub? Codemagic can help us with that as well.

First, we need to add a GitHub token to our workflow environment to have access to GitHub Releases. To generate a token, go to https://github.com/settings/tokens and click “Generate new token”. Note that the GitHub API currently supports only classic tokens, so you’ll need to generate a classic token. Then type the token name (Codemagic, for example) and choose the “repo” scope.

After saving the token, copy its value and open your Codemagic app environment settings.

Add a new environment variable. Set the variable name to GITHUB_TOKEN, paste the token you copied before in the “variable value” field, and select (or create) a group. Then click “add”.

After that, we need to update the workflow environment configuration:

   environment:
      groups:
        - GitHub

Note: I use a group name here as a more convenient way to deal with variables.

Now, we need to set up a publishing section in our codemagic.yaml file:

    publishing:
      scripts:
        - name: Publish to GitHub
          script: |
            #!/usr/bin/env zsh
            gh release create -d "${CM_TAG}" \
              --title "${CM_TAG}" \
              macos/compiled_executable            

This script will create a new draft release using the tag data with our executables attached as assets.

.

Full workflow code with publishing to GitHub Releases

workflows:
  compile-mac:
    instance_type: mac_pro
    max_build_duration: 10
    environment:
      flutter: stable
      groups:
        - GitHub
    triggering:
      events:
        - tag
      tag_patterns:
        - pattern: "*"
          include: true
      cancel_previous_builds: true

    scripts:
      - name: Install dependencies
        script: dart pub get

      - name: Create target directory
        script: mkdir macos

      - name: Compile executable
        script: dart compile exe <path_to_dart_file> -o macos/compiled_executable

    artifacts:
      - macos/**

    publishing:
      scripts:
        - name: Publish to GitHub
          script: |
            #!/usr/bin/env zsh
            gh release create -d "${CM_TAG}" \
              --title "${CM_TAG}" \
              macos/compiled_executable            

This workflow builds our CLI as an executable for Intel-based Mac machines and publishes it to GitHub Releases. Similar to how we have created this workflow, we can add three more workflows for Apple silicon Mac, Linux, and Windows machines, respectively. You can find the full codemagic.yaml file with all the workflows in the last section of this article.

Caveats (aka feature requests for the Codemagic dev team)

No way to wait for the workflows to complete

Codemagic currently does not support waiting for the workflows to complete, meaning that if you have set up publishing to GitHub Releases and want to create a release only if all the workflows have passed successfully, you’ll need additional code to make it work. If you have questions, you can join this discussion: https://github.com/orgs/codemagic-ci-cd/discussions/1235

No support for matrixes

Matrixes are another useful feature when it comes to writing workflows for multiple platforms when multiple steps are repeated. Without them, the configuration file might get too bloated with the copied steps. Codemagic doesn’t yet have support for matrixes. However, this doesn’t prevent us from building the pipeline — it just makes the YAML file longer.

Conclusion

If you have followed the steps in this article, you now know how to build a Codemagic pipeline to cross-compile your CLI in Dart for all the major platforms it may be needed on.

As a creator of one such CLI you may even use, Dart Code Metrics, I encourage you to experiment and build something of your own. And if you find it helpful, don’t hesitate to share it with the community to help others who encounter similar problems.


Dmitry Zhifarsky is a passionate software engineer specializing in various front-end frameworks, as well as Dart and Flutter. He’s also the co-creator of DCM. You can find him on Twitter, GitHub, or deeply immersed in VS Code. Do not disturb him if it’s the latter.

Full codemagic.yaml example

workflows:
  compile-mac: &base_workflow
    instance_type: mac_pro
    max_build_duration: 10
    environment: &base_environment
      flutter: stable
      groups:
        - GitHub
      vars: &base_vars
        DART_FILE_PATH: "my/dart/file" # Enter the path to your dart file  
        TARGET_DIR: macos
        TARGET_FILE: compiled_executable
    triggering:
      events:
        - tag
      tag_patterns:
        - pattern: "*"
          include: true
      cancel_previous_builds: true    
    scripts: &scripts
      - name: Install dependencies
        script: dart pub get

      - name: Create target directory
        script: mkdir $TARGET_DIR

      - name: Compile executable
        script: dart compile exe $DART_FILE_PATH -o $TARGET_DIR/$TARGET_FILE

    artifacts:
      - $TARGET_DIR/**
    publishing:
      scripts:
        - name: Publish to GitHub
          script: |
            #!/usr/bin/env zsh
            gh release create -d "${CM_TAG}" \
              --title "${CM_TAG}" \
              $TARGET_DIR/$TARGET_FILE            

  compile-mac-m1:
    instance_type: mac_mini_m1
    <<: *base_workflow
    environment:
      <<: *base_environment
      vars:
        TARGET_DIR: macos-m1
        <<: *base_vars
        

  compile-windows:
    instance_type: windows_x2
    <<: *base_workflow
    environment:
      <<: *base_environment
      vars:
        TARGET_DIR: windows
        TARGET_FILE: compiled_executable.exe
        <<: *base_vars

  compile-linux:
    instance_type: linux_x2
    <<: *base_workflow
    environment:
      <<: *base_environment
      vars:
        TARGET_DIR: linux
        <<: *base_vars 

Related articles

Latest articles

Show more posts