feat(release): expand desktop distribution to 8 assets + 2 package managers

Phases 3, 4, 5, 6 of the multi-platform distribution plan.

## create-release.yml rewrite
- Replace deprecated actions/create-release@v1 + upload-release-asset@v1 with
  softprops/action-gh-release@v2 (SHA-pinned)
- Expand build-desktop matrix: macos-13 (Intel), macos-14 (ARM),
  windows-latest, ubuntu-latest × 2 legs (deb+rpm, AppImage+tar.gz)
- Each matrix job uploads directly to release (no artifact round-trip — saves
  ~10 min + 1.5GB transfer per run)
- Inline portable archives: Windows .zip via 7z, Linux .tar.gz via tar
- linuxdeploy SHA-verified fetch for AppImage builds (not `continuous` tag)
- Per-asset size budget: hard fail at 1 GB per asset
- prerelease inferred from tag regex (-rc|-beta|-alpha|-dev|-snapshot)
- workflow_dispatch dry_run input: builds all assets without publishing,
  skips Android + bump workflows
- Tag-vs-libs.versions.toml assertion as first step in each matrix job
- Android + Quartz jobs preserved; migrated to softprops/action-gh-release@v2

## Package manager bump workflows (Homebrew + Winget)
- .github/workflows/bump-homebrew.yml — macauley/action-homebrew-bump-cask
  on ubuntu-latest (saves macOS runner quota). Cask name: `amethyst-nostr`.
- .github/workflows/bump-winget.yml — vedantmgoyal9/winget-releaser on
  windows-latest. PackageIdentifier: `VitorPamplona.Amethyst`.
- Shared composite action .github/actions/assert-stable-release rejects
  draft/prerelease/malformed-tag releases at action boundary (defense in
  depth vs workflow-level `if:` alone).
- Both workflows auto-open `release-ops`-labeled GH Issues on failure.
- Concurrency groups per tag prevent re-fire races.
- AUR + Scoop deferred to follow-up PR (unresolved ownership questions).

## Documentation
- BUILDING.md: per-platform build commands, asset naming contract, release
  runbook, bootstrap runbook, troubleshooting, uninstall paths, incident
  response, fallback plans (macos-13 retirement, Homebrew Sept 2026 deadline)
- README: expanded Download section with per-OS install matrix for 7 formats
  + 2 package managers. Deploying section points at BUILDING.md.

## Supply chain
- .github/dependabot.yml: monthly bumps for github-actions ecosystem
- All new third-party actions SHA-pinned:
  - softprops/action-gh-release v2.6.2
  - macauley/action-homebrew-bump-cask v4.0.0
  - vedantmgoyal9/winget-releaser v2
  - nick-fields/retry v3.0.2
- linuxdeploy binary SHA256-verified against pinned release tag
This commit is contained in:
nrobi144
2026-04-16 14:54:51 +03:00
parent da1037423c
commit c428661601
7 changed files with 840 additions and 227 deletions
@@ -0,0 +1,44 @@
name: Assert Stable Release
description: >-
Defense-in-depth guard for package-manager bump workflows. Re-validates
tag format, prerelease flag, and draft status before invoking third-party
actions that hold write credentials to external package manager repos
(Homebrew, Winget, AUR, Scoop). Prevents RC builds from reaching stable
channels even if the `release.released` event gating is bypassed.
runs:
using: composite
steps:
- name: Assert release is stable
shell: bash
env:
TAG: ${{ github.event.release.tag_name }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
IS_DRAFT: ${{ github.event.release.draft }}
run: |
set -euo pipefail
echo "tag=$TAG prerelease=$IS_PRERELEASE draft=$IS_DRAFT"
# Reject prerelease suffix even if GitHub's flag says false.
if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then
echo "::error::Tag $TAG contains prerelease suffix; refusing bump"
exit 1
fi
# Enforce strict vMAJOR.MINOR.PATCH format.
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tag $TAG does not match vMAJOR.MINOR.PATCH"
exit 1
fi
# Draft releases must never trigger bumps.
if [[ "$IS_DRAFT" == "true" ]]; then
echo "::error::Release is draft; refusing bump"
exit 1
fi
# Prerelease flag cross-check (belt-and-suspenders with workflow-level `if:`).
if [[ "$IS_PRERELEASE" == "true" ]]; then
echo "::error::Release is prerelease; refusing bump"
exit 1
fi
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
# Auto-update SHA-pinned GitHub Actions across all workflows.
# Required for supply-chain safety — SHA pins only age well with active bumps.
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
labels:
- release-ops
- dependencies
commit-message:
prefix: chore(actions)
groups:
actions:
patterns:
- "*"
+68
View File
@@ -0,0 +1,68 @@
name: Bump Homebrew Cask
# Fires when a GH Release is published (not draft, not prerelease).
# `release.types: [released]` event fires only for stable releases — still
# double-checked by .github/actions/assert-stable-release for defense-in-depth.
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to bump (for manual recovery)'
required: true
type: string
permissions:
contents: read
concurrency:
# Serialize bumps per tag; do not cancel in-progress bumps.
group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Re-assert stable release
if: github.event_name == 'release'
uses: ./.github/actions/assert-stable-release
- name: Bump cask (push-or-update PR)
uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
tap: homebrew/cask
cask: amethyst-nostr
tag: ${{ github.event.release.tag_name || inputs.tag }}
- name: Report failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew failed for ${tag}`,
body: [
`Homebrew cask bump failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Cask (\`amethyst-nostr\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``,
`3. File PR directly against Homebrew/homebrew-cask`
].join('\n'),
labels: ['release-ops', 'bug']
});
+65
View File
@@ -0,0 +1,65 @@
name: Bump Winget Manifest
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to submit (for manual recovery)'
required: true
type: string
permissions:
contents: read
concurrency:
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Re-assert stable release
if: github.event_name == 'release'
uses: ./.github/actions/assert-stable-release
- name: Submit manifest to winget-pkgs
uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: VitorPamplona.Amethyst
version: ${{ github.event.release.tag_name || inputs.tag }}
# Asset naming contract: scripts/asset-name.sh
installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
- name: Report failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-winget failed for ${tag}`,
body: [
`Winget manifest submission failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Winget (\`VitorPamplona.Amethyst\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``,
`3. File PR directly against microsoft/winget-pkgs`
].join('\n'),
labels: ['release-ops', 'bug']
});
+219 -211
View File
@@ -3,31 +3,192 @@ name: Create Release Assets
on: on:
push: push:
tags: tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run: build assets but do not publish to GH Release or trigger bump workflows'
type: boolean
default: false
test_tag:
description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push'
type: string
default: 'v0.0.0-dryrun'
permissions: permissions:
contents: write contents: write
jobs: env:
create-release: # Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
runs-on: ubuntu-latest # Single source of truth in scripts/asset-name.sh.
outputs: # linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
upload_url: ${{ steps.create_release.outputs.upload_url }} LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
steps: LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: true
jobs:
# ---------------------------------------------------------------------------
# Desktop build matrix. Each leg uploads directly to the GH Release via
# softprops/action-gh-release@v2 (upsert by tag_name). No artifact round-trip.
# ---------------------------------------------------------------------------
build-desktop:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Resolve tag + version
id: ver
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TAG="${TEST_TAG:-v0.0.0-dryrun}"
else
TAG="${GITHUB_REF_NAME}"
fi
VER="${TAG#v}"
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
# On dry-run we only require that TOML has a version; on real tag push we require exact match.
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
if [[ "$TOML_VER" != "$VER" ]]; then
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
exit 1
fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "toml=$TOML_VER" >> "$GITHUB_OUTPUT"
- name: Install RPM tooling (linux families)
if: startsWith(matrix.family, 'linux')
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Fetch linuxdeploy (linux-portable only, SHA-verified)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o packaging/appimage/linuxdeploy-x86_64.AppImage
actual=$(sha256sum packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then
echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual"
exit 1
fi
chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
# linuxdeploy needs FUSE; on newer runners, --appimage-extract-and-run is required.
# Bypass FUSE requirement by pre-extracting the AppImage.
(cd packaging/appimage && ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null && \
mv squashfs-root linuxdeploy-extracted && \
ln -sf linuxdeploy-extracted/AppRun linuxdeploy-x86_64.bin && \
chmod +x linuxdeploy-x86_64.bin)
- name: Build desktop artifacts
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
max_attempts: 2
timeout_minutes: 40
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}
- name: Build portable archives (windows + linux-portable)
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
APP="desktopApp/build/compose/binaries/main-release/app"
mkdir -p desktopApp/build/portable
if [[ "${{ matrix.family }}" == "windows" ]]; then
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
else
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
fi
- name: Collect + rename assets
run: |
set -euo pipefail
# shellcheck source=scripts/asset-name.sh
source scripts/asset-name.sh
# linux-portable family holds AppImage + tar.gz, but we re-tag with plain `linux` for the asset name.
FAMILY="${{ matrix.family }}"
[[ "$FAMILY" == "linux-portable" ]] && FAMILY="linux"
mkdir -p dist
collect_assets "$FAMILY" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
- name: Enforce asset size budget (1 GB per asset)
run: |
set -euo pipefail
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 1073741824 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 1 GB budget"
fail=1
else
echo "OK: $f — ${mb} MB"
fi
fi
done
[[ "$fail" == 0 ]]
- name: Classify release
id: classify
run: |
TAG="${{ steps.ver.outputs.tag }}"
if [[ "$TAG" =~ -(rc|beta|alpha|dev|dryrun|snapshot) ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: true
- name: Dry-run summary
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "### Dry-run: ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Android build + sign + direct-upload. Logic preserved from previous workflow;
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
# ---------------------------------------------------------------------------
deploy-android: deploy-android:
needs: create-release if: github.event_name != 'workflow_dispatch' # dry-run skips Android (tag-push only)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -44,9 +205,9 @@ jobs:
path: | path: |
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-android-gradle-
- name: Build AAB - name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace run: ./gradlew clean bundleRelease --stacktrace
@@ -98,141 +259,50 @@ jobs:
env: env:
BUILD_TOOLS_VERSION: "36.0.0" BUILD_TOOLS_VERSION: "36.0.0"
# Google Play APK - name: Collect Android assets (rename to canonical scheme)
- name: Upload Play APK Universal Asset run: |
id: upload-release-asset-play-universal-apk set -euo pipefail
uses: actions/upload-release-asset@v1 mkdir -p dist
env: TAG="${GITHUB_REF_NAME}"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86 Asset # Play APKs (5 variants)
id: upload-release-asset-play-x86-apk for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do
uses: actions/upload-release-asset@v1 cp "amethyst/build/outputs/apk/play/release/amethyst-play-${variant}-release-unsigned-signed.apk" \
env: "dist/amethyst-googleplay-${variant}-${TAG}.apk"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} done
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset # F-Droid APKs (5 variants)
id: upload-release-asset-play-x86-64-apk for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do
uses: actions/upload-release-asset@v1 cp "amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-${variant}-release-unsigned-signed.apk" \
env: "dist/amethyst-fdroid-${variant}-${TAG}.apk"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} done
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK arm64-v8a Asset # AABs
id: upload-release-asset-play-arm64-v8a-apk cp "amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab" \
uses: actions/upload-release-asset@v1 "dist/amethyst-googleplay-${TAG}.aab"
env: cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} "dist/amethyst-fdroid-${TAG}.aab"
with: ls -la dist
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK armeabi-v7a Asset - name: Classify release
id: upload-release-asset-play-armeabi-v7a-apk id: classify
uses: actions/upload-release-asset@v1 run: |
env: TAG="${GITHUB_REF_NAME}"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then
with: echo "prerelease=true" >> "$GITHUB_OUTPUT"
upload_url: ${{ needs.create-release.outputs.upload_url }} else
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk echo "prerelease=false" >> "$GITHUB_OUTPUT"
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk fi
asset_content_type: application/zip
# F-Droid APK - name: Upload Android assets to GH Release
- name: Upload F-Droid APK Universal Asset uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ needs.create-release.outputs.upload_url }} files: dist/*
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk tag_name: ${{ github.ref_name }}
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk prerelease: ${{ steps.classify.outputs.prerelease }}
asset_content_type: application/zip draft: false
fail_on_unmatched_files: true
- name: Upload F-Droid APK x86 Asset generate_release_notes: true
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK arm64-v8a Asset
id: upload-release-asset-fdroid-arm64-v8a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK armeabi-v7a Asset
id: upload-release-asset-fdroid-armeabi-v7a-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
- name: Publish Quartz Lib - name: Publish Quartz Lib
run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache
@@ -241,65 +311,3 @@ jobs:
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
deploy-desktop:
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
format: deb
platform: linux
- os: macos-latest
task: packageDmg
format: dmg
platform: macos
- os: windows-latest
task: packageMsi
format: msi
platform: windows
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Find distribution file
id: find-dist
run: |
DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
- name: Upload Desktop Distribution to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ steps.find-dist.outputs.path }}
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
asset_content_type: application/octet-stream
+395
View File
@@ -0,0 +1,395 @@
# Building Amethyst Desktop
This guide covers building Amethyst Desktop from source, the release pipeline,
and one-time bootstrap steps for distribution channels.
- [Prerequisites](#prerequisites)
- [Clone + first build](#clone--first-build)
- [Per-format build commands](#per-format-build-commands)
- [Asset naming contract](#asset-naming-contract)
- [Release runbook](#release-runbook)
- [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time)
- [Troubleshooting installs](#troubleshooting-installs)
- [Uninstall + state paths](#uninstall--state-paths)
- [Incident response](#incident-response)
- [Fallback plans](#fallback-plans)
---
## Prerequisites
All platforms:
- **JDK 21** (Zulu or Temurin recommended)
- **Git**
Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage
Install Linux RPM tooling:
```bash
# Debian/Ubuntu
sudo apt-get install -y rpm fakeroot
# Fedora
sudo dnf install -y rpm-build
```
Install linuxdeploy locally (CI fetches its own — SHA-verified):
```bash
curl -fsSL -o packaging/appimage/linuxdeploy-x86_64.AppImage \
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
```
---
## Clone + first build
```bash
git clone https://github.com/vitorpamplona/amethyst.git
cd amethyst
# Dev loop (launches Amethyst Desktop)
./gradlew :desktopApp:run
# Package for current OS
./gradlew :desktopApp:packageDistributionForCurrentOS
```
---
## Per-format build commands
| Artifact | Command | Output |
|---|---|---|
| macOS DMG (host arch) | `./gradlew :desktopApp:packageReleaseDmg` | `desktopApp/build/compose/binaries/main-release/dmg/Amethyst-*.dmg` |
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
**Inline portable archives** (run after `createReleaseDistributable`):
```bash
./gradlew :desktopApp:createReleaseDistributable
# Linux tar.gz
VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
( cd desktopApp/build/compose/binaries/main-release/app \
&& tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
# Windows .zip (PowerShell)
Compress-Archive -Path desktopApp\build\compose\binaries\main-release\app\Amethyst `
-DestinationPath "desktopApp\build\portable\amethyst-desktop-$env:VER-windows-x64.zip"
```
Cross-platform architecture note: **`jpackage` cannot cross-compile**. An Intel
DMG must be built on `macos-13` (x64); an ARM DMG must be built on `macos-14`
or later. CI runs both.
---
## Asset naming contract
All GH Release assets follow:
```
amethyst-desktop-<version>-<family>-<arch>.<ext>
```
Where:
| Field | Values |
|---|---|
| `<version>` | Tag stripped of leading `v` (e.g. `1.08.0`) |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
any change is a breaking contract.
Examples:
- `amethyst-desktop-1.08.0-macos-x64.dmg`
- `amethyst-desktop-1.08.0-macos-arm64.dmg`
- `amethyst-desktop-1.08.0-windows-x64.msi`
- `amethyst-desktop-1.08.0-linux-x64.AppImage`
---
## Release runbook
The release flow is driven by a tag push. Every cut ships Android + Desktop +
Quartz library in one pipeline.
1. **Bump the app version** in `gradle/libs.versions.toml`:
```toml
[versions]
app = "1.08.1" # new semver
```
2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer,
must increment even for same `versionName`):
```groovy
versionCode = 443
versionName = generateVersionName(libs.versions.app.get())
```
3. **Commit + tag + push**:
```bash
git commit -am "chore(release): 1.08.1"
git tag -s v1.08.1 -m "Release 1.08.1"
git push && git push --tags
```
4. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
5. **Verify**:
- GH Release contains 8 desktop assets + 12 Android assets
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Intel + ARM DMGs both present
- Android flow unchanged
6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
### Dry-run (no tag push)
Use `workflow_dispatch` to exercise the full matrix without publishing:
```bash
gh workflow run create-release.yml \
-f dry_run=true \
-f test_tag=v0.0.0-dryrun \
--ref feat/my-branch
```
Assets are built and size-checked, but not uploaded; bump workflows do not
fire. Use for pre-merge validation of workflow changes.
### Version constraint: tag must match `libs.versions.toml`
The first step in each build-desktop matrix job asserts:
```
tag (stripped of 'v') == gradle/libs.versions.toml [versions] app
```
If they drift, the workflow fails fast. Always bump the TOML first, then tag.
### NEVER change Windows `upgradeUuid`
`desktopApp/build.gradle.kts:upgradeUuid` is the MSI product family GUID.
Changing it breaks in-place upgrades for existing Windows users — they must
uninstall before a new release. Leave it alone forever.
---
## Bootstrap runbook (one-time)
### Secrets to provision in GitHub repo settings
| Secret | Purpose | Scope |
|---|---|---|
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain
unchanged.
Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md`
or equivalent issue tracker. On rotation, paste new token and run
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
### Homebrew cask (one-time initial PR)
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.08.0 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-macos-arm64.dmg"
```
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
auto-submits new version bumps on each stable release.
### Winget (one-time initial submission)
```bash
wingetcreate new \
https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-windows-x64.msi
```
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is
merged into `microsoft/winget-pkgs`, `bump-winget.yml` auto-submits new
version manifests.
---
## Troubleshooting installs
### macOS — Gatekeeper "damaged and can't be opened"
Amethyst Desktop is currently unsigned. First-time launch requires:
1. **Right-click → Open** on the app (don't double-click) — then click **Open** on the Gatekeeper dialog
2. Or: `xattr -cr /Applications/Amethyst.app` to strip quarantine
3. Or: System Settings → Privacy & Security → "Open Anyway" after a blocked launch
Recommended path: install via Homebrew (`brew install --cask amethyst-nostr`)
— cask flow handles this seamlessly.
### Windows — SmartScreen "Windows protected your PC"
Amethyst Desktop is currently unsigned (no Authenticode). First-time launch:
1. Click **More info** on the SmartScreen dialog
2. Click **Run anyway**
Alternatively use `winget install VitorPamplona.Amethyst` — winget install
bypasses the UI dialog after accepting the installer's inherent trust.
### Linux AppImage won't execute
```bash
chmod +x Amethyst-*.AppImage
./Amethyst-*.AppImage
```
On Fedora Silverblue / very minimal distros, FUSE might be missing. Use
`--appimage-extract-and-run`:
```bash
./Amethyst-*.AppImage --appimage-extract-and-run
```
---
## Uninstall + state paths
State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb,
.rpm, AppImage, tar.gz). Switching channels does not duplicate data but may
expose downgrade migration risks — **prefer a single install channel per
machine**.
| OS | App location | State directories |
|---|---|---|
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
Uninstall:
- Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr`
- Winget: `winget uninstall VitorPamplona.Amethyst`
- .deb: `sudo apt remove amethyst`
- .rpm: `sudo dnf remove amethyst`
- AppImage / tar.gz: delete the file / extracted directory
- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually
---
## Incident response
### Bad GH Release asset
1. Immediately mark release as prerelease (pauses bump workflows):
```bash
gh release edit v1.08.1 --prerelease
```
2. Delete the bad asset:
```bash
gh release delete-asset v1.08.1 amethyst-desktop-1.08.1-macos-arm64.dmg --yes
```
3. Rebuild locally or rerun the failing matrix job:
```bash
gh run rerun <run-id> --failed
```
4. Flip back to stable once verified (re-fires bump workflows — confirm fix first):
```bash
gh release edit v1.08.1 --prerelease=false
```
### Bad build reached Homebrew
**Preferred**: ship a point release (e.g. v1.08.2) — users on v1.08.1 get the
fix via `brew upgrade`.
**Alternative**: close the open PR in `Homebrew/homebrew-cask` before merge,
or file a revert PR if already merged. Typical Homebrew turn-around: 12 days.
### Bad build reached Winget
Winget manifests are append-only — no hard unpublish. Options:
1. Ship a point release (preferred — users upgrade via `winget upgrade`)
2. File a manifest-removal PR against `microsoft/winget-pkgs`. Moderator
review: 2472h.
### User-facing communication
On any incident:
1. Edit the release body on GitHub with a warning banner + workaround
2. Pin a GH Issue with downgrade instructions per channel
3. Announce via Nostr relay + project social channels
---
## Fallback plans
### macOS Intel runner retirement
GitHub's `macos-13` runner will eventually be deprecated. Monitor
<https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners>
for the deprecation date. When it hits:
1. Drop the `macos-13` matrix entry from `.github/workflows/create-release.yml`
2. Add a cross-arch build step on `macos-14` using a bundled x64 JDK + `jpackage --mac-signing-prefix` shenanigans, OR accept that only Apple Silicon DMGs ship and direct Intel users to `winget` on a Parallels VM or to rebuild from source.
3. Update README install matrix to reflect the change.
### Homebrew main-cask rejects unsigned app (post-Sept 1 2026)
Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask`
on 2026-09-01. Before that date:
**Option A**: Commit budget to Apple Developer Program ($99/yr), add
`signing { sign.set(true) }` + `notarization {}` blocks to
`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI.
**Option B**: Pivot to a private Homebrew tap:
```bash
# Create repo: vitorpamplona/homebrew-amethyst
# Update bump-homebrew.yml:
# tap: vitorpamplona/amethyst
# cask: amethyst-nostr
# Users install: brew tap vitorpamplona/amethyst && brew install --cask amethyst-nostr
```
Note: a private tap does NOT bypass Gatekeeper itself (macOS OS-level) — users
still see the "unsigned developer" dialog. Tap only sidesteps Homebrew's
internal policy.
---
## Follow-up channels (separate PRs)
- **AUR** (`amethyst-desktop-bin`) — blocked on AUR account ownership decision
- **Scoop** (Windows) — blocked on bucket strategy (own vs Extras)
- **Flathub** — deferred (moderate ongoing maintenance)
+32 -16
View File
@@ -21,6 +21,8 @@ Join the social network you control.
## Download and Install ## Download and Install
### Android
[<img src="./docs/design/zapstore.svg" [<img src="./docs/design/zapstore.svg"
alt="Get it on Zap Store" alt="Get it on Zap Store"
height="70">](https://github.com/zapstore/zapstore/releases) height="70">](https://github.com/zapstore/zapstore/releases)
@@ -33,6 +35,24 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
alt="Get it on Google Play" alt="Get it on Google Play"
height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst) height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
### Desktop
| OS | CLI install | Direct download |
|---|---|---|
| macOS (Apple Silicon) | `brew install --cask amethyst-nostr` | [.dmg arm64](https://github.com/vitorpamplona/amethyst/releases/latest) |
| macOS (Intel) | `brew install --cask amethyst-nostr` | [.dmg x64](https://github.com/vitorpamplona/amethyst/releases/latest) |
| Windows 10/11 | `winget install VitorPamplona.Amethyst` | [.msi](https://github.com/vitorpamplona/amethyst/releases/latest) · [.zip portable](https://github.com/vitorpamplona/amethyst/releases/latest) |
| Debian/Ubuntu | — | [.deb](https://github.com/vitorpamplona/amethyst/releases/latest) |
| Fedora/RHEL/openSUSE | — | [.rpm](https://github.com/vitorpamplona/amethyst/releases/latest) |
| Any Linux | — | [AppImage](https://github.com/vitorpamplona/amethyst/releases/latest) · [.tar.gz](https://github.com/vitorpamplona/amethyst/releases/latest) |
_Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._
**Build from source:** see [BUILDING.md](BUILDING.md).
**Install troubleshooting** (Gatekeeper / SmartScreen / AppImage): see
[BUILDING.md § Troubleshooting installs](BUILDING.md#troubleshooting-installs).
</div> </div>
## Supported Features ## Supported Features
@@ -249,22 +269,18 @@ For the Play build:
## Deploying ## Deploying
1. Generate a new signing key Full release + bootstrap runbooks (Android AAB upload, desktop packaging,
``` Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000 in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt [BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time).
```
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information TL;DR for cutting a release:
- `KEY_ALIAS` <- `<alias_name>`
- `KEY_PASSWORD` <- `<your password>` 1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`)
- `KEY_STORE_PASSWORD` <- `<your key store password>` 2. Bump `versionCode` in `amethyst/build.gradle`
- `SIGNING_KEY` <- the data from `<my-release-key.keystore>` 3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags`
3. Change the `versionCode` and `versionName` on `amethyst/build.gradle` 4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags
4. Commit and push. 5. Upload AAB to Play Store manually (existing step)
5. Tag the commit with `v{x.x.x}`
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.
7. Add your CHANGE LOG to the description of the new release
8. Download the `aab` file and upload it to the PlayStore.
## Using the Quartz library ## Using the Quartz library