Rave Build System

General

Rave files are parsed line-by-line with each line starting with a command, followed by zero or more arguments, and then an optional block, for example:

target MyTarget {
    sources main.cc
    executable a.out
}

This will create ninja.build that compiles main.cc and links it as a.out and then two additional rules to sign and run a.out, these will be MyTarget and MyTarget/run respectively.

Variables

We can assign to variables with the following three commands:

set «name» «value»
add «name» «value»
capture «name» «command»

For example:

set CS_FLAGS "--force --options runtime"
add CS_FLAGS "--timestamp=none"
capture VERSION "grep -om1 '^## .* (v.*)$' Changes.md|sed 's/.*(v\\(.*\))/\\1/'"

First command sets the CS_FLAGS variable, the second one appends to it.

The third one will run «command» as a shell command and assign the output to «name».

Note though that this command will only run when generating build.ninja, so it will only be re-run if build.ninja needs to be regenerated.

If «value» is an existing file relative to the current working directory, any build rule that uses this variable (directly or indirectly) will be set to depend on the file.

For example we may have a structure like this:

# ./default.rave
load app/*/*.rave

# ./app/MyApp/default.rave
target "${dirname}" {
    set entitlements "${dir}/Entitlements.plist"
    add CS_FLAGS "--entitlements '${entitlements}'"
}

This will append --entitlements app/MyApp/Entitlements.plist to CS_FLAGS and it will also make code signing depend on the Entitlements.plist file (since that rule uses CS_FLAGS).

This dependency tracking also works for capture, but here the build rule that uses these variables, is the build rule that regenerates build.ninja, so if we change the above version number capture to the following:

set changes "${dir}/Changes.md"
capture VERSION "grep -om1 '^## .* (v.*)$' '${changes}'|sed 's/.*(v\\(.*\))/\\1/'"

We now have build.ninja depend on Changes.md, i.e. if we update Changes.md (by adding release notes for latest version), we cause a new build.ninja to be produced that captures the most recent version number from the release notes.

Configurations

It is possible to have multiple configurations, these can be specified as follows:

config debug {
    add FLAGS    "-fsanitize=address -fno-omit-frame-pointer"
    add LN_FLAGS "-fsanitize=address"
    add CS_FLAGS "--timestamp=none"
}

config release {
    add FLAGS    "-Os -DNDEBUG"
    add LN_FLAGS "-Wl,-dead_strip -Wl,-dead_strip_dylibs"
    add CS_FLAGS "--timestamp"
}

They can either be specified at the global level, in which case all targets will get multiple configurations, or they can be scoped to a single target (but all targets that it depend on, must also have a configuration using the same name).

When there are configurations specified, they are added to all symbolic targets, so if we add the above to our first example file, we get MyTarget/debug, MyTarget/debug/run, MyTarget/release, and MyTarget/release/run.

Each configuration has its own build directory, and any command can be used inside a config block.

Architectures

Architectures are similar to configurations, though only clang-based build rules are split when there are multiple architectures.

For both architectures and configurations, we can create multiple using the same code block, and there is a config and arch variable available that indicates the current configuration/architecture.

So to create a universal release build we can simply add this block:

config release {
    arch "arm64" "x86_64" {
        add FLAGS    "-target macos-${arch}"
        add LN_FLAGS "-target macos-${arch}"
    }
}

Custom Actions

It is possible to define custom actions using define followed by the name of the target to create, and then a shell command.

The output of this command is captured in a file under _Actions in the build directory, using the current target’s name as basename, and the command itself as extension.

Commands can use variables to references the current target’s executable or application (for bundles). It can also reference the output (file) of other custom commands using their name.

For example to create a gzipped version of a.out from MyTarget, we can define it like this:

target MyTarget {
    sources main.cc
    executable a.out
    define gz "gzip --stdout '${executable}'"
}

This will create a new target named MyTarget/gz that creates $builddir/_Actions/MyTarget.gz (which depends on the signed executable).

If we want to upload this version to a server, we could add a new command, e.g.:

define upload "scp '${gz}' example.org:/var/www-data/upload/"

Hint: If version is defined as a variable, this will be included in the name of the file generated in $builddir/_Actions.

Notarization

Notarization is handled by a command that takes 3 arguments: developer-id, password, and bundle-identifier.

For example:

notarize "developer@icloud.com" "@keychain:Apple Notarization" "org.example.MyApp"

The rule (for notarization) uses the notarize_await shell script (to poll Apple’s server).

Extending Targets

Rather than define everything in a single place, it is possible to augment a target with the extend command. For example to notarize our application and add deployment commands, we can do something like this:

extend MyApp {
    config release {
        notarize "developer@icloud.com" "@keychain:Apple Notarization" "org.example.${target}"

        set KEYFILE "${HOME}/private/sign.key"

        define tbz    "/usr/bin/tar --disable-copyfile -jcC '${application}' --strip-components=1 .."
        define sign   "openssl dgst -dss1 -sign '${KEYFILE}' '${tbz}'|openssl enc -base64"
        define deploy "curl -sfn -Ffile='@${tbz}' -Fsignature='<${sign}' -Fversion='${version}'' 'https://example.org/releases'"
    }
}

Application Bundles

When creating a bundle, one must use prefix to set the prefix path that executable, files, and copy are relative to.

There is both a files and copy command to include bundle resources: First one will process files as they are copied to the bundle, the last one will copy them as they are, e.g. it will not convert Markdown to HTML, convert .strings files to UTF-16, expand variables in .strings and Info.plist, etc.

Here is a simple target for an application bundle:

target "${dirname}" {
    prefix "${target}.app/Contents"
    
    files Info.plist  "."
    files resources/* "Resources"
    
    sources src/*.mm
    executable "MacOS/${target}"
    frameworks Cocoa
}

Both copy and files accept target names to copy by prefixing them with @, and they both do the same thing. For example:

target "tool" {
    sources main.mm
    executable "${target}"
}

target "App" {
    prefix "${target}.app/Contents"
    
    files Info.plist  "."
    files resources/* "Resources"
    files @tool       "SharedSupport/bin"
    
    sources src/*.mm
    executable "MacOS/${target}"
    frameworks Cocoa
}