Files

622 lines
28 KiB
YAML

# 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-<sha>-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 "## [<version>]", 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