# Builds moho for a pushed version tag (v1.0.0) and publishes the release, # with the notes for that version taken from CHANGELOG.md and its files named # after the version (#239). Every version tag publishes a full release, marked # GitHub's "Latest" - a release candidate (v1.0.0-rc.1) included, so it is # shown on the repository's page like any other release. # # Tags only. A push to master builds nothing: master and a tag on it were two # push events, so every release was built twice. To check a branch without # releasing it, run this by hand from the Actions tab - a run that is not for # a tag builds and tests everything and publishes nothing. # # The rolling `latest` build this file used to keep is retired: there are # versions now, and only versions. # # The shape mirrors how this is built by hand: the daemon first, then the app # around it, with the Windows installer cross-compiled from Linux exactly as # scripts/build-daemon-win.sh does locally. There is no Windows runner and # there does not need to be one - the .exe CI ships is then the same kind of # binary that has been shipped and tested from this desk all along. # # Every build step is an existing npm script rather than a reimplementation of # one. scripts/build-daemon.sh and build-daemon-win.sh stay the single # description of how the daemon gets built; if they change, this follows # without being edited. # # Two properties this file is arranged to keep: # # - Nothing reaches a release unless typecheck and the Rust tests passed. # That is structural rather than a convention: `publish` has `needs:` on # both build jobs, and a needs-dependency on a failed job never runs. # - What is published was built from a clean tree. See the "the tree is # clean" step for why that is not automatic. name: release on: push: tags: ['v*'] workflow_dispatch: # A hand-started check of a branch gives way to a newer one of the same # branch. A tag is its own ref and is never cancelled, by a check or by # another tag. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref_type != 'tag' }} # Read by default. Only `publish` asks for more, and only for itself. permissions: contents: read env: # A release build does not use incremental compilation anyway; saying so # keeps an incremental/ directory out of the cached target dir, where it # would be carried back and forth forever and never hit. CARGO_INCREMENTAL: 0 CARGO_TERM_COLOR: always # crates.io from a hosted runner is not always well, and a connection reset # forty minutes in is an expensive way to learn that. CARGO_NET_RETRY: 10 RUSTUP_MAX_RETRIES: 10 jobs: # -------------------------------------------------------------------- # Linux: the AppImage and the deb, and the job carrying the test gate. # # cargo test lives here rather than in a job of its own on purpose. A # standalone gate would compile all 812 crates a third time, in the dev # profile, sharing nothing with either build. nobilis has no # [dev-dependencies] and no [profile] overrides, so run in the release # profile straight after `npm run daemon` it reuses every rlib that build # just produced and compiles only the crate's own test harness. That is the # difference between minutes and another half hour - and it is what # PKGBUILD's check() already does locally. # -------------------------------------------------------------------- build-linux: # Pinned to the oldest distribution moho supports, because the daemon is a # native binary and glibc symbol versions only go forwards: one built # against 2.39 refuses to start on anything older, whatever the AppImage # around it can do. Built here on Arch that floor came out at 2.39 and on # ubuntu-latest (24.04) it would be the same, while Ubuntu 22.04 has 2.35 - # so the released daemon would not run on a distribution this project # claims to support, and the failure would look like the app starting and # the backend never coming up. # # Building on 22.04 sets the floor at 2.35, which every newer target # satisfies: Debian 13 is on 2.41, Ubuntu 24.04 on 2.39. The Windows job # needs none of this - it cross-builds with mingw and links no glibc at # all. When this label is eventually retired, the replacement is a # `container: ubuntu:22.04` on a current runner, not a bump to the next # label along. runs-on: ubuntu-22.04 timeout-minutes: 90 outputs: # Published for the release body. It must not be recomputed there: # git's --short length scales with the object count, so slicing the sha # to a fixed width is a guess that eventually names a file that does not # exist. This is the same string that named the artifacts. build: ${{ steps.stamp.outputs.build }} steps: # nobilis is a submodule at a commit its own master holds, which is why # this workflow only ever runs on master. # # Depth 1 is enough: nobilis/build.rs only asks git for a short sha and # a dirty flag, and falls back to "unknown" rather than failing. - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 1 # libopus-dev is deliberately absent. Without it libopus_sys builds a # static opus through CMake, which is what we want in something that has # to run on a machine we know nothing about. libasound2-dev is not # optional - cpal reaches alsa-sys, which wants it. - name: system build dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential cmake pkg-config libasound2-dev xvfb # The image ships a stable rust, but its version floats and this tree's # effective floor is 1.93 (matrix-sdk-crypto). Ask explicitly. # # The version goes into the cache key below: artifacts compiled by # another rustc cannot be reused, and a restored target dir full of them # rebuilds everything while reporting a cache hit, which is a confusing # way to lose twenty minutes. - name: rust toolchain run: | rustup toolchain install stable --profile minimal --component clippy --no-self-update rustup default stable echo "RUSTC_VERSION=$(rustc -V | tr ' /' '--')" >> "$GITHUB_ENV" - uses: actions/setup-node@v4 with: node-version: 22 cache: npm # Registry index and downloaded crates only. registry/src is the # extracted form of registry/cache and cargo recreates it on demand, so # caching it doubles the archive for nothing. - name: cache cargo registry uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db key: cargo-registry-${{ hashFiles('nobilis/Cargo.lock') }} restore-keys: cargo-registry- # The cache that matters. What costs half an hour here is compiling, not # downloading: aws-lc and bundled sqlite go through CMake and cc, and the # arti and matrix-sdk trees are large. A clean release build of this is # about 1.7 GB of artifacts. # # The key deliberately excludes the commit sha. With the sha in it every # push writes a fresh entry, and two jobs a few pushes a day evict the # 10 GB repository budget within a day - leaving a cache that is never # warm. Keyed on the lockfile it is written when dependencies change and # hit on every push after that, so the only thing recompiled per push is # nobilis itself, which is the few minutes that genuinely cannot be # avoided. # # ImageOS is in the key because the runner image is part of what built # these artifacts, and pinning this job to 22.04 changed it without # changing anything the key looked at. The next run restored a tree # compiled on 24.04 under an exact-match key, cargo believed the crates # it named were already built, and the build died on "can't find crate # for tor_bytes" - a dependency whose rlib the restored tree did not # have. The Windows job, still on ubuntu-latest, restored its own # matching cache and passed, which is how the image turned out to be # the variable. - name: cache the cargo target directory uses: actions/cache@v4 with: path: nobilis/target key: cargo-target-linux-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} # A lockfile bump should start from the previous tree and rebuild # what actually changed, rather than starting from nothing. Scoped # to the image for the reason above: falling back across images is # the same trap one step further down. restore-keys: cargo-target-linux-${{ env.ImageOS }}- # The Electron binary, and the appimagetool and nsis bundles # electron-builder fetches at pack time. Small, and it removes a real # source of flakes: those are rate-limited downloads from GitHub. - name: cache electron downloads uses: actions/cache@v4 with: path: | ~/.cache/electron ~/.cache/electron-builder key: electron-${{ hashFiles('package-lock.json') }} restore-keys: electron- - run: npm ci # Before anything expensive. `npm run pack` builds the daemon first and # typechecks afterwards, so without this a missing semicolon would not # surface until half an hour in. The repeat inside pack costs seconds. # A version tag that disagrees with the version in package.json would # publish "v1.0.0" carrying an app that calls itself something else in # Settings > About. Said before half an hour of building, not after. - name: the tag matches the version if: github.ref_type == 'tag' run: | want="$(node -p 'require("./package.json").version')" if [ "$GITHUB_REF_NAME" != "v$want" ]; then echo "::error::tag $GITHUB_REF_NAME, but package.json says $want - tag v$want, or bump the version first" exit 1 fi - name: typecheck run: npm run typecheck # The window's own unit tests: its formatting, its emote and media URL # rewriting, the rail, search, mentions - and the media scheme's # boundary. Seconds, so here beside the typecheck rather than after the # long build. - name: unit tests run: npm test # Two jobs in one line. It warms the registry, and --locked turns a # stale lockfile into a loud failure here rather than a silent rewrite # later: the npm scripts call plain `cargo build`, which would quietly # update Cargo.lock, dirty the working tree, and name the asset # moho--dirty out of a pristine checkout. - name: verify the lockfile, and fetch what it names run: cargo fetch --locked --manifest-path nobilis/Cargo.toml - name: build the daemon run: npm run daemon # The runner pin above is load-bearing and invisible: nothing about a # daemon built on too new a distribution looks wrong until somebody on # an older one finds the backend never starts. So it is asserted rather # than trusted, here where the answer is cheap and the failure names # itself. - name: the daemon runs on the oldest target run: | floor="$(objdump -T nobilis/target/release/nobilis \ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1)" echo "highest glibc symbol required: $floor" highest="$(printf '%s\n' "${floor#GLIBC_}" 2.35 | sort -V | tail -1)" if [ "$highest" != "2.35" ]; then echo "::error::the daemon needs $floor, which Ubuntu 22.04 (2.35) does not have" exit 1 fi # Reuses the release artifacts built one step above. The 19 tests that # want the network, Tor, a sound device or real credentials are all # #[ignore]d, so this is hermetic on a runner. - name: cargo test run: cargo test --release --locked --manifest-path nobilis/Cargo.toml # Held at zero, so a lint is the change that introduced it rather than # one more in a pile nobody reads. Release profile for the same reason # as the tests: the dependencies were checked by the build above. - name: cargo clippy run: cargo clippy --release --locked --all-targets --manifest-path nobilis/Cargo.toml -- -D warnings # Layout, which only a real browser can check: the built app is run # against a scratch home and a fake IRC server, every popup is opened at # two window sizes, and each must stay inside the window - measured at # several moments, because a popup that grows after it opens is the bug # this was written for. Needs the daemon built above. - name: layout check run: xvfb-run -a npm run test:ui # The tray cannot be looked at, so this reads what main gave it - icon, # tooltip, menu - and presses its entries, including Restart moho, which # has to bring back both the window and a daemon of its own. - name: tray check run: xvfb-run -a npm run test:tray # GIFs: that a message carrying one is drawn as the picture, that a page # nothing could read stays a link, and that the picker finds and sends. # Needs ffmpeg, for the pictures it plays. - name: gif check run: | sudo apt-get install -y --no-install-recommends ffmpeg xvfb-run -a npm run test:gif # Scrolling through a long unread run: that the view stays where it is # read when something above it changes size, with the "new messages" bar # up over the top of the log. - name: scroll check run: xvfb-run -a npm run test:scroll # Kick event cards: where they sit in each layout, measured against a # chat line beside them - the picture's column, the name's place, and the # air above. - name: kick card check run: xvfb-run -a npm run test:kick # MOHO_BUILD is computed inside the npm script - a shell assignment on # the electron-builder line - so it cannot be injected from out here. # The only way to control what the artifact is called is to guarantee # the tree it is named after is clean. # # Failing is the point. A -dirty suffix on a public release built from a # clean checkout is a lie about provenance, and it would also put the # file's name out of step with the commit the release body claims. - name: the tree is clean id: stamp run: | if ! git diff --quiet HEAD; then echo "::error::the checkout was modified during the build - the artifact would be named -dirty" git status --porcelain exit 1 fi echo "build=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" # `npm run daemon` inside pack is a no-op against the tree just built. # --publish never because there is no publish configuration and no token # in this job - saying so means a later package.json change cannot # quietly turn a build job into a publishing one. - name: pack the AppImage and the deb run: npm run pack:linux -- --publish never # Here to be read on the first few runs: it says how much headroom there # actually was, rather than how much was estimated. - run: df -h / if: always() # The two packages only. electron-builder also emits a .blockmap, a # latest-linux.yml for its own updater and a builder-debug.yml, none of # which belong on the release - so this names what to take rather than # sweeping dist/. - uses: actions/upload-artifact@v4 with: name: linux path: | dist/*.AppImage dist/*.deb if-no-files-found: error retention-days: 7 # The daemon on its own, for nobilis's release - attached to that by # scripts/release-nobilis.sh, since this job's token can only release # here. Not an asset of moho's release: the AppImage already carries it. - uses: actions/upload-artifact@v4 if: github.ref_type == 'tag' with: name: daemon-linux path: nobilis/target/release/nobilis if-no-files-found: error retention-days: 30 # -------------------------------------------------------------------- # Windows, cross-compiled. Same runner OS, same scripts, plus mingw. # # In parallel with the Linux job rather than after it, so a run takes as # long as the slower of the two rather than the sum. The trade is that a # failing test does not stop this job finishing: wasted compute, never a # wasted publish. # -------------------------------------------------------------------- build-windows: runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 1 # No pkg-config and no libasound2-dev here, deliberately: the cross # build must not find the host's Linux libraries. In particular do NOT # set PKG_CONFIG_ALLOW_CROSS - libopus_sys' pkg-config probe correctly # fails on the cross target and falls back to building opus from its # vendored C source, and allowing cross would let it link the host's # Linux libopus instead. That produces an .exe that fails on somebody # else's machine rather than here, which is the worst place to find out. # wine is here because electron-builder needs it even though nothing is # signed. Packaging for Windows runs the executable through signtool, # which is a Windows program, and it is that call - not the signing, # which it then skips for want of a certificate - that wants wine. The # first run died at exactly this step with "wine is required". # # The alternative is win.signAndEditExecutable: false, which skips the # call and with it the icon and version metadata the installer shows. # Paying a minute of apt for an .exe that looks like the one we ship by # hand is the better trade. # # Asked for by both names because the package split differs between # Ubuntu releases, then checked: an install that quietly put no `wine` # on PATH would fail thirty minutes later at the pack step instead of # here. - name: system build dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential cmake gcc-mingw-w64-x86-64 # i386 as well as amd64, because the program wine is needed for is # a 32-bit one: electron-builder writes the icon and version # metadata into the .exe with rcedit-ia32.exe, and a 64-bit-only # wine dies loading it on a missing syswow64 ntdll. sudo dpkg --add-architecture i386 sudo apt-get update # Recommends left ON here, unlike everything above. The `wine` # package is a thin thing whose 32-bit half arrives as a recommend; # asking for wine64 and wine32 directly instead installs # /usr/lib/wine/wine64 and nothing in /usr/bin at all, which is a # wine that no caller can find. sudo apt-get install -y wine # Ubuntu names the binary wine-stable and hangs /usr/bin/wine off it # through alternatives, which may or may not have been set up. # electron-builder looks for `wine`, so make sure that name exists # whichever way round this image has it. if ! command -v wine >/dev/null; then target="$(command -v wine-stable || true)" [ -n "$target" ] || { echo "::error::wine installed but no binary is on PATH"; exit 1; } sudo ln -s "$target" /usr/local/bin/wine fi wine --version # And prove the 32-bit half is really there rather than trusting # apt, by building the prefix and looking for the very file whose # absence failed the pack step. `wine --version` passed on a wine # that could not run rcedit at all, so on its own it is not a check # worth having. WINEDEBUG=-all timeout 300 wine wineboot --init >/dev/null 2>&1 || true test -f "$HOME/.wine/drive_c/windows/syswow64/ntdll.dll" \ || { echo "::error::wine has no 32-bit support - rcedit cannot run"; exit 1; } - name: rust toolchain run: | rustup toolchain install stable --profile minimal --no-self-update rustup default stable rustup target add x86_64-pc-windows-gnu echo "RUSTC_VERSION=$(rustc -V | tr ' /' '--')" >> "$GITHUB_ENV" - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: cache cargo registry uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db key: cargo-registry-${{ hashFiles('nobilis/Cargo.lock') }} restore-keys: cargo-registry- # Its own key, not shared with the Linux job. The same directory holds # different things here: host artifacts for build scripts and proc # macros under target/release/, and the real output under # target/x86_64-pc-windows-gnu/. One key for both would be two jobs # fighting over one entry and neither getting a hit. - name: cache the cargo target directory uses: actions/cache@v4 with: path: nobilis/target # Scoped to the image for the same reason as the Linux job's: this # directory also holds host-built artifacts - build scripts and # proc macros - even though the binary itself is cross-compiled. key: cargo-target-windows-gnu-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }} restore-keys: cargo-target-windows-gnu-${{ env.ImageOS }}- - name: cache electron downloads uses: actions/cache@v4 with: path: | ~/.cache/electron ~/.cache/electron-builder key: electron-${{ hashFiles('package-lock.json') }} restore-keys: electron- - run: npm ci - name: typecheck run: npm run typecheck - name: verify the lockfile, and fetch what it names run: cargo fetch --locked --manifest-path nobilis/Cargo.toml # build-daemon-win.sh builds for the gnu target and copies the .exe to # nobilis/target/release/, which is where extraResources looks for it. # It checks that file exists rather than trusting the exit code - # against a restored target dir that is exactly the stale-binary case it # was written for. - name: build the Windows daemon run: npm run daemon:win - name: the tree is clean run: | if ! git diff --quiet HEAD; then echo "::error::the checkout was modified during the build - the artifact would be named -dirty" git status --porcelain exit 1 fi - name: pack the installer run: npm run pack:win -- --publish never - run: df -h / if: always() - uses: actions/upload-artifact@v4 with: name: windows path: dist/*.exe if-no-files-found: error retention-days: 7 - uses: actions/upload-artifact@v4 if: github.ref_type == 'tag' with: name: daemon-windows path: nobilis/target/release/nobilis.exe if-no-files-found: error retention-days: 30 # -------------------------------------------------------------------- # A version tag only: the release, its files and its changelog. # # The notes are the version's section of CHANGELOG.md - major features, # then fixes, patches and changes, each linked to its commit - followed by # the downloads table. A version with no section is not published: a # release without its changelog is the one thing this step exists to stop. # -------------------------------------------------------------------- publish: if: github.ref_type == 'tag' needs: [build-linux, build-windows] runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} BUILD: ${{ needs.build-linux.outputs.build }} steps: # Only the changelog is needed from the tree. - uses: actions/checkout@v4 with: sparse-checkout: CHANGELOG.md sparse-checkout-cone-mode: false - uses: actions/download-artifact@v4 with: pattern: '{linux,windows}' path: artifacts merge-multiple: true # Three files, no more and no fewer: AppImage, deb, Windows installer. # Built named after the commit (the name is decided inside the npm # script); published named after the version. - name: name the files after the version run: | version="${GITHUB_REF_NAME#v}" test "$(find artifacts -maxdepth 1 -type f | wc -l)" -eq 3 for f in artifacts/moho-"$BUILD".*; do mv -v "$f" "artifacts/moho-$version.${f##*.}" done ls -l artifacts - name: compose the notes from the changelog run: | version="${GITHUB_REF_NAME#v}" # The section headed "## []", up to the next "## [". section() { awk -v v="$1" ' index($0, "## [" v "]") == 1 { on = 1; next } on && /^## \[/ { exit } on { print } ' CHANGELOG.md } section "$version" > "$RUNNER_TEMP/changes.md" # A release candidate rehearses its release's notes, unless it has # a section of its own: v1.0.0-rc.1 publishes "## [1.0.0]". if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/changes.md" && [ "${version%%-*}" != "$version" ]; then section "${version%%-*}" > "$RUNNER_TEMP/changes.md" fi if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/changes.md"; then echo "::error::CHANGELOG.md has no section for $version - write it, then tag again" exit 1 fi { cat "$RUNNER_TEMP/changes.md" echo echo "## Downloads" echo echo "| File | Platform |" echo "| --- | --- |" echo "| \`moho-$version.AppImage\` | Linux x86_64 - mark it executable and run it |" echo "| \`moho-$version.deb\` | Debian / Ubuntu x86_64 |" echo "| \`moho-$version.exe\` | Windows x64 - a per-user installer, no administrator needed |" echo echo "**The Windows installer is not code-signed.** SmartScreen warns when you run it:" echo "choose *More info*, then *Run anyway*. If Windows 11's **Smart App Control** is on," echo "moho installs but never opens; turn it off in *Windows Security > App & browser" echo "control > Smart App Control settings*. The installer checks and warns." } > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" # Created as a draft and published once its files are attached, so # nobody lands on a version with nothing to download. Always a full # release and GitHub's "Latest", a release candidate included - shown on # the repository's page, not tucked away as a pre-release. A tag pushed # again finds its release there: the files are replaced, the notes too, # since the changelog is where they are corrected. - name: publish the version run: | version="${GITHUB_REF_NAME#v}" if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then gh release edit "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" gh release upload "$GITHUB_REF_NAME" artifacts/* --clobber else gh release create "$GITHUB_REF_NAME" --draft --verify-tag \ --title "moho $version" --notes-file "$RUNNER_TEMP/notes.md" gh release upload "$GITHUB_REF_NAME" artifacts/* gh release edit "$GITHUB_REF_NAME" --draft=false --latest fi