Files
soundcraft/docs/releasing.md
T
aiongg e82c1689b3 refactor(cli): parse soundcraft-cli arguments with clap, following RFC-0012 (#1136)
* test(cli): pin today's argument handling

Run the real binary and record how the hand-written parser behaves for every
subcommand: help and version spellings, no arguments, unknown subcommands and
flags, value flags in both spellings, missing and repeated values, positional
order, exit codes and which stream carries each message.

No behaviour change. Later parser changes update these assertions on purpose.

* refactor(cli): split main.rs into modules

Move only: args, error, output, convert, info, session, catalog, app and
mcp each get their functions and unit tests; main.rs keeps the usage doc,
help_text and the dispatch. Moved items become pub(crate) where other
modules call them. No behaviour change.

* refactor(cli): typed errors and run(args, out, err)

soundcraft-cli is now a library plus a thin binary. `soundcraft_cli::run(args,
out, err) -> i32` is the whole program; main.rs only passes the real streams
and shuts the plugin hosts down afterwards (process teardown, not part of run,
which tests call repeatedly in one process). Subcommands write to the given
writers, return `Result<(), CliError>`, and `fail` is gone. CliError wraps
engine, audio-io, io (with the path), serde_json and the control channel
errors; UsageError, ConvertError and RootFlagError cover the subcommands' own
failures. No Result<_, String> is left except where a dependency reports a
string (control-channel call, AutomationRoots::new), wrapped in a variant.

The tests that need no files, working directory, environment or stdin now call
`run` in-process (tests/it/run.rs); the rest still run the binary.

Behaviour changes:
- Error messages start with `error: ` instead of `soundcraft-cli: `.
- A script line that stops the run now prints `error: FILE:N: ID: ...` (it had
  no prefix). With --keep-going the line stays bare.
- A convert encode or write failure now names the output path.
- An empty or help-less call still prints the one-line usage to stderr without
  a prefix and exits 1; `files confined:` keeps its `soundcraft-cli:` prefix
  (it is a note, not an error).

* refactor(cli): parse arguments with clap

Replace the hand parser (arg_value, check_options, ENGINE_OPTIONS, the
lib.rs dispatch, the per-subcommand positional handling and the root
flags' consume) with clap derive types in args.rs: a Parser, a
Subcommand enum and one Args struct per subcommand, with the run/script
engine flags flattened. main passes args_os(); run takes OsString-like
arguments and renders clap's help, version and usage errors (help and
version on stdout, exit 0; usage errors on stderr, exit 2). Add
command() returning the clap definition. The usage block in the module
doc and help_text are gone: the doc comments are the help.

Behaviour changes:
- Usage errors exit 2 with clap's message and the --help hint on
  stderr, instead of exit 1 with hand-written text: unknown subcommand,
  unknown flag, missing value, missing or extra positional, a value on
  a boolean flag (--demo=1, --normalize=1).
- No arguments prints the help to stderr, exit 2 (was a one-line usage,
  exit 1).
- --help, -h and help print clap's generated help, and every subcommand
  has its own (SUB --help, SUB -h, help SUB). Before, info and script
  read --help as a file name, describe as an id, commands as a filter,
  run rejected --help and ignored -h, convert and app printed their
  usage error, mcp, parity and plugins ignored it, and app --port P -h
  called the method -h.
- --help or -h before -- shows the subcommand's help even after a
  mistake on the same line (run --bounce --help).
- --version, -V and version print "soundcraft-cli X.Y.Z" (was
  "SoundCraft CLI X.Y.Z").
- Unknown flags on info, commands, describe, app, mcp, parity and
  plugins are usage errors; they were ignored or read as a positional.
  mcp --automation-bogus exits 2 (was exit 1).
- Extra positionals are usage errors on every subcommand (were ignored).
- A single-dash word (-x) is a flag, so an unknown one is a usage
  error; a value starting with - is written --flag=-value.
- Flags may come before or between the positionals: convert IN --rate
  44100 OUT, convert --rate 44100 IN OUT and script --keep-going FILE
  parse (the flag word was taken as a file name).
- A repeated single-value flag keeps its last value (was the first):
  --rate, --bit-depth, --save, --port and the others.
- parity --write without a value is a usage error (printed the report).
- mcp --connect without a value, or followed by a flag, is a usage
  error (was exit 1, or took the flag as the port).
- mcp --demo=1 is a usage error (was ignored, without the demo).
- A root flag without a folder, with an empty one, or followed by a
  flag is a usage error with clap's message (was exit 1).
- A non-Unicode argument is a usage error where text is expected and
  accepted as a root folder (std::env::args panicked).
- The help lists --sample as --demo's alias.

Unchanged: --dither with --no-dither, bad --rate and --bit-depth values,
the roots with --connect and the SOUNDCRAFT_CONTROL_PORT fallback are
still checked after parsing (exit 1).

* refactor(cli): reject repeated flags, clap --version (photocraft#7071 decisions)

Behaviour changes:
- A flag given twice is a usage error (exit 2), in either spelling, including
  an alias pair such as --demo and --sample. Before, the last one won. Only
  run's --cmd and mcp's --automation-read-root / --automation-write-root
  repeat; they accumulate in order.
- --version and -V print the long version, "soundcraft-cli 0.13.1 (linux
  x86_64)": the version and the platform the binary was built for.
- The hidden `version` subcommand is gone; `soundcraft-cli version` is an
  unrecognized-subcommand usage error.
- --start and --end accept a negative number (`--start -1`, as the engine
  counts positions before 0); any other value starting with `-` needs `=`
  and otherwise is an unexpected-argument usage error. No flag has
  allow_hyphen_values.
- `help <subcommand>` is unchanged (tested).

* refactor(cli): typed values and flag relations

Handlers receive typed values: convert --bit-depth is a ValueEnum, --rate a
NonZeroU32, file arguments PathBuf, --start/--end a TimeArg, --cmd a parsed
(id, JSON) spec, app's JSON a serde_json::Value, app --port and mcp --connect
a validated port or host:port. Relations (--dither/--no-dither, --in/--demo,
--start/--end needing --bounce) are declared on the flags. describe ID,
commands FILTER and app METHOD refuse an empty string. parse_rate,
parse_bit_depth, parse_dither, parse_cmd_spec, time_arg and the Rate,
BitDepth, DitherConflict and CmdJson errors are gone.

Behaviour changes:
- --dither with --no-dither is a usage error (exit 2) instead of exit 1.
- --rate 0 / non-numeric and an unknown --bit-depth are usage errors (exit 2)
  with clap's wording, naming the flag. --bit-depth help lists the values.
- --in with --demo (or --sample) is a usage error; --in used to win silently.
- --start/--end without --bounce are a usage error; they used to be ignored.
- Malformed JSON in --cmd ID=JSON and in app's JSON argument is a usage error
  (exit 2) at parse time. Before, run opened the engine and executed every
  earlier --cmd (printing their results) before the bad one failed with exit 1.
- An empty --start/--end, an empty command id (--cmd =1), and an empty
  describe ID, commands FILTER or app METHOD are usage errors. An empty
  commands FILTER used to list everything.
- app --port and mcp --connect accept only a port (0-65535) or host:port;
  junk used to be accepted and failed at connect time (exit 1).
- Non-Unicode file names are accepted wherever the callee takes a Path (info
  audio files, convert, script, parity --write). Session and bounce/save paths
  go to the engine as text, so a non-Unicode one fails with exit 1, "the path
  is not valid Unicode", after any earlier --cmd ran.

* refactor(cli): shared launch flags and SOUNDCRAFT_CONTROL_PORT as clap env

soundcraft_automation::args holds the launch flags as clap Args structs:
ControlClientArgs (`app --port`, env SOUNDCRAFT_CONTROL_PORT, default
7979), ControlServerArgs (the desktop `--control`, same variable, wired
in a later step; its ControlPort value is a port, also from a loopback
host:port, or another host's host:port, which the app decides on, so one
export serves both), AutomationArgs (the two root flags, repeatable, flags
only), the ControlTarget value parser, and the blank_env_is_unset,
name_env_vars and conflict helpers. The module and its clap/thiserror
dependencies are left out of the wasm build. `app` and `mcp` flatten
them; the hand fallback to the variable in `app` is gone.

Behaviour changes:
- An empty or blank SOUNDCRAFT_CONTROL_PORT counts as unset, so `app`
  uses 7979; before, it tried to connect to `127.0.0.1:` (exit 1).
- A bad SOUNDCRAFT_CONTROL_PORT is a usage error naming the variable
  (exit 2); before, the connection failed (exit 1).
- Spaces around the `app --port` or `mcp --connect` value, or the
  variable's, are ignored; before, the connection failed (exit 1).
- An IPv6 host in `app --port`, `mcp --connect` or the variable goes in
  brackets (`[::1]:7979`); `::1:7979` is a usage error (exit 2), as the
  host and port can't be told apart; before, the connection failed
  (exit 1).
- `app --help` shows `[env: SOUNDCRAFT_CONTROL_PORT=]` and
  `[default: 7979]` instead of the hand-written default note.
- `mcp` with a root flag and --connect is clap's conflict error (exit 2,
  "the argument '...' cannot be used with '...'"), reported before any
  root folder is opened; before, a custom message with exit 1, after the
  roots were opened.

* refactor(app): move launch flag parsing into launch_args

The parsing moves out of main unchanged, into a LaunchArgs struct and a
parse function that takes SOUNDCRAFT_CONTROL_PORT as a parameter. Tests pin
the flags, the file list, ignored unknown flags and the fallback of a bad
--control to the variable. No behaviour change.

* refactor(app): parse launch flags with clap

soundcraft --help now comes from the LaunchArgs doc comments and ends with the
environment variables the app reads, and the shared ControlServerArgs supplies
--control and SOUNDCRAFT_CONTROL_PORT. Process tests cover the variable (bad
value, blank value, loopback host:port, another host).

Behaviour changes:
- An unknown flag (--bogus, -x) is a usage error on stderr, exit 2. It was
  ignored, and a single-dash word was opened as a file; after `--` it is a file.
- A bad --control (out of range, not a number, missing value) is a usage error,
  exit 2. It was ignored and the variable used instead.
- A bad SOUNDCRAFT_CONTROL_PORT is a usage error naming the variable, exit 2.
  It was ignored. An empty or blank variable still counts as unset.
- A loopback host:port (127.0.0.1, localhost or [::1]) in --control or
  SOUNDCRAFT_CONTROL_PORT gives its port, so the value `soundcraft-cli app`
  takes from the variable works for both. The control server listens on
  127.0.0.1 only, so another host in --control is a usage error. In the
  variable, which may point `soundcraft-cli app` at another machine, it is
  ignored with a log warning and the app opens without a control server.
  Both were ignored before.
- A repeated flag is a usage error (--demo twice, --control twice).
- --control=PORT and a port with surrounding spaces are accepted; a value that
  starts with `-` is not taken as the value, except a negative number, which is
  a bad port (`--control -1`).
- --help ends with an Environment section listing SOUNDCRAFT_CONTROL_PORT,
  SOUNDCRAFT_CONFIG_DIR, SOUNDCRAFT_NO_PREFS, SOUNDCRAFT_NO_SYSTEM_FONTS,
  SOUNDCRAFT_WAYLAND (Linux) and RUST_LOG.
- --help and -h print the help, exit 0. They were ignored.
- --version and -V print `soundcraft 0.13.1 (linux x86_64)`, as soundcraft-cli
  does. It printed `SoundCraft 0.13.1`.
- -V and --version are read in order with the other arguments: an earlier
  error comes first.
- macOS -psn_… process serial numbers are dropped, as before they were
  opened as files.
- A file whose name is not valid Unicode is skipped with a log warning. It
  used to panic in std::env::args.

* feat(cli): shell completions and man pages

An example, `cargo run -p soundcraft-cli --example man-and-completions -- <out-dir>`,
writes bash, zsh, fish, elvish and PowerShell completions and soundcraft-cli.1 plus a
page per subcommand, using clap_complete and clap_mangen (dev-dependencies of
soundcraft-cli only, not in the binary).

The Linux package (deb, rpm, tar.gz, AppImage) installs the pages in
/usr/share/man/man1 and the bash, zsh and fish completions in their standard
directories; the deb also links the zsh completion into Debian's
vendor-completions. A cross build runs the example through $EMULATOR and
leaves the files out without one. The FreeBSD tarball carries them in the
ports layout; a cross build builds the example for the host as well.

No change to the CLI's behaviour. A test pins the files the example writes.

* ci(cli): check the command-line code with ast-grep rules

Adds lint/ast-grep (rules for clap errors built from strings, String as an
error type, and std::env::args) and a workflow that runs the rule tests and a
scan over the CLI code through the shared craft-common-actions workflow. No
behaviour change; the scan has no hits.

* docs(cli): document soundcraft-cli and RFC-0012

Add docs/cli.md: the subcommands, exit codes, environment variables, the
desktop app's launch flags, man pages and completions, and the RFC-0012 rules
not followed yet (convert can overwrite its input; no --compact/--json).
Link it from the README and the MCP and control-protocol guides. No code change.

* feat(cli): refuse an output that replaces an input

An output that is one of the subcommand's inputs is a usage error (exit 2,
`error: …` on stderr, clap's usage line), reported while parsing, before
the engine loads or any --cmd runs. Paths are compared by file identity
(device and inode, through same-file), or, when neither exists yet, by
where they would be created, so `a.wav`, `./a.wav`, an absolute path and a
symbolic or hard link all count as the same file.

Inputs and outputs per subcommand:
- convert: input IN, output OUT. --in-place lets OUT replace IN.
- run: input --in, outputs --bounce and --save. --in-place lets --save
  replace the --in session; it requires --in. A --bounce naming the --in
  session is always refused.
- script: inputs FILE and --in, outputs --bounce and --save. --in-place
  lets --save replace the --in session; a --bounce naming it is always
  refused, and neither may ever replace FILE.
- run and script: --bounce and --save naming one file is refused with or
  without --in-place (the save would replace the bounce).
- info, commands, describe, app, mcp, plugins write no file; parity
  --write has no input. Paths inside --cmd parameters are not checked.

With --in-place, an output that is a symbolic link to the input is written
to the file the link names, so convert and --save both replace the input
and leave the link in place (a save renames a new file over its path,
which would otherwise replace the link and leave the input unchanged).

Behaviour changes:
- `convert a.wav a.wav`, `run --in s.scraft --save s.scraft`,
  `run --in s.scraft --bounce s.scraft` and the other cases above exit 2
  instead of replacing the input.
- New flag --in-place on convert, run and script.
- An existing file that is not an input is still replaced without asking.

* feat(cli): --compact for JSON output

RFC-0012 rule 1.3: JSON is pretty-printed by default and --compact prints
one line. Every pretty-printed JSON output now goes through
output::print_json.

Changed outputs:
- info <audio file>, describe, app: new --compact flag.
- run and script: new --compact flag, which requires --inspect and applies
  to the --inspect JSON only.

Unchanged on purpose:
- The `ID: JSON` result lines of run and script and the --bounce and --save
  results stay one line each. The prefix makes them text records, and the
  RFC lets a command document its own layout; scripts that parse them keep
  working.
- commands, plugins, parity and the .scraft summary of info are text and get
  no --json flag (an open question in the RFC); mcp speaks JSON-RPC lines.

Pretty output is byte-identical to before.

* fix(app): one Switch parser for boolean environment switches

SOUNDCRAFT_NO_PREFS, SOUNDCRAFT_NO_SYSTEM_FONTS and SOUNDCRAFT_WAYLAND each
counted any value, including 0, as on. They now share
soundcraft_ui_egui::switch::Switch: 1/on/true/yes is On, 0/off/false/no is
Off (case- and space-insensitive), unset or blank is Default, and any other
value is Default and logged with the variable name. The ENVIRONMENT help
text, docs/cli.md and the README say the same.

Behaviour changes (old -> new):
- SOUNDCRAFT_NO_PREFS=0 disabled preferences (any value did); now only On
  does.
- SOUNDCRAFT_NO_SYSTEM_FONTS=0 disabled system fonts (any value did); now
  only On does.
- SOUNDCRAFT_WAYLAND=0 kept native Wayland (any value did); now only On does.
- An empty value of any of the three counted as set; it now means the
  default (off).
- An unknown value of any of the three is logged.

* refactor(app): read the environment once into Env

The desktop app reads SOUNDCRAFT_CONFIG_DIR, SOUNDCRAFT_NO_PREFS,
SOUNDCRAFT_NO_SYSTEM_FONTS and SOUNDCRAFT_WAYLAND once at startup into
env::Env (typed fields, Switch for the on/off ones), after the logger is
installed so malformed switches are logged. The --help Environment
section is generated from the same VARS table, with a test that it lists
every name.

ui-egui no longer reads the app's variables: fonts::install and
definitions take system_fonts, set through SoundApp::set_system_fonts
(default on, so the web app is unchanged). The XWayland and prefs
decisions are methods on Env, tested on literals. logging::install takes
the RUST_LOG value, which stays outside Env because the logger needs it
before anything can log.

No behaviour change beyond help column spacing.

* refactor(cli): one long_version for both binaries

* refactor(audio-io): one FileFormat table for extensions

FileFormat gains the extension table every caller matches on:
from_extension / from_path (ASCII case-insensitive) over extensions,
from_write_extension over write_extensions, default_extension, name,
mime_type, the READ and WRITE lists, and readable_extensions /
writable_extensions for dialog filters, help and messages. Reading
takes every extension the decoder knows; writing takes only the names
whose readers expect what the encoder writes (no rf64 or aifc, whose
files would get plain RIFF and AIFF headers).
detect_format, bounce (mix and stems), convert, the bounce dialog, the
desktop and web file pickers, the workspace browser, the Sampler, web
downloads and the MCP bounce schema all use it; ENCODE_EXTENSIONS and
encode_format_for are gone. convert's IN/OUT and info's FILE help name
the extensions from the read and write lists.

Behaviour changes, where the lists disagreed:
- The bounce dialog's Browse result, the bounce save filter and the MCP
  bounce enum take wav, wave, bwf, aif, aiff and flac: wave and bwf are
  new to all three, and aif is new to the MCP enum. convert and
  file.bounce_mix already took these. The "supported:" list in the
  cannot-write errors is "wav, wave, bwf, aif, aiff, flac".
- file.bounce_stems reads its format name case-insensitively: `AIFF`
  or `FLAC` meant WAV before. Unknown names still mean WAV.
- The bounce dialog sends the format as its file extension (aif for
  AIFF); the engine accepts both spellings.
- File pickers, the workspace browser and the Sampler's drop and Load
  Sample filter list every extension the decoder reads: the workspace
  browser gains wave, bwf, rf64, aifc, mp1, mp2, oga, aac, mp4, adts,
  mkv and webm; the Sampler gains rf64, mp1, mp2, m4b, adts, mkv and
  webm; the desktop and web open filters gain rf64, mp1, mp2 and adts.
- Web downloads get a media type for every audio format, not only WAV,
  AIFF and FLAC.

* refactor(app): resolve the settings folder once in Env

* fix(app): keep paths as paths until the engine needs text

A path is text only where an engine command takes `"path": string`; a
name that is not valid Unicode is reported there instead of being opened
under a lossy rendering (another file, or a confusing "can't read").

- ui-egui: dialogs::unicode() and NotUnicode ("<path:?> is not valid
  Unicode"); dialogs::open_path(app, &Path), moved from the desktop app,
  is the one open-by-extension path for dropped, command-line and
  Workspace files.
- Services: the file dialogs return PathBuf (pick_save and pick_folder
  start from a &Path); callers convert where the text field or command
  takes the path.
- Sampler drops and Load Sample pass the path to mix.sampler_load
  through unicode(); the audio test checks the Path.
- Workspace panel keeps entry paths; names are shown lossily only as
  labels. workspace_dir stays a String (serialized preferences).
- soundcraft-cli: utf8() is unicode(), CliError::NonUnicodePath is
  NotUnicode; it is called only for info's session, --in, --bounce and
  --save. convert's output format test no longer treats a non-Unicode
  extension as "no extension".
- docs/cli.md: sessions and bounces the engine opens or writes must be
  valid Unicode.

Behaviour changes:
- A dropped, command-line, picked or Workspace file whose name is not
  valid Unicode is not opened: the status line (and log) says
  "<path:?> is not valid Unicode", with the bytes escaped. Dropped files
  used to be opened under a U+FFFD rendering.
- A file dialog result that is not valid Unicode leaves the path field
  unchanged and shows the same warning.
- Double-clicking a Workspace file opens it like a dropped file:
  .MID/.MIDI/.SCRAFT in any case open as MIDI/session (they used to
  import as audio), and a failed open is logged.
- convert OUT with an extension that is not valid Unicode is refused as
  an unsupported format instead of being written as WAV.

* fix(app): read Env after the launch flags; tidy Switch and Env

* fix(audio-io): decode every extension the format table lists

* fix(cli): write convert output atomically; tighten --connect and loopback

- convert writes every output to a new file in the output's folder, flushes it and renames it
  over the target, removing the new file on failure. A failed write (disk full, unwritable
  folder) no longer truncates the input under --in-place (RFC-0012 rule 1.6). A link output
  still writes the linked file.
- --save in place already goes through the engine's replace_file (tmp + rename), and
  file.bounce_mix renders to <name>.part and renames it when complete, so both are atomic.
- The desktop's --control / SOUNDCRAFT_CONTROL_PORT no longer treat [::1] as loopback: the
  control server binds 127.0.0.1 only. [::1]:P is a usage error from the flag and is ignored
  with a warning from the variable. The CLI's --connect / app --port still accept [::1]:P.
  Error tip and docs/cli.md updated.
- mcp --connect conflicts with --demo (exit 2): the demo session belongs to the in-process
  server. Help and docs/cli.md say so.

* test(audio-io): list the decode fixtures in ATTRIBUTION.md

cargo xtask assets needs a row for every committed audio file; one
directory row covers crates/audio-io/tests/fixtures/.

* fix(cli): refuse non-finite --start and --end values

* refactor(cli): parse the control target once; open sessions by path; keep error sources

- A1: ControlTarget { host, port } is parsed once by args::control_target; is_loopback() replaces
  control_port/ControlPort, and Remote::new takes &ControlTarget.
- A2: engine::io::open_session takes impl AsRef<Path>; the CLI no longer requires a Unicode path
  to open a session, only for the --bounce and --save paths that go into JSON.
- A3: ValueError::Json keeps the serde_json::Error; Remote::request returns RemoteError and
  AutomationRoots::new returns RootError, both carried by CliError.

* test(engine): pass session paths to open_session without a borrow

* test(cli): info opens a session at a non-Unicode path

* refactor(app): log the shared long version; info: session extension in any case; Switch beside Env
2026-10-27 21:09:43 -04:00

15 KiB

Releasing SoundCraft

Every push to the release branch runs .github/workflows/release.yml. The workflow builds signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates or updates a draft GitHub Release named SoundCraft v<version>. Nobody sees a draft until a maintainer publishes it.

This is SoundCraft's implementation of the shared release playbook. User-facing names say SoundCraft. Files, binaries and ids stay lowercase (soundcraft-<version>-<platform>-<arch>.<ext>, ai.storyteller.soundcraft).

Cutting a release

  1. Bump the version on main. The only place it lives is [workspace.package] version in the root Cargo.toml; the app reads it from CARGO_PKG_VERSION (About SoundCraft, soundcraft --version, soundcraft-cli --version).

    cargo xtask version                 # prints the current version, e.g. 0.1.0
    cargo xtask version set 0.2.0       # or 0.2.0-rc.1; updates Cargo.toml and Cargo.lock
    

    Commit the change (Cargo.toml + Cargo.lock) through the normal review flow.

  2. Merge main into release (or fast-forward it) and push. The workflow starts by itself.

  3. Wait for the draft. After about 30 to 60 minutes (notarization and the FreeBSD VM are the slow parts), the Releases page has a draft SoundCraft v0.2.0, tagged v0.2.0 on the pushed commit, with every artifact and SHA256SUMS.txt. The notes are generated from the merged PRs.

  4. Check it. Download an installer or two and look at the job summaries. Any ::warning:: there means a signing secret was missing and that artifact is unsigned.

  5. Publish the draft in the GitHub UI. Publishing creates the v0.2.0 tag. Versions with a pre-release suffix (-rc.1) are marked as pre-releases.

Pushing to release again before you publish rebuilds the same draft and replaces its assets. After the draft is published, the workflow refuses to touch that version again, so bump it first.

Test runs: Actions → Release → Run workflow runs the whole pipeline by hand. The optional version input (such as 0.2.0-rc.1) overrides Cargo.toml for that run only. The jobs apply it with cargo xtask version set before building, so the binaries report it too. Only the signing jobs (macOS, Windows) and the draft-release job use the release environment, which only the release branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and web build and are checked, the signing jobs are refused, and no release is drafted.

What gets built

Platform Artifacts Built on
macOS 11+ (universal: Apple silicon + Intel) soundcraft-<v>-macos-universal.dmg, soundcraft-cli-<v>-macos-universal.zip macos-15
Windows 10+ x64 soundcraft-<v>-windows-x64.msi, soundcraft-<v>-windows-x64-portable.zip windows-latest
Windows 10+ x86 (32-bit) soundcraft-<v>-windows-x86.msi, soundcraft-<v>-windows-x86-portable.zip windows-latest
Windows 11 ARM64 soundcraft-<v>-windows-arm64.msi, soundcraft-<v>-windows-arm64-portable.zip windows-latest (cross-compiled; windows-arm64.yml installs and runs it on ARM64)
Linux x86_64 soundcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz} ubuntu-22.04
Linux aarch64 soundcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz} ubuntu-22.04-arm
Linux riscv64 soundcraft-<v>-linux-riscv64.tar.gz (glibc 2.39+) ubuntu-24.04 (cross-compiled; CLI smoke-tested under QEMU)
AppImage updates soundcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync with the AppImage
Flatpak x86_64, aarch64 soundcraft-<v>-linux-{x86_64,aarch64}.flatpak ubuntu-24.04, ubuntu-24.04-arm (repackages the Linux tarball)
FreeBSD 14 x86_64 soundcraft-<v>-freebsd-x86_64.tar.gz (a /usr/local tree) FreeBSD 14.3 VM on ubuntu-latest
Web soundcraft-web-<v>.zip (static site; see packaging/web/README.md) ubuntu-latest

The from-source Flatpak manifest (packaging/linux/flatpak/ai.storyteller.soundcraft.yml) is kept ready for a Flathub submission; the release's .flatpak bundles come from ai.storyteller.soundcraft.bundle.yml (see Linux below).

Audio libraries

SoundCraft plays and records through cpal: CoreAudio on macOS, WASAPI on Windows, ALSA on Linux and FreeBSD (PulseAudio and PipeWire through their ALSA plugins), and WebAudio in the browser.

  • Linux builds need the ALSA headers (libasound2-dev and pkg-config on Ubuntu). The binary links libasound.so.2, so the .deb depends on libasound2 | libasound2t64 and the .rpm on alsa-lib; the AppImage and tarball expect it on the host (every desktop distro has it).
  • FreeBSD builds need alsa-lib (pkg install alsa-lib); at runtime alsa-plugins-oss routes ALSA to the OSS sound(4) devices. Without a device the player falls back to its silent clock, so the transport still runs.
  • Flatpak: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the manifests grant --socket=pulseaudio (PipeWire's pulse server answers on it). No raw ALSA devices and no JACK: SoundCraft doesn't use either.
  • Web: browsers start audio only after a user gesture, so the first click in the page may be needed before sound plays. The web build has no recording.

macOS

packaging/macos/package.sh builds aarch64-apple-darwin and x86_64-apple-darwin with MACOSX_DEPLOYMENT_TARGET=11.0, joins them with lipo, and assembles SoundCraft.app:

  • Info.plist is generated from Info.plist.in. The bundle id is ai.storyteller.soundcraft. It declares the .scraft session type (ai.storyteller.soundcraft.session, MIME application/x-soundcraft-session, role Editor, rank Owner) and lists WAV, AIFF/AIFC, FLAC, MP3 and MIDI as Viewer/Alternate, so SoundCraft appears under Open With without taking over the default player. NSMicrophoneUsageDescription explains the recording prompt.
  • Signing goes inside-out with the hardened runtime and a secure timestamp. The executable is signed first, then the bundle. There's no --deep on the final signature. entitlements.plist holds a single exception, com.apple.security.device.audio-input, without which the hardened runtime blocks recording.
  • Notarization: the app is zipped and sent with xcrun notarytool submit --wait, then the ticket is stapled to the app. The app goes on a DMG (hdiutil, with an Applications link). The DMG is signed, notarized and stapled too. Its Finder window (background, icon size and positions) comes from packaging/macos/dmg/, and its volume is named SoundCraft without the version, which the window's background needs; the DMG file name keeps the version.
  • CLI: the universal soundcraft-cli is signed with the hardened runtime, zipped and notarized.

Locally, without certificates, the script signs ad-hoc (codesign -s -) and skips notarization:

packaging/macos/package.sh                    # universal; needs both rustup targets
packaging/macos/package.sh --arch aarch64     # quicker, host-only
open dist/release/soundcraft-*-macos-*.dmg

Windows

packaging/windows/package.ps1 -Arch x64|x86|arm64 builds with -C target-feature=+crt-static (in CARGO_TARGET_<TRIPLE>_RUSTFLAGS, so host build scripts aren't affected), so neither the MSI nor the portable zip needs the Visual C++ redistributable.

  • apps/soundcraft/build.rs embeds the icon (assets/app-icon/soundcraft.ico) and VERSIONINFO with winresource, only when targeting Windows. Release packaging sets SOUNDCRAFT_REQUIRE_WINRES=1 so a missing resource compiler fails the build.
  • The script checks both PE headers: the machine type matches -Arch, soundcraft.exe is a GUI app (no console window) and soundcraft-cli.exe a console app.
  • soundcraft.wxs (WiX v5) is a dual-scope package (per-user by default into %LOCALAPPDATA%\Programs\ArtCraft\SoundCraft, per-machine into Program Files\ArtCraft\SoundCraft (Program Files (x86) for the 32-bit build), see packaging/windows/INSTALLATION.md) with an advertised Start Menu shortcut. SoundCraft becomes the default app for .scraft and is listed under Open with for .wav, .aif, .aiff, .flac, .mp3, .mid and .midi. It also registers App Paths (Win+R soundcraft). The MSI version is the numeric X.Y.Z; same-version upgrades let release candidates replace each other. The upgrade code EE53D3E5-D0AE-40F9-808D-9417FDEBF61A is fixed forever.
  • Signing: packaging/windows/sign.ps1 signs both .exe files and then the .msi with signtool (SHA-256, RFC 3161 timestamp), using a .pfx (WINDOWS_CERTIFICATE*) if present, otherwise Azure Trusted Signing (all six AZURE_*), otherwise nothing (a warning).

Locally on Windows: dotnet tool install -g wix --version 5.0.2, then pwsh packaging/windows/package.ps1 -Arch x64.

Linux

packaging/linux/package.sh stages one FHS tree and makes every format from it: both binaries, ai.storyteller.soundcraft.desktop (with MimeType= for sessions, WAV, AIFF, FLAC, MP3 and MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a shared-mime-info file declaring application/x-soundcraft-session (*.scraft), and soundcraft-cli's man pages and bash, zsh and fish completions. The man-and-completions example, built with the binaries, writes those; a cross build runs it through EMULATOR and leaves them out without one. The zsh completion is in /usr/share/zsh/site-functions, which Fedora's and openSUSE's zsh read; the .deb also links it from /usr/share/zsh/vendor-completions, which Debian's zsh reads instead.

  • AppImage (maintained AppImage/appimagetool, static runtime: no libfuse2 needed). Each embeds the update information gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-<arch>.AppImage.zsync, and the .zsync next to it (written when zsyncmake from the zsync package is installed, as in CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release.
  • .deb and .rpm by nfpm from one nfpm.yaml; the postinst.sh hook refreshes the desktop, MIME and icon caches.
  • .tar.gz for people who manage their own /opt or ~/.local.
  • Flatpak: the flatpak job runs packaging/linux/flatpak-bundle.sh on the Linux job's tarball (no Rust build) with flatpak/ai.storyteller.soundcraft.bundle.yml, then installs the bundle and runs soundcraft-cli --version in the sandbox. Install with flatpak install --user soundcraft-<v>-linux-<arch>.flatpak. Both manifests use the freedesktop 25.08 runtime with Wayland/X11, dri, PulseAudio and xdg-music; other files go through the portals. For the VST3 and CLAP hosts they grant ~/.vst3 and ~/.clap read-only and mount Flathub's org.freedesktop.LinuxAudio.Plugins extensions (pointed to by VST3_PATH and CLAP_PATH); system plugin folders aren't visible in the sandbox. ai.storyteller.soundcraft.yml builds from source for Flathub and needs vendored crates (cargo-sources.json from flatpak-cargo-generator.py); the commands are in its header.

Binaries are built on Ubuntu 22.04 and need glibc ≥ 2.35. X11, Wayland, xkbcommon, Vulkan and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations.

Locally (on Linux): install nfpm, then packaging/linux/package.sh (or --formats "deb tar").

FreeBSD

GitHub has no FreeBSD runners, so the freebsd job builds inside a FreeBSD 14.3 VM (vmactions/freebsd-vm, pinned by commit) and runs packaging/freebsd/package.sh, which stages a /usr/local-style tree (binaries, desktop entry, MIME type, metainfo, icons, docs, and soundcraft-cli's man pages and shell completions) as a .tar.gz. Install with tar -xzf soundcraft-*-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local. .github/workflows/freebsd.yml builds and tests the workspace there on every push to main.

Web

packaging/web/package.sh runs trunk build --release in apps/soundcraft-web (output in dist/web, public_url = "./") and zips the site with sample _headers and .htaccess files and the hosting guide. apps/soundcraft-web runs the same SoundApp as the desktop app on the synthesised demo session, rendering with wgpu (WebGPU, or WebGL2 with ?webgl) and playing through WebAudio.

Secrets

All secrets live in the repository's release environment (Settings → Environments → release), restricted to the release branch. Only the jobs that sign (macOS, Windows) and the draft-release job declare environment: release; the others get no secrets. Each secret is optional: if one is missing, that platform's artifacts are unsigned and the run shows a ::warning::. Scripts never echo secret values; certificates are written with umask 077 and deleted in always()/finally steps.

Secret Used for
APPLE_CERTIFICATE base64 .p12 with "Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
APPLE_CERTIFICATE_PASSWORD password for that .p12
KEYCHAIN_PASSWORD password for the temporary CI keychain (random if unset)
APPLE_ID Apple ID used by notarytool
APPLE_PASSWORD app-specific password for that Apple ID
APPLE_TEAM_ID DJ6XS33FX8
WINDOWS_CERTIFICATE / WINDOWS_CERTIFICATE_PASSWORD base64 .pfx (unused: Windows uses Azure)
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET service principal for Azure Trusted Signing
AZURE_SIGNING_ENDPOINT, AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE Trusted Signing endpoint, account and profile

GITHUB_TOKEN creates the release. Only the final job gets contents: write. Provisioning the environment, ruleset and secrets for a new repo: craftrules release/signing-setup.md.

Icons

assets/app-icon/icon.svg is the canonical icon: a placeholder singing nightingale, drawn as original SVG in the three-colour icon palette (ink, paper, SoundCraft teal #14a9c4); see assets/app-icon/README.md. packaging/icons.sh regenerates the 1024 px PNG, the macOS 512 px window icon, the .icns, the .ico (packed by cargo xtask ico) and the hicolor PNGs from it. It needs resvg, plus iconutil on macOS. The outputs are committed, so packaging never needs those tools. apps/soundcraft/src/main.rs sets the runtime window/Dock/taskbar icon from them.

Checks

.github/workflows/packaging-lint.yml runs in seconds on any change to packaging/, the workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists, AppStream, MIME), desktop-file-validate, appstreamcli validate, and a check that the two Flatpak manifests agree on runtime, finish-args and plugin extensions.