diff --git a/.github/actions/build-appimage/action.yml b/.github/actions/build-appimage/action.yml new file mode 100644 index 0000000..37fc9e0 --- /dev/null +++ b/.github/actions/build-appimage/action.yml @@ -0,0 +1,156 @@ +name: Build AppImage +description: | + Repackage a per-platform deadzone tarball into an AppImage. + Reads the tarball from the working directory, produces + `deadzone__linux_.AppImage` as an output. + + `AppImage/appimagetool` release `1.9.1` is pinned here instead of + the `continuous` tag; bumping it is a conscious step (same philosophy + as TOKENIZERS_VERSION in release.yml). The older `AppImage/AppImageKit` + repo is archived and all its release-13 assets have been renamed + `obsolete-*` — don't use them. + +inputs: + tarball: + description: Path (or glob) of the input tarball produced by the build job. + required: true + arch: + description: "`amd64` or `arm64` — matches the Linux build matrix." + required: true + icon: + description: Path to the 256x256 placeholder icon checked into the repo. + required: false + default: .github/assets/deadzone.png + +outputs: + appimage: + description: Path to the produced AppImage file. + value: ${{ steps.build.outputs.appimage }} + +runs: + using: composite + steps: + - name: Install appimagetool + shell: bash + env: + APPIMAGE_ARCH: ${{ inputs.arch }} + run: | + set -euo pipefail + # AppImage/appimagetool release 1.9.1 is the active successor + # to the archived AppImageKit repo (whose release-13 assets are + # now prefixed `obsolete-*`). Both x86_64 and aarch64 AppImages + # are published per release. Pinning (vs the `continuous` tag) + # means a bump is an explicit commit. + case "${APPIMAGE_ARCH}" in + amd64) tool_arch="x86_64" ;; + arm64) tool_arch="aarch64" ;; + *) echo "unsupported arch: ${APPIMAGE_ARCH}" >&2; exit 1 ;; + esac + url="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${tool_arch}.AppImage" + curl -fL -o /tmp/appimagetool "${url}" + chmod +x /tmp/appimagetool + + - name: Install libfuse2 + shell: bash + run: | + set -euo pipefail + # appimagetool is itself an AppImage and needs FUSE v2 to + # self-mount. Ubuntu 24.04 runners don't ship libfuse2 by + # default (FUSE v3 is the new default), so install it here. + # --appimage-extract-and-run would also work but costs an extra + # extract step per invocation. + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libfuse2 + + - name: Assemble AppDir and build AppImage + id: build + shell: bash + env: + TARBALL_GLOB: ${{ inputs.tarball }} + APPIMAGE_ARCH: ${{ inputs.arch }} + ICON_PATH: ${{ inputs.icon }} + run: | + set -euo pipefail + # Resolve the tarball glob (download-artifact lands one file). + shopt -s nullglob + matches=( ${TARBALL_GLOB} ) + if [[ ${#matches[@]} -ne 1 ]]; then + echo "::error::expected exactly 1 tarball matching '${TARBALL_GLOB}', got ${#matches[@]}" + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + tarball="${matches[0]}" + + # Tarball naming: deadzone__linux_.tar.gz — the + # version segment is everything between `deadzone_` and + # `_linux_.tar.gz`, so peel both sides to recover it. + base="$(basename "${tarball}" .tar.gz)" + version="${base#deadzone_}" + version="${version%_linux_${APPIMAGE_ARCH}}" + if [[ -z "${version}" || "${version}" == "${base}" ]]; then + echo "::error::could not parse version from tarball name: ${base}" >&2 + exit 1 + fi + + workdir="$(mktemp -d)" + tar -xzf "${tarball}" -C "${workdir}" + extracted="${workdir}/${base}" + if [[ ! -x "${extracted}/deadzone" ]]; then + echo "::error::extracted tarball missing executable deadzone binary" >&2 + ls -la "${extracted}" >&2 || true + exit 1 + fi + + appdir="${workdir}/deadzone.AppDir" + mkdir -p "${appdir}/usr/bin" + cp "${extracted}/deadzone" "${appdir}/usr/bin/deadzone" + cp "${extracted}/LICENSE" "${extracted}/NOTICE" "${extracted}/README.md" "${appdir}/" + + # AppRun wrapper. Explicitly steers DEADZONE_ORT_CACHE at the + # user's XDG cache dir — the AppImage payload is read-only at + # runtime (SquashFS mount), so letting the Go default land + # somewhere inside the mount would be a footgun. This matches + # internal/ort/ort.go's existing default, just lifted up so the + # contract is documented at the package boundary too. + cat > "${appdir}/AppRun" <<'APPRUN' + #!/bin/bash + set -e + HERE="$(dirname "$(readlink -f "$0")")" + export DEADZONE_ORT_CACHE="${DEADZONE_ORT_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/deadzone/ort}" + exec "${HERE}/usr/bin/deadzone" "$@" + APPRUN + chmod +x "${appdir}/AppRun" + + # Minimal CLI .desktop file. Terminal=true tells launchers to + # spawn a terminal, but in practice deadzone is invoked from + # the shell or by an MCP client (stdio), so this is just spec + # compliance — not a desktop-integration feature. + cat > "${appdir}/deadzone.desktop" <<'DESKTOP' + [Desktop Entry] + Name=deadzone + Exec=deadzone + Icon=deadzone + Type=Application + Categories=Utility;Development; + Terminal=true + DESKTOP + + # appimagetool looks for .png (or .svg) at the AppDir + # root; a 256x256 placeholder is enough to satisfy validation. + cp "${GITHUB_WORKSPACE}/${ICON_PATH}" "${appdir}/deadzone.png" + + output="deadzone_${version}_linux_${APPIMAGE_ARCH}.AppImage" + # ARCH env var is how appimagetool picks the embedded runtime; + # without it the tool guesses from the host, which is correct + # on the matrix but explicit is cheaper than debugging later. + case "${APPIMAGE_ARCH}" in + amd64) export ARCH="x86_64" ;; + arm64) export ARCH="aarch64" ;; + esac + # -n skips the GPG signing prompt (we don't sign yet; see #124 + # out-of-scope). --no-appstream because this is a CLI with no + # AppStream metadata by design. + /tmp/appimagetool -n --no-appstream "${appdir}" "${output}" + + ls -la "${output}" + echo "appimage=${output}" >> "$GITHUB_OUTPUT" diff --git a/.github/assets/deadzone.png b/.github/assets/deadzone.png new file mode 100644 index 0000000..e4de3dd Binary files /dev/null and b/.github/assets/deadzone.png differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adddee7..0e15035 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,9 +189,76 @@ jobs: -artifacts ./empty-artifacts \ -db /tmp/deadzone-smoke.db + # Repackages each Linux tarball into an AppImage via the + # .github/actions/build-appimage composite. Runs after `smoke` so a + # broken tarball can't silently produce a broken AppImage (the tarball + # smoke is the cheaper canary). AppImage smoke (FUSE mount + ORT + # bootstrap) is inlined at the end of this job because the runner + # already has the AppImage on disk and libfuse2 installed via the + # composite — no sense adding a fourth job for two extra steps. + # Aggregation happens in `release` below, which globs `deadzone-*` + # artifacts; the `-appimage` suffix keeps AppImage and tarball + # artifacts from colliding on upload-artifact names. + appimage: + name: appimage (linux/${{ matrix.goarch }}) + needs: smoke + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + goarch: amd64 + - runner: ubuntu-24.04-arm + goarch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v4 + with: + name: deadzone-linux-${{ matrix.goarch }} + + - name: Build AppImage + id: build + uses: ./.github/actions/build-appimage + with: + tarball: deadzone_*_linux_${{ matrix.goarch }}.tar.gz + arch: ${{ matrix.goarch }} + + # Mirrors the tarball smoke's contract (see the `smoke` job + # above): -version proves the AppImage launches and dylib linkage + # resolves inside the SquashFS mount, then `consolidate` with an + # empty artifacts dir exercises the ORT bootstrap (#73) from a + # read-only payload into $XDG_CACHE_HOME. If AppRun's + # DEADZONE_ORT_CACHE override regresses, this step is where it + # surfaces — the bootstrap would try to write inside the mount + # and fail. + - name: Smoke AppImage -version + shell: bash + run: | + set -euo pipefail + chmod +x "${{ steps.build.outputs.appimage }}" + "./${{ steps.build.outputs.appimage }}" -version + + - name: Smoke AppImage consolidate (ORT bootstrap) + shell: bash + run: | + set -euo pipefail + mkdir -p empty-artifacts + "./${{ steps.build.outputs.appimage }}" consolidate \ + -artifacts ./empty-artifacts \ + -db /tmp/deadzone-appimage-smoke.db + + - uses: actions/upload-artifact@v4 + with: + name: deadzone-linux-${{ matrix.goarch }}-appimage + path: ${{ steps.build.outputs.appimage }} + if-no-files-found: error + retention-days: 7 + release: name: publish release - needs: [build, smoke] + needs: [build, smoke, appimage] runs-on: ubuntu-24.04 # The job runs for both tag pushes and workflow_dispatch so the # dry-run path can validate the tarball aggregation on a branch. @@ -226,13 +293,15 @@ jobs: run: | set -euo pipefail cd dist - # Single checksums.txt covers every per-platform tarball — users - # who want to verify hit one file, one command. The consolidated - # `deadzone.db` is uploaded separately by `deadzone dbrelease` - # from the operator's laptop after this job finishes (see #101 - # §H); its sha256 lives in a sibling `deadzone.db.sha256` asset - # written by that subcommand. - sha256sum deadzone_*.tar.gz > "deadzone_${VERSION}_checksums.txt" + # Single checksums.txt covers every per-platform tarball and + # AppImage — users who want to verify hit one file, one + # command. The consolidated `deadzone.db` is uploaded + # separately by `deadzone dbrelease` from the operator's + # laptop after this job finishes (see #101 §H); its sha256 + # lives in a sibling `deadzone.db.sha256` asset written by + # that subcommand. + sha256sum deadzone_*.tar.gz deadzone_*.AppImage \ + > "deadzone_${VERSION}_checksums.txt" cat "deadzone_${VERSION}_checksums.txt" - name: Create GitHub release @@ -249,4 +318,5 @@ jobs: --title "$VERSION" \ --generate-notes \ dist/deadzone_*.tar.gz \ + dist/deadzone_*.AppImage \ "dist/deadzone_${VERSION}_checksums.txt" diff --git a/README.md b/README.md index 302c093..682b95a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Documentation is fetched by the `deadzone scrape` subcommand, embedded into vect Pre-built binaries for **macOS Apple Silicon**, **Linux amd64**, and **Linux arm64** are published on the [Releases page](https://github.com/laradji/deadzone/releases). Windows is blocked upstream (no `libtokenizers.a`). If you want to build from source instead — most useful if you're contributing or running on an unsupported platform — skip to [Build from source](#build-from-source). -macOS Apple Silicon users can also install via [Homebrew](#homebrew-macos-apple-silicon) below — it's a one-liner and skips the quarantine workaround. +macOS Apple Silicon users can also install via [Homebrew](#homebrew-macos-apple-silicon) below — it's a one-liner and skips the quarantine workaround. Linux users have a one-liner too: the [AppImage](#appimage-linux) bundles the binary and its assets into a single self-mounting file. ### Homebrew (macOS Apple Silicon) @@ -68,6 +68,23 @@ Apple Silicon only — Intel Macs aren't built by the release pipeline. If you n Homebrew installs into a non-quarantined location, so the [quarantine workaround](#macos-clear-the-quarantine-attribute) below doesn't apply. +### AppImage (Linux) + +```bash +VERSION=v0.1.0 +ARCH=amd64 # or arm64 + +curl -L -O "https://github.com/laradji/deadzone/releases/download/${VERSION}/deadzone_${VERSION}_linux_${ARCH}.AppImage" +chmod +x "deadzone_${VERSION}_linux_${ARCH}.AppImage" +"./deadzone_${VERSION}_linux_${ARCH}.AppImage" -version +``` + +The AppImage self-mounts its payload via FUSE v2. Most desktop distros have `libfuse2` preinstalled; minimal server or container images often don't. If you hit `dlopen(): error loading libfuse.so.2`, either install the FUSE v2 package (`apt-get install libfuse2` on Debian/Ubuntu, `dnf install fuse-libs` on Fedora) or pass `--appimage-extract-and-run`, which bypasses FUSE entirely by extracting the payload to a temp dir per invocation: + +```bash +"./deadzone_${VERSION}_linux_${ARCH}.AppImage" --appimage-extract-and-run -version +``` + ### Quick install Pick the archive for your platform and extract it into the directory you want to run deadzone from: