name: CI # Forgejo reads .forgejo/workflows before .github/workflows. Actions referenced # bare (actions/checkout, actions/cache, ...) resolve through the instance's # DEFAULT_ACTIONS_URL, which points at data.forgejo.org, so nothing here reaches # out to github.com. on: push: # Pushing a branch whose name starts with Release cuts a release: the version # comes from Cargo.toml and the release job creates the tag itself. Pushing a # v* tag by hand still works and takes the same path. The lowercase pattern is # there because branch names are case-sensitive and a silent no-op would be a # miserable thing to debug, as is the fact that * does not match / in these # filters - Release/0.9.1 needs the ** form to be seen at all. branches: [master, 'Release*', 'Release/**', 'release*', 'release/**'] tags: ['v*'] pull_request: branches: [master] workflow_dispatch: concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always # Incremental artifacts are never reused between CI runs and would bloat the # cached target/ tree for nothing. CARGO_INCREMENTAL: '0' RUST_BACKTRACE: '1' # Baked into the binaries by crates/quicksearch-gui/build.rs and shown in the # GUI status bar, so a screenshot or a bug report identifies the exact build. # Handed over rather than shelled out to git: checkout leaves a shallow clone # this would otherwise have to trust, and the runner already knows the SHA. # The version itself still comes from [workspace.package], as everywhere else. QS_COMMIT: ${{ github.sha }} jobs: # ---------------------------------------------------------------- linux ---- # # The container base is load-bearing: packaging/build-deb.sh derives the # package's libc6 floor with objdump from the binary it just built, so the # .deb inherits the *builder's* glibc. Ubuntu 22.04 fixes that floor at 2.35, # which covers 22.04 LTS and newer plus Debian 12 and newer. Packages built by # hand on a dev machine declared libc6 (>= 2.43) and installed on almost # nothing. # # catthehacker/ubuntu is the act-compatible image family. A bare ubuntu:22.04 # will not work: JS actions need Node already present in the image, and no # step can install it before actions/checkout runs. linux: runs-on: self-hosted container: image: catthehacker/ubuntu:act-22.04 # A container-level `options: --cap-drop=...` was tried here first and did # not take effect - the runner does not pass it through to the daemon that # creates the job container. The capabilities are dropped inside the Test # step instead, where nothing can ignore them. env: # crates/quicksearch-core/src/config.rs has a test that expects a home # directory and panics without one. HOME: /root # The highest libc6 version the .deb is allowed to require. MAX_GLIBC: '2.35' # full_index.rs asserts a heavy indexing root cannot stall a light one, and # measures that as wall-clock stall. The 100 ms default is calibrated on a # developer machine; this runner measured 188 ms for the same correct # behaviour. 600 ms keeps the check meaningful - the regression it exists to # catch is ~6x the healthy figure, so it would land near 1.2 s here. QSB_STALL_BUDGET_MS: '600' steps: - uses: actions/checkout@v4 - name: Check the tag matches the workspace version # Asset names come from [workspace.package] version, not from the tag, so # tagging v0.9.0 against version 0.8.8 would publish a release called # v0.9.0 full of 0.8.8 files. Fails in seconds, before anything is built. if: startsWith(github.ref, 'refs/tags/v') run: | version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) tag="${GITHUB_REF#refs/tags/}" if [ "v$version" != "$tag" ]; then echo "ERROR: tag $tag does not match the workspace version $version." >&2 echo "Bump [workspace.package] version in Cargo.toml, refresh Cargo.lock," >&2 echo "commit, delete the tag and re-tag." >&2 exit 1 fi echo "OK: $tag matches the workspace version" - name: Install build dependencies run: | apt-get update -qq # rusqlite's bundled-sqlcipher-vendored-openssl and keyring's vendored # feature compile SQLCipher, OpenSSL and libdbus from source, so a C # toolchain plus perl covers them and no -dev packages are needed. # winit and glutin dlopen the whole display stack, so there are no X11 # or Wayland headers here either. The rest is what build-deb.sh checks # for before it will run. # libcap2-bin provides capsh, which the Test step uses to drop the two # DAC capabilities so root obeys permission bits. # jq parses the tags API for the releasable check below. Named rather # than assumed: the release job gets it from its image, and a base # image change that dropped it would turn that check into a silent # pass rather than a failure. # zsync and appstream are for build-appimage.sh: appimagetool shells # out to zsyncmake rather than bundling it, and reports success while # writing nothing when it is absent, so the script checks for it up # front. appstream provides the appstreamcli that validates the # metainfo both packages ship. apt-get install -y --no-install-recommends \ build-essential perl pkg-config \ binutils dpkg-dev desktop-file-utils gzip libcap2-bin \ zsync appstream jq - name: Check the release tag is free # The other half of the guard above, for the path that actually cuts # most releases: pushing a Release* branch. There the tag comes from # [workspace.package] rather than the ref, so the mistake is not a # mismatched tag but a *forgotten bump* — the version still points at a # release that already shipped. # # The release job checks this too and remains the authority; it just # cannot check it until both build jobs are green, so forgetting the # bump used to cost two full release builds, packaging and an artifact # upload before anything said so. This says so in seconds. # # Deliberately as lenient as the release job: a tag at *this* commit is # a re-run and fine, and an unreachable API reads as "not found" and # lets the build proceed rather than failing on a network hiccup. An # early check that blocks a good release is worse than one that misses # a bad one, because the late check still catches it. if: >- startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') env: RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } tag="v$version" api="${GITHUB_API_URL:-$GITHUB_SERVER_URL/api/v1}" at=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \ "$api/repos/$GITHUB_REPOSITORY/tags/$tag" | jq -r '.commit.sha // empty') if [ -n "$at" ] && [ "$at" != "$GITHUB_SHA" ]; then echo "ERROR: $tag already exists at $at, not $GITHUB_SHA." >&2 echo "Bump [workspace.package] version in Cargo.toml, refresh Cargo.lock" >&2 echo "with 'cargo update -w', commit and push again." >&2 exit 1 fi echo "OK: $tag is free (or already at this commit)" - name: Trust the workspace # checkout writes as root into a directory git then considers dubiously # owned; build-deb.sh and the version scrape both shell out to git. run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Install Rust run: | # --default-toolchain none defers to rust-toolchain.toml, so the pinned # channel and its targets are downloaded exactly once. curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --profile minimal --default-toolchain none echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" # Materialise the pinned toolchain here rather than partway through the # build, so a toolchain problem shows up as its own failed step. "$HOME/.cargo/bin/rustup" show - uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target # Keyed per job so this tree never collides with the cross-compiled one. key: ${{ github.job }}-cargo-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} restore-keys: ${{ github.job }}-cargo- - name: Sync the lockfile to the workspace version # Bumping [workspace.package] version leaves Cargo.lock pinning the old # member versions, and --locked then refuses to build. `cargo update -w` # re-resolves only the workspace members and leaves every third-party pin # alone - it reports the others as "unchanged dependencies behind latest" # rather than bumping them. So --locked below still catches the case that # actually matters: a dependency added or bumped without committing the # lockfile, which this cannot and should not paper over. run: cargo update -w - name: Build run: cargo build --release --locked -p quicksearch-gui - name: Test # Release mode is not a nicety: tests/encrypted.rs derives an Argon2id key # at m=64 MiB, t=3, which takes about half a second in release and minutes # in debug. tests/snippet_perf.rs self-skips without QSB_SNIPPET_PERF=1. # # capsh drops CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH from the bounding # set before exec. Without that, uid 0 reads a mode-000 file or directory # regardless of its permissions, and the tests that build one with # platform::deny_read see a perfectly readable tree: they assert an # unreadable directory is *reported* rather than looking empty, because an # empty listing deletes index rows. Dropping from the bounding set is what # makes it stick - a root process re-derives its permitted set from the # bounding set on execve, so the test binaries cannot regain them. run: | echo "capabilities before: $(grep CapEff /proc/self/status | tr -d '\t')" capsh --drop=cap_dac_override,cap_dac_read_search -- -c ' echo "capabilities in test shell: $(grep CapEff /proc/self/status | tr -d "\t")" cargo test --release --locked --workspace' # Everything from here down produces release assets, so it runs only # where a release can actually come out: a v* tag or a Release* branch. # On master and pull requests the job stops after Build and Test, which # is what those runs are for — the packaging that used to follow built a # .deb, an AppImage, a tarball and a 14-day artifact upload that nothing # would ever download, because the release job is skipped there anyway. # # The condition is repeated rather than hoisted into an env var: Actions # has no workflow-level expression alias, YAML anchors are not supported, # and `env.X` inside `if:` would fail *closed* on a runner that did not # populate it — silently skipping packaging on a real release. Spelled # out, it is the same form the release job's own gate uses. - name: Build the .deb if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') # --no-build reuses the binaries from the Build step rather than # recompiling. SOURCE_DATE_EPOCH pins the generated changelog date so # repeat builds of the same commit are byte-identical. run: | SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" \ ./packaging/build-deb.sh --no-build - name: Check the glibc floor if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') # The whole point of pinning the container. If someone bumps the image, # this fails loudly instead of quietly shipping an uninstallable package. # Gated with the .deb it inspects — there is no package to read without # the step above. run: | deb=$(ls dist/*.deb) depends=$(dpkg-deb -f "$deb" Depends) echo "$depends" floor=$(printf '%s' "$depends" | sed -n 's/.*libc6 (>= \([0-9][0-9.]*\)).*/\1/p') [ -n "$floor" ] || { echo "could not read the libc6 floor from $deb" >&2; exit 1; } # dpkg's own comparator, so 2.9 does not sort above 2.35. if ! dpkg --compare-versions "$floor" le "$MAX_GLIBC"; then echo "ERROR: the .deb requires glibc $floor, above the $MAX_GLIBC target." >&2 echo "The build container base has probably changed." >&2 exit 1 fi echo "OK: glibc floor $floor <= $MAX_GLIBC" - name: Build the AppImage if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') # After the glibc gate, so the cheaper check still fails first. --no-build # reuses the binaries from the Build step, as the .deb step does, and # SOURCE_DATE_EPOCH pins the date substituted into the AppStream release # entry the same way it pins the .deb changelog. # # The script downloads appimagetool and the AppImage runtime, both pinned # by sha256, and needs no FUSE: it runs appimagetool with # APPIMAGE_EXTRACT_AND_RUN so the container needs no /dev/fuse. run: | SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" \ ./packaging/build-appimage.sh --no-build - name: Package the binaries if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') # A tarball for anyone not installing the .deb, stripped to match what # build-deb.sh ships. run: | version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } stage="quicksearch-$version-linux-x86_64" mkdir -p "dist/$stage" for bin in quicksearch quicksearch-cli; do install -m755 "target/release/$bin" "dist/$stage/$bin" strip --strip-unneeded "dist/$stage/$bin" done install -m644 README.md config_example.toml LICENSE "dist/$stage/" tar -czf "dist/$stage.tar.gz" -C dist "$stage" rm -rf "dist/$stage" ls -l dist/ # Not v4, and not plain v3 either. @actions/artifact v2+ (which backs # upload/download-artifact v4) inspects the API host, decides anything that # is not github.com is GitHub Enterprise Server, and refuses outright with # GHESNotSupportedError - it never reaches the server, so whether Forgejo # implements the v4 artifact API is beside the point. Plain v3 runs on # node16, which current Forgejo runner images no longer ship; the # -node20 tags are Forgejo's builds for precisely this combination. - uses: actions/upload-artifact@v3-node20 # `if-no-files-found: error` below would fail every master push once the # packaging steps above are gated, so this carries the same gate. if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') with: name: linux-x86_64 # The .zsync is not optional: the update URL baked into every AppImage # points at it, so leaving it unpublished breaks AppImageUpdate for # everyone who already installed one. It is the one asset named without # a version, because that URL has to keep resolving across releases. path: | dist/*.deb dist/*.tar.gz dist/*.AppImage dist/*.zsync if-no-files-found: error retention-days: 14 # -------------------------------------------------------- windows-cross ---- # # Deliberately a newer base than the linux job. This job emits a PE binary # linked against msvcrt.dll, so the container's glibc cannot affect what the # .exe runs on; pinning it to 22.04 would buy nothing while forcing the build # through mingw-w64 10.3 instead of 13.2. windows-cross: runs-on: self-hosted container: image: catthehacker/ubuntu:act-24.04 env: HOME: /root TARGET: x86_64-pc-windows-gnu # rusqlite, zstd-sys and openssl-src all shell out to a C compiler, which # has to be the cross one rather than the host's cc. CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar steps: - uses: actions/checkout@v4 - name: Install build dependencies run: | apt-get update -qq # nsis is what compiles the Windows installer, and it compiles it here # rather than on Windows: makensis is a Linux binary that emits a PE, # so both Windows assets come out of this one job. apt-get install -y --no-install-recommends \ build-essential perl make pkg-config \ gcc-mingw-w64-x86-64 zip nsis - name: Trust the workspace run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Install Rust run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --profile minimal --default-toolchain none echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" # Materialise the pinned toolchain here rather than partway through the # build, so a toolchain problem shows up as its own failed step. "$HOME/.cargo/bin/rustup" show - uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target key: ${{ github.job }}-cargo-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} restore-keys: ${{ github.job }}-cargo- - name: Sync the lockfile to the workspace version # Same reasoning as the linux job: workspace members only, third-party # pins untouched, so --locked keeps its teeth. run: cargo update -w - name: Build # rust-toolchain.toml already lists the target, so no `rustup target add`. run: cargo build --release --locked -p quicksearch-gui --target "$TARGET" - name: Check for non-system DLL dependencies # Debian's default mingw alternative uses posix threads, which can pull # in libwinpthread-1.dll or libgcc_s_seh-1.dll and produce an .exe that # refuses to start on a clean Windows machine. The binaries are currently # clean - every import is a system DLL - and this keeps them that way. run: | for exe in "target/$TARGET"/release/quicksearch.exe "target/$TARGET"/release/quicksearch-cli.exe; do dlls=$(x86_64-w64-mingw32-objdump -p "$exe" | sed -n 's/^[[:space:]]*DLL Name: //p' | sort -fu) printf '%s:\n%s\n\n' "$exe" "$dlls" if printf '%s\n' "$dlls" | grep -qiE '^(libgcc|libwinpthread|libstdc\+\+|libssp)'; then echo "ERROR: $exe imports a non-system DLL and will not run on a clean Windows install." >&2 exit 1 fi done # As in the linux job, the asset-producing steps run only where a release # can come out. The DLL check above deliberately stays ungated: it # validates the .exe itself rather than packaging it, costs an objdump, # and is exactly the kind of regression worth catching on master rather # than at release time. - name: Build the installer if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') # --no-build reuses the binaries from the Build step rather than # cross-compiling them a second time. The installer and the .zip below # are alternatives, not a two-step download: the installer puts the app # in Program Files with a Start menu entry and an uninstall entry, the # .zip is the same binaries for anyone who wants them unpacked by hand # or run portably. run: ./packaging/build-installer.sh --no-build - name: Package the binaries if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') run: | version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } stage="quicksearch-$version-windows-x86_64" mkdir -p "dist/$stage" for exe in quicksearch.exe quicksearch-cli.exe; do install -m755 "target/$TARGET/release/$exe" "dist/$stage/$exe" x86_64-w64-mingw32-strip --strip-unneeded "dist/$stage/$exe" done install -m644 README.md config_example.toml LICENSE "dist/$stage/" (cd dist && zip -qr "$stage.zip" "$stage") rm -rf "dist/$stage" ls -l dist/ # Not v4, and not plain v3 either. @actions/artifact v2+ (which backs # upload/download-artifact v4) inspects the API host, decides anything that # is not github.com is GitHub Enterprise Server, and refuses outright with # GHESNotSupportedError - it never reaches the server, so whether Forgejo # implements the v4 artifact API is beside the point. Plain v3 runs on # node16, which current Forgejo runner images no longer ship; the # -node20 tags are Forgejo's builds for precisely this combination. - uses: actions/upload-artifact@v3-node20 # Same gate as the packaging steps, for the same `if-no-files-found` # reason as the linux job. if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') with: name: windows-x86_64 path: | dist/*-setup.exe dist/*.zip if-no-files-found: error retention-days: 14 # -------------------------------------------------------------- release ---- # # Two ways in, one path out: # push a Release* branch -> the tag is derived from the workspace version # push a v* tag by hand -> that tag is used as-is # # Either way this runs only after linux and windows-cross are green, so a # failing test cannot produce a release. # # The tag is created by the same API call that creates the release. Creating it # in an earlier step would not work: a tag pushed with CI's own token does not # re-trigger workflows, so a design that tagged first and waited for the tag # event would stall with no release and no error. release: needs: [linux, windows-cross] if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/release') runs-on: self-hosted container: image: catthehacker/ubuntu:act-24.04 steps: # Only needed to read the workspace version on a Release* branch push. - uses: actions/checkout@v4 # Must match the uploader's major version - see the note on the upload steps. - uses: actions/download-artifact@v3-node20 with: path: artifacts - name: Publish the release # Forgejo populates GITHUB_API_URL, GITHUB_REPOSITORY and the token by # itself, so this needs no configuration. Talking to the API directly # keeps the release step off any third-party action's release cadence. env: RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | api="${GITHUB_API_URL:-$GITHUB_SERVER_URL/api/v1}" auth="Authorization: token $RELEASE_TOKEN" repo="$GITHUB_REPOSITORY" # One source of truth for the version: the same [workspace.package] # field build-deb.sh reads and the artifact names already carry. The # branch name only signals intent - nothing is parsed out of it. case "$GITHUB_REF" in refs/tags/*) tag="${GITHUB_REF#refs/tags/}" ;; *) version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } tag="v$version" ;; esac echo "release tag: $tag commit: $GITHUB_SHA" # A published version is immutable. If the tag already exists at some # other commit, stop rather than ship two different builds under one # version - the usual cause is forgetting to bump Cargo.toml. at=$(curl -sS -H "$auth" "$api/repos/$repo/tags/$tag" | jq -r '.commit.sha // empty') if [ -n "$at" ] && [ "$at" != "$GITHUB_SHA" ]; then echo "ERROR: tag $tag already exists at $at, not $GITHUB_SHA." >&2 echo "Bump [workspace.package] version in Cargo.toml, refresh Cargo.lock" >&2 echo "with 'cargo update -w', commit and push again." >&2 exit 1 fi mkdir -p dist find artifacts -type f -exec mv -t dist -- {} + ls -l dist/ # Creating a release whose tag_name does not exist yet makes Forgejo # create the tag at target_commitish, so this one call both tags and # publishes. Reuse an existing release so a re-run replaces assets # instead of failing. id=$(curl -sS -H "$auth" "$api/repos/$repo/releases/tags/$tag" | jq -r '.id // empty') if [ -z "$id" ]; then id=$(curl -fsS -X POST "$api/repos/$repo/releases" \ -H "$auth" -H 'Content-Type: application/json' \ -d "$(jq -n --arg t "$tag" --arg c "$GITHUB_SHA" \ '{tag_name: $t, target_commitish: $c, name: $t, draft: false, prerelease: false}')" \ | jq -r '.id // empty') fi [ -n "$id" ] || { echo "could not create or find a release for $tag" >&2; exit 1; } for f in dist/*; do name=$(basename "$f") echo "uploading $name" # Replace an asset of the same name left by an earlier run. old=$(curl -sS -H "$auth" "$api/repos/$repo/releases/$id/assets" \ | jq -r --arg n "$name" '.[] | select(.name == $n) | .id') [ -z "$old" ] || curl -fsS -X DELETE -H "$auth" \ "$api/repos/$repo/releases/$id/assets/$old" curl -fsS -X POST "$api/repos/$repo/releases/$id/assets?name=$name" \ -H "$auth" -F "attachment=@$f" -o /dev/null done echo "published $tag"