Skip to content

chore(flathub): run Flathub's own submission linter (#449) #671

chore(flathub): run Flathub's own submission linter (#449)

chore(flathub): run Flathub's own submission linter (#449) #671

name: Linux desktop build
# Keeps the native Linux target building. Linthra's Android CI is unchanged and
# stays the gate it already is; this runs alongside it so an Android-shaped
# change that quietly breaks the desktop build — a new plugin with no Linux
# implementation, a service that initialises eagerly, an edit that regenerates
# the linux/ runner from the Flutter template — fails on the PR that made it
# rather than the next time someone tries to package a release.
#
# It builds the real application, not a stripped-down probe: analyze and the
# full test suite run against the same checkout, then `flutter build linux
# --release` produces the bundle a developer would run.
#
# This same job also serves the Linux release path, via workflow_dispatch's
# optional `release_tag` input — NOT a `release: published` trigger. Events
# produced using the default GITHUB_TOKEN (which is how Linthra's release
# workflows create tags and Releases) generally do not start new workflow
# runs; `workflow_dispatch` (and `repository_dispatch`) are the documented
# exceptions, so the orchestrating release workflows explicitly dispatch this
# one instead of relying on any push/release event to fire it:
#
# * Stable releases (`/publish-stable vX.Y.Z`): after
# publish-stable-release.yml creates the tag and the GitHub Release, it
# `gh workflow run`s this workflow with `release_tag=vX.Y.Z`, waits for it,
# and requires the resulting archive to verify the Release as published.
# * Alpha/beta/rc pre-releases pushed directly (`git push origin vX.Y.Z-...`
# by a human, not a bot): android-release-build.yml's existing
# attach-release job — itself triggered normally by that human's tag push
# — dispatches this workflow the same way, once the GitHub pre-release it
# just created/attached to actually exists.
#
# With `release_tag` set, `build-linux` checks out that EXACT tag (never
# `main` or whatever ref happens to be at the tip) and runs unchanged; a
# second job then packages the bundle it already built and validated into a
# `.tar.gz` and attaches it to that Release. See docs/release-process.md §4a
# for the full flow and where to look when a release build fails.
#
# Flatpak packaging is separate, later work (issue #376, PR 5). Nothing here
# assumes it. This workflow only ever produces the native Linux tarball.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
release_tag:
description: >-
Existing GitHub Release tag (e.g. v0.1.15) to build at and attach
the Linux .tar.gz to. Leave empty for a normal manual build with no
Release interaction.
required: false
type: string
default: ""
# Read-only by default: build-linux only inspects the checkout and builds it,
# identically on fork PRs, `main`, and a plain manual run. Only the
# package-linux-release job below (which only runs for a validated
# release_tag, never on PR/push) elevates to `contents: write`, and only for
# itself, to attach the Release asset.
permissions:
contents: read
jobs:
build-linux:
name: Build Linux desktop
runs-on: ubuntu-latest
outputs:
is_release_build: ${{ steps.resolve.outputs.is_release_build }}
release_tag: ${{ steps.resolve.outputs.release_tag }}
steps:
# Runs on every trigger, including plain PR/push builds, where
# `inputs.release_tag` is simply absent/empty and this exits immediately
# leaving `ref` empty (so the Checkout step below falls back to its
# normal default). When a tag IS supplied, this is the one place that
# decides whether the rest of the job builds a Release at all: it
# validates the tag's shape before it ever reaches a filename, and
# confirms — via the GitHub API, not just a guess — that a Release for
# it actually exists, since that Release is what the packaging job will
# upload to. A malformed or nonexistent tag fails here, before any
# native dependency install or Flutter build runs.
- name: Validate release_tag input
id: resolve
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG_INPUT: ${{ inputs.release_tag }}
run: |
set -euo pipefail
if [ -z "${RELEASE_TAG_INPUT:-}" ]; then
echo "is_release_build=false" >> "$GITHUB_OUTPUT"
echo "ref=" >> "$GITHUB_OUTPUT"
echo "No release_tag supplied; normal build (PR/push/manual), no Release interaction."
exit 0
fi
# Same tag grammar scripts/release_preflight.sh treats as the source
# of truth (vMAJOR.MINOR.PATCH, optionally -alpha.N / -beta.N /
# -rc.N) — not just "looks vaguely tag-shaped".
if [[ ! "$RELEASE_TAG_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
echo "::error::Unexpected release tag shape: $RELEASE_TAG_INPUT"
exit 1
fi
if ! gh release view "$RELEASE_TAG_INPUT" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::error::No GitHub Release found for tag $RELEASE_TAG_INPUT. Create/publish the Release first, then dispatch this workflow."
exit 1
fi
echo "is_release_build=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG_INPUT" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_TAG_INPUT" >> "$GITHUB_OUTPUT"
echo "Release $RELEASE_TAG_INPUT confirmed; building at that exact tag."
# For a push/pull_request/plain-manual run, steps.resolve.outputs.ref is
# empty, so this is exactly actions/checkout's own default behavior (PR
# merge ref / branch head) — unchanged from before this workflow had a
# release_tag input at all. For a validated release build it is the
# exact tag confirmed above.
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ steps.resolve.outputs.ref }}
# The native toolchain `flutter build linux` shells out to. Kept as an
# explicit list rather than a meta-package so it is obvious what the
# desktop build actually needs, and so it stays comparable with the
# Fedora package list in docs/linux-desktop.md:
#
# clang / cmake / ninja-build / pkg-config — the build itself
# libgtk-3-dev — the Flutter Linux embedder
# liblzma-dev, libstdc++-12-dev — Flutter tool prerequisites
# xauth / xvfb — headless native smoke display
# libsecret-1-dev — flutter_secure_storage_linux,
# i.e. encrypted credential
# storage. Not optional: the
# plugin fails to build
# without it, and Linthra
# never falls back to
# plaintext credentials.
# libmpv-dev — media_kit audio backend;
# also supplies runtime libmpv
# desktop-file-utils — desktop-file-validate, for
# the installed desktop entry
- name: Install Linux build dependencies
run: |
set -euo pipefail
# `apt-get update` exits 100 if *any* configured source fails, and the
# runner image ships third-party ones (Google Chrome, Microsoft) that
# none of the packages below come from. When one of those serves an
# index that disagrees with its own Release file, the whole job dies
# seconds in, having fetched every Ubuntu index perfectly well:
#
# E: Failed to fetch .../chrome-stable/.../Packages.gz
# Hash Sum mismatch
#
# So a partial update is tolerated and `apt-get install` below stays
# strict. That is the step whose success actually matters: if an index
# we really need is missing, the install fails on the package it
# cannot find, which is both louder and more honest than guessing here
# which sources were the important ones.
#
# Deliberately not deleting the offending source lists instead. Their
# names and format are the image's business (noble moved several to
# deb822 `.sources`), so matching on them is a check that silently
# stops working the next time the image is rebuilt.
sudo apt-get update || echo 'apt-get update was incomplete; the install below decides'
sudo apt-get install -y --no-install-recommends \
clang \
cmake \
ninja-build \
pkg-config \
libgtk-3-dev \
liblzma-dev \
libstdc++-12-dev \
libsecret-1-dev \
libmpv-dev \
desktop-file-utils \
xauth \
xvfb
# Installs the version pinned in .flutter-version, the same action every
# other workflow uses.
- name: Set up Flutter
uses: ./.github/actions/setup-flutter
- name: Enable Linux desktop
run: flutter config --enable-linux-desktop
# Same lockfile-enforced resolution as the Android jobs: the desktop build
# must use the exact committed dependency set, not a fresh resolution that
# could pull a different plugin version onto one platform only.
- name: Install dependencies (lockfile-enforced)
run: flutter pub get --enforce-lockfile
- name: Verify formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test
# Identity and build-hygiene guard for the committed runner: the GTK
# application id still matches Android's applicationId, the window title
# still matches AppInfo.name, and the offline SQLite seam is still wired.
# Cheap, and it catches a `flutter create` regeneration that the build
# itself would happily accept.
- name: Check Linux runner configuration
run: python3 scripts/check_linux_runner.py
- name: Test the Linux runner tooling
run: python3 test/tooling/check_linux_runner_test.py
# Syntax half of the desktop entry check (#434); the identity half — the
# filename, Name, Exec, Icon and the Flatpak install step — is in
# check_linux_runner.py above. desktop-file-validate exits 0 for
# everything it calls a warning, hint, or "error (will be fatal in the
# future)", so its exit status alone would let a deprecated key or a
# miscategorised entry through; any output at all is the failure signal.
- name: Validate the desktop entry
run: |
set -euo pipefail
report="$(desktop-file-validate linux/packaging/io.github.thezupzup.linthra.desktop)"
if [ -n "$report" ]; then
printf '%s\n' "$report" >&2
exit 1
fi
# A normal Flutter widget test cannot load the Linux plugin bundle, so
# this target runs in the real GTK runner and walks a full transport
# lifecycle (initialize, load, play, pause, seek, stop, dispose) against
# a local WAV it generates, through libmpv, without requiring audible
# output. Three cycles cover reinitialization. The same target runs inside
# the packaged Flatpak (#446, docs/flatpak-audio-smoke.md); this run uses
# the host's libmpv and so says nothing about packaging.
- name: Build native audio lifecycle smoke
run: >-
flutter build linux --release
--target=tool/linux_audio_backend_smoke.dart
# libmpv autoselects PipeWire on ubuntu-latest, which has no audio device,
# so the smoke target defaults to libmpv's own `null` output. That output
# still paces samples against the system clock, which is what the new
# position/pause/seek assertions need; ALSA's null PCM does not, and a
# five-second fixture used to finish in milliseconds against it. The
# override applies to this run only; production builds keep the host's
# normal audio.
- name: Run native audio lifecycle smoke
run: xvfb-run --auto-servernum build/linux/x64/release/bundle/linthra
# Release mode, because that is the build that has to work for packaging
# and it exercises the AOT path a debug build skips.
- name: Build Linux application
run: flutter build linux --release
# A build that "succeeds" without producing a runnable bundle is the
# failure mode worth catching explicitly — mirrors the APK size check in
# the Android debug workflow.
- name: Verify build output exists
run: |
set -euo pipefail
bundle="build/linux/x64/release/bundle"
binary="$bundle/linthra"
for required in "$binary" "$bundle/lib/libflutter_linux_gtk.so" "$bundle/data/flutter_assets"; do
if [ ! -e "$required" ]; then
echo "::error::Expected Linux build output missing: $required"
exit 1
fi
done
if [ ! -x "$binary" ]; then
echo "::error::$binary is not executable."
exit 1
fi
echo "Linux bundle present at $bundle."
- name: Upload Linux bundle
uses: actions/upload-artifact@v7
with:
name: linthra-linux-x64
path: build/linux/x64/release/bundle
if-no-files-found: error
# Packages the bundle build-linux already built and validated at the exact
# release tag, and attaches it to the GitHub Release named by that tag. Only
# reachable when build-linux confirmed a validated release_tag (see
# "Validate release_tag input" above) — never on a plain PR, push, or
# no-tag manual run, so this job never sees `contents: write` there. It
# never re-builds anything: it only downloads the artifact build-linux
# already produced and verified, tars it, and uploads it, so it cannot
# drift from what build-linux actually built.
package-linux-release:
name: Package and attach Linux release asset
needs: build-linux
if: ${{ needs.build-linux.outputs.is_release_build == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checked out first, and at the exact tag being released, for the
# containment verification below: the script and the message it compares
# against both live in the repository, and checkout cleans the workspace,
# so it has to run before the bundle is downloaded into it.
- name: Checkout the released tag
uses: actions/checkout@v7
with:
ref: ${{ needs.build-linux.outputs.release_tag }}
# Same split as the Android release build: the tag supplies the expected
# containment message, this workflow's own revision supplies the script
# that looks for it, so rebuilding an older tag is still verified.
- name: Check out the release tooling from this workflow's revision
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
path: .release-tooling
sparse-checkout: scripts
# The artifact upload above stores the *contents* of
# build/linux/x64/release/bundle at its root (linthra, lib/, data/, ...),
# so downloading it directly into the archive's staging directory already
# gives the correct internal layout — no extra copy/flatten step needed.
- name: Download Linux bundle
uses: actions/download-artifact@v8
with:
name: linthra-linux-x64
path: bundle
- name: Package Linux bundle
env:
# Crosses a job boundary via `needs.*.outputs`, already validated by
# build-linux's "Validate release_tag input" step — re-validated
# here too before it touches a filename, since a script should not
# trust an untyped string just because a previous job checked it.
# Kept in an env var and never interpolated directly into a run:
# script, so it can't be read as shell.
RELEASE_TAG: ${{ needs.build-linux.outputs.release_tag }}
run: |
set -euo pipefail
# Same tag grammar as the build-linux validation above and
# scripts/release_preflight.sh (vMAJOR.MINOR.PATCH, optionally
# -alpha.N / -beta.N / -rc.N).
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
echo "::error::Unexpected release tag shape: $RELEASE_TAG"
exit 1
fi
archive_dir="Linthra-${RELEASE_TAG}-linux-x64"
archive_file="${archive_dir}.tar.gz"
if [ ! -e "bundle/linthra" ]; then
echo "::error::Downloaded artifact is missing the linthra executable."
exit 1
fi
mkdir -p "$archive_dir"
cp -r bundle/. "$archive_dir/"
# upload-artifact/download-artifact round trips have lost the
# executable bit before; restore it explicitly rather than trust it
# survived, and verify the rest of the required layout is present.
chmod +x "$archive_dir/linthra"
for required in \
"$archive_dir/linthra" \
"$archive_dir/lib/libflutter_linux_gtk.so" \
"$archive_dir/data/flutter_assets"; do
if [ ! -e "$required" ]; then
echo "::error::Packaged Linux bundle missing: $required"
exit 1
fi
done
# Pack from the parent of archive_dir so the tar has no leading
# path components beyond the single top-level Linthra-<tag>-linux-x64/
# directory — never the build/linux/... or repo checkout hierarchy.
tar -czf "$archive_file" "$archive_dir"
# Validate the complete archive, then preview a few entries without a
# `tar | head` pipeline. With `set -o pipefail`, `head` closing early
# makes GNU tar report a broken-pipe write error even when the archive
# itself is perfectly valid.
archive_list="$RUNNER_TEMP/linux-release-archive-list.txt"
tar -tzf "$archive_file" > "$archive_list"
head -5 "$archive_list"
echo "archive_file=${archive_file}" >> "$GITHUB_ENV"
# Casting is withheld from shipped builds while a reported security issue
# is resolved (lib/core/services/cast/cast_containment.dart). The Android
# release build makes the same check on its APK/AAB before uploading them;
# this is the Linux half, and it runs *before* the upload on purpose —
# the Release is already public by this point, so an artifact that failed
# the check must never become downloadable from it. Fails closed; see the
# script's docstring for what it can and cannot see.
- name: Verify the archive carries the Cast containment
env:
ARCHIVE_FILE: ${{ env.archive_file }}
run: |
set -euo pipefail
source_file="lib/core/services/cast/cast_containment.dart"
# Only a tag from before the containment existed has nothing to
# check; everything else is verified with the script from this
# workflow's revision against that tag's own message, so an old tag
# is never a way to attach an unverified asset.
if [ ! -f "$source_file" ] || ! grep -q 'static const String userMessage' "$source_file"; then
echo "::warning::This tag predates the Cast containment, so its archive carries nothing to check. See docs/release-artifact-verification.md."
exit 0
fi
python3 .release-tooling/scripts/verify_release_containment.py \
--containment-source "$source_file" \
"$ARCHIVE_FILE"
- name: Attach archive to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.build-linux.outputs.release_tag }}
ARCHIVE_FILE: ${{ env.archive_file }}
run: |
set -euo pipefail
# --clobber replaces only an existing asset with this exact file
# name (a re-run of the same Release, e.g. after a failed first
# attempt); it never touches other assets on the Release, such as
# the Android APK/AAB attached by android-release-build.yml.
gh release upload "$RELEASE_TAG" "$ARCHIVE_FILE" \
--clobber \
--repo "$GITHUB_REPOSITORY"
echo "Attached $ARCHIVE_FILE to Release '$RELEASE_TAG'."