# Builds the Linux AppImage with scripts/build-appimage.sh and uploads it # to the release for an existing v* tag. Pushing this file does not run a build. # The act_runner label ubuntu-latest is docker://catthehacker/ubuntu:full-latest. name: AppImage release on: push: tags: - "v*" workflow_dispatch: inputs: tag: description: "Existing v* tag to publish. Leave empty when this run is already on a v* tag." required: false type: string permissions: contents: write jobs: appimage: runs-on: ubuntu-latest timeout-minutes: 180 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Resolve the existing release tag id: tag shell: bash env: INPUT_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }} run: | set -euo pipefail TAG="" if [[ "${GITHUB_REF_TYPE:-}" == "tag" && "${GITHUB_REF_NAME:-}" == v* ]]; then TAG="${GITHUB_REF_NAME}" fi if [[ -n "${INPUT_TAG}" ]]; then if [[ "${INPUT_TAG}" != v* ]]; then echo "The tag input must match v*, for example v0.1.0." >&2 exit 1 fi TAG="${INPUT_TAG}" fi if [[ -z "${TAG}" || "${TAG}" != v* ]]; then echo "This workflow publishes an AppImage for a tag that already exists and starts with v, such as v0.1.0." >&2 echo "Push that tag, or run this workflow on it. It does not create a tag." >&2 exit 1 fi if ! git ls-remote --exit-code origin "refs/tags/${TAG}" >/dev/null; then echo "Tag ${TAG} is not on the server. This workflow does not create tags." >&2 exit 1 fi if [[ "${GITHUB_REF_NAME:-}" != "${TAG}" ]]; then git fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" git checkout --detach "refs/tags/${TAG}" fi echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" - name: Install build tools shell: bash run: | set -euo pipefail if [[ "$(id -u)" -eq 0 ]]; then apt_get() { apt-get "$@"; } else apt_get() { sudo apt-get "$@"; } fi export DEBIAN_FRONTEND=noninteractive apt_get update apt_get install -y --no-install-recommends \ ca-certificates \ curl \ xz-utils \ binutils \ git \ jq \ file \ unzip apt_get install -y --no-install-recommends libfuse2 \ || apt_get install -y --no-install-recommends libfuse2t64 \ || echo "No libfuse2 package in this image. AppImage tools will extract instead of mounting." need_node=0 if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then need_node=1 else major="$(node -p 'Number(process.versions.node.split(".")[0])')" if [[ "${major}" -lt 20 ]]; then need_node=1 fi fi if [[ "${need_node}" -eq 1 ]]; then tarball="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | awk '/linux-x64\.tar\.xz$/ { print $2; exit }')" if [[ -z "${tarball}" ]]; then echo "Could not find a Node.js 22 linux-x64 tarball." >&2 exit 1 fi curl -fL --retry 3 -o "/tmp/${tarball}" "https://nodejs.org/dist/latest-v22.x/${tarball}" if [[ "$(id -u)" -eq 0 ]]; then tar -xJf "/tmp/${tarball}" -C /usr/local --strip-components=1 else sudo tar -xJf "/tmp/${tarball}" -C /usr/local --strip-components=1 fi fi command -v node command -v npm command -v curl command -v tar command -v strip node -v npm -v - name: Build the AppImage shell: bash env: APPIMAGE_EXTRACT_AND_RUN: "1" run: | set -euo pipefail # npm omits devDependencies when NODE_ENV=production. electron-builder is one of them. env -u NODE_ENV bash scripts/build-appimage.sh - name: Require the AppImage to be under 600MB id: appimage shell: bash run: | set -euo pipefail APPIMAGE="$(find dist -maxdepth 1 -name '*.AppImage' -printf '%T@ %p\n' | sort -nr | head -1 | cut -d' ' -f2-)" if [[ -z "${APPIMAGE}" || ! -f "${APPIMAGE}" ]]; then echo "No AppImage was produced in dist/." >&2 exit 1 fi SIZE="$(stat -c%s "${APPIMAGE}")" LIMIT=$((600 * 1024 * 1024)) if (( SIZE > LIMIT )); then echo "AppImage is ${SIZE} bytes, over 600MB." >&2 exit 1 fi echo "Built ${APPIMAGE} (${SIZE} bytes)." echo "path=${APPIMAGE}" >> "${GITHUB_OUTPUT}" echo "name=$(basename "${APPIMAGE}")" >> "${GITHUB_OUTPUT}" - name: Upload the AppImage to the release shell: bash env: GITEA_TOKEN: ${{ github.token }} TAG: ${{ steps.tag.outputs.tag }} TARGET_SHA: ${{ steps.tag.outputs.sha }} APPIMAGE_PATH: ${{ steps.appimage.outputs.path }} ASSET_NAME: ${{ steps.appimage.outputs.name }} run: | set -euo pipefail TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}" if [[ -z "${TOKEN}" ]]; then echo "No job token. act_runner must provide a token that can publish releases." >&2 exit 1 fi if [[ ! -f "${APPIMAGE_PATH}" ]]; then echo "AppImage not found at ${APPIMAGE_PATH}." >&2 exit 1 fi SIZE="$(stat -c%s "${APPIMAGE_PATH}")" LIMIT=$((600 * 1024 * 1024)) if (( SIZE > LIMIT )); then echo "AppImage is ${SIZE} bytes, over 600MB." >&2 exit 1 fi API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" AUTH=( -H "Authorization: token ${TOKEN}" ) ref_code="$(curl -sS -o /tmp/tag-ref.json -w "%{http_code}" "${AUTH[@]}" "${API}/git/refs/tags/${TAG}")" if [[ "${ref_code}" != "200" ]]; then echo "Tag ${TAG} is not on the server (HTTP ${ref_code}). This workflow does not create tags." >&2 cat /tmp/tag-ref.json >&2 exit 1 fi NOTES_FILE="$(mktemp)" cat > "${NOTES_FILE}" <&2 cat /tmp/release.json >&2 exit 1 fi RELEASE_ID="$(jq -r '.id' /tmp/release.json)" patch_body="$(jq -n --arg name "${TITLE}" --rawfile body "${NOTES_FILE}" '{name:$name, body:$body}')" patch_code="$(curl -sS -o /tmp/release-patch.json -w "%{http_code}" \ -X PATCH "${AUTH[@]}" \ -H "Content-Type: application/json" \ --data "${patch_body}" \ "${API}/releases/${RELEASE_ID}")" if [[ "${patch_code}" != "200" ]]; then echo "Could not update the release notes (HTTP ${patch_code})." >&2 cat /tmp/release-patch.json >&2 exit 1 fi else echo "Could not create the release (HTTP ${create_code})." >&2 cat /tmp/release.json >&2 exit 1 fi if [[ -z "${RELEASE_ID}" || "${RELEASE_ID}" == "null" ]]; then echo "The release id is missing." >&2 exit 1 fi mapfile -t asset_ids < <(jq -r --arg name "${ASSET_NAME}" '.assets[]? | select(.name == $name) | .id' /tmp/release.json) for asset_id in "${asset_ids[@]}"; do [[ -z "${asset_id}" ]] && continue del_code="$(curl -sS -o /tmp/asset-delete.json -w "%{http_code}" \ -X DELETE "${AUTH[@]}" \ "${API}/releases/${RELEASE_ID}/assets/${asset_id}")" if [[ "${del_code}" != "204" && "${del_code}" != "200" ]]; then echo "Could not replace the existing asset ${ASSET_NAME} (HTTP ${del_code})." >&2 cat /tmp/asset-delete.json >&2 exit 1 fi done enc_name="$(jq -rn --arg name "${ASSET_NAME}" '$name|@uri')" upload_code="$(curl -sS -o /tmp/upload.json -w "%{http_code}" \ -X POST "${AUTH[@]}" \ -F "attachment=@${APPIMAGE_PATH};filename=${ASSET_NAME}" \ "${API}/releases/${RELEASE_ID}/assets?name=${enc_name}")" if [[ "${upload_code}" != "201" ]]; then echo "Upload failed with HTTP ${upload_code}." >&2 cat /tmp/upload.json >&2 exit 1 fi echo "Uploaded ${ASSET_NAME} (${SIZE} bytes) to release ${TAG}." jq -r '.browser_download_url // empty' /tmp/upload.json