diff --git a/.gitea/workflows/appimage-release.yml b/.gitea/workflows/appimage-release.yml new file mode 100644 index 0000000..053cf93 --- /dev/null +++ b/.gitea/workflows/appimage-release.yml @@ -0,0 +1,274 @@ +# 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