Merge pull request #2430 from nrobi144/feat/desktop-multiplatform-distribution

feat(desktop): multi-platform distribution — 8 assets, Homebrew + Winget
This commit is contained in:
Vitor Pamplona
2026-04-17 08:19:40 -04:00
committed by GitHub
16 changed files with 1086 additions and 286 deletions
@@ -0,0 +1,49 @@
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.
inputs:
tag:
description: "Tag to validate (e.g. v1.08.0)"
required: true
is_prerelease:
description: "Whether the release is marked as prerelease (empty string treated as false)"
required: false
default: "false"
is_draft:
description: "Whether the release is a draft (empty string treated as false)"
required: false
default: "false"
runs:
using: composite
steps:
- name: Assert release is stable
shell: bash
env:
TAG: ${{ inputs.tag }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
IS_DRAFT: ${{ inputs.is_draft }}
run: |
set -euo pipefail
echo "tag=$TAG prerelease=$IS_PRERELEASE draft=$IS_DRAFT"
# Stable = exactly vMAJOR.MINOR.PATCH; reject everything else.
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tag $TAG does not match stable vMAJOR.MINOR.PATCH format; refusing bump"
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:
- "*"
+71
View File
@@ -0,0 +1,71 @@
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
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- 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']
});
+68
View File
@@ -0,0 +1,68 @@
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
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- 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']
});
+268 -267
View File
@@ -3,267 +3,184 @@ 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
env:
# Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# Single source of truth in scripts/asset-name.sh.
# linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
jobs: jobs:
create-release: # ---------------------------------------------------------------------------
runs-on: ubuntu-latest # Desktop build matrix. Each leg uploads directly to the GH Release via
outputs: # softprops/action-gh-release@v2 (upsert by tag_name). No artifact round-trip.
upload_url: ${{ steps.create_release.outputs.upload_url }} # ---------------------------------------------------------------------------
steps: build-desktop:
- 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
deploy-android:
needs: create-release
runs-on: ubuntu-latest
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 AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: amethyst/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: amethyst/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK
run: ./gradlew assembleRelease --stacktrace
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: amethyst/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: amethyst/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-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/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
id: upload-release-asset-play-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/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
id: upload-release-asset-play-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/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
id: upload-release-asset-play-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/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
id: upload-release-asset-play-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/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-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-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
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
run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
deploy-desktop:
needs: create-release
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-latest - { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
task: packageDeb - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
format: deb - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
platform: linux - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- os: macos-latest - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
task: packageDmg
format: dmg
platform: macos
- os: windows-latest
task: packageMsi
format: msi
platform: windows
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 45
defaults: defaults:
run: run:
shell: bash 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 (deb+rpm leg only)
if: 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
- name: Build desktop artifacts
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
max_attempts: 2
timeout_minutes: 15
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
# collect_assets normalizes linux-portable → linux internally.
collect_assets "${{ matrix.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 }}"
# Stable = exactly vMAJOR.MINOR.PATCH; everything else is prerelease.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$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: false # Android job writes release notes (last-writer-wins race)
- 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:
if: github.event_name != 'workflow_dispatch' # dry-run skips Android (tag-push only)
runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -280,26 +197,110 @@ 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', 'gradle/libs.versions.toml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-android-gradle-
- name: Build Desktop Distribution - name: Build AAB
run: ./gradlew :desktopApp:${{ matrix.task }} run: ./gradlew clean bundleRelease --stacktrace
- name: Find distribution file - name: Sign AAB (Google Play)
id: find-dist uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
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: with:
upload_url: ${{ needs.create-release.outputs.upload_url }} releaseDirectory: amethyst/build/outputs/bundle/playRelease
asset_path: ${{ steps.find-dist.outputs.path }} signingKeyBase64: ${{ secrets.SIGNING_KEY }}
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }} alias: ${{ secrets.KEY_ALIAS }}
asset_content_type: application/octet-stream keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
with:
releaseDirectory: amethyst/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK
run: ./gradlew assembleRelease --stacktrace
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
with:
releaseDirectory: amethyst/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
with:
releaseDirectory: amethyst/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Collect Android assets (rename to canonical scheme)
run: |
set -euo pipefail
mkdir -p dist
TAG="${GITHUB_REF_NAME}"
# Play APKs (5 variants)
for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do
cp "amethyst/build/outputs/apk/play/release/amethyst-play-${variant}-release-unsigned-signed.apk" \
"dist/amethyst-googleplay-${variant}-${TAG}.apk"
done
# F-Droid APKs (5 variants)
for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do
cp "amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-${variant}-release-unsigned-signed.apk" \
"dist/amethyst-fdroid-${variant}-${TAG}.apk"
done
# AABs
cp "amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab" \
"dist/amethyst-googleplay-${TAG}.aab"
cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \
"dist/amethyst-fdroid-${TAG}.aab"
ls -la dist
- name: Classify release
id: classify
run: |
TAG="${GITHUB_REF_NAME}"
# Stable = exactly vMAJOR.MINOR.PATCH; everything else is prerelease.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
files: dist/*
tag_name: ${{ github.ref_name }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: true
- name: Publish Quartz Lib
run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
+5
View File
@@ -164,6 +164,11 @@ desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/ desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/ desktopApp/src/jvmMain/appResources/windows/
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
packaging/appimage/linuxdeploy-x86_64.AppImage
packaging/appimage/linuxdeploy-extracted/
packaging/appimage/squashfs-root/
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
.claude/worktrees/ .claude/worktrees/
+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
+1 -1
View File
@@ -55,7 +55,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInteger() minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 442 versionCode = 442
versionName = generateVersionName("1.08.0") versionName = generateVersionName(libs.versions.app.get())
buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\"" buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+5
View File
@@ -12,7 +12,12 @@ plugins {
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
} }
// Shared app version for all subprojects — read from gradle/libs.versions.toml.
// Android versionCode stays local in amethyst/build.gradle (must be monotonic int).
// Desktop packageVersion inherits via project.version in desktopApp/build.gradle.kts.
allprojects { allprojects {
version = libs.versions.app.get()
configurations.configureEach { configurations.configureEach {
resolutionStrategy.cacheChangingModulesFor 0, 'seconds' resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
} }
+80 -2
View File
@@ -1,4 +1,6 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.io.File
import java.nio.file.Files
plugins { plugins {
alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.jetbrainsKotlinJvm)
@@ -7,6 +9,10 @@ plugins {
id("ir.mahozad.vlc-setup") version "0.1.0" id("ir.mahozad.vlc-setup") version "0.1.0"
} }
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
// for prerelease ordering: 1.08.0~rc1 < 1.08.0 per RPM version comparison rules.
val appVersion: String = project.version.toString()
sourceSets { sourceSets {
main { main {
kotlin.srcDir("src/jvmMain/kotlin") kotlin.srcDir("src/jvmMain/kotlin")
@@ -88,11 +94,11 @@ compose.desktop {
nativeDistributions { nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
modules("java.management") // Required by kmp-tor TorRuntime modules("java.management") // Required by kmp-tor TorRuntime
packageName = "Amethyst" packageName = "Amethyst"
packageVersion = "1.0.0" packageVersion = appVersion
description = "Nostr client for desktop" description = "Nostr client for desktop"
vendor = "Amethyst Contributors" vendor = "Amethyst Contributors"
@@ -109,6 +115,12 @@ compose.desktop {
linux { linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png")) iconFile.set(project.file("src/jvmMain/resources/icon.png"))
menuGroup = "Network"
appCategory = "Network"
debMaintainer = "vitor@vitorpamplona.com"
rpmLicenseType = "MIT"
// RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering).
rpmPackageVersion = appVersion.replace("-", "~")
} }
} }
} }
@@ -126,3 +138,69 @@ vlcSetup {
tasks.named("spotlessKotlin") { tasks.named("spotlessKotlin") {
mustRunAfter("vlcSetup") mustRunAfter("vlcSetup")
} }
// --- AppImage packaging (Linux) ---
//
// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101).
// Instead: wrap `createReleaseDistributable` output with `linuxdeploy` (which
// auto-bundles libraries, handles rpath, and calls appimagetool internally).
//
// Build inputs live in packaging/appimage/:
// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC)
// - amethyst.desktop XDG desktop entry
// - amethyst.png 512x512 icon
//
// linuxdeploy binary is fetched by CI (SHA-verified) into packaging/appimage/
// as linuxdeploy-x86_64.AppImage. BUILDING.md documents local-dev fetch.
val createReleaseAppImage by tasks.registering(Exec::class) {
group = "compose desktop"
description = "Bundle createReleaseDistributable output into a Linux AppImage via linuxdeploy."
dependsOn("createReleaseDistributable")
val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst")
val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-x86_64.AppImage")
val toolRoot = layout.projectDirectory.dir("../packaging/appimage")
val linuxdeployTool = toolRoot.file("linuxdeploy-x86_64.AppImage")
inputs.dir(distDir)
inputs.dir(toolRoot)
outputs.file(outFile)
doFirst {
val dir = appDir.get().asFile
dir.deleteRecursively()
dir.mkdirs()
copy {
from(distDir) { into("usr") }
from(toolRoot.file("AppRun")) {
rename { "AppRun" }
filePermissions { unix("0755") }
}
from(toolRoot.file("amethyst.desktop"))
from(toolRoot.file("amethyst.png"))
into(dir)
}
// DirIcon is used by desktop integrations (file managers, AppImageLauncher)
val dirIcon = File(dir, ".DirIcon")
if (dirIcon.exists()) dirIcon.delete()
Files.createSymbolicLink(dirIcon.toPath(), File("amethyst.png").toPath())
if (!linuxdeployTool.asFile.canExecute()) {
linuxdeployTool.asFile.setExecutable(true)
}
}
commandLine(
linuxdeployTool.asFile.absolutePath,
"--appdir", appDir.get().asFile.absolutePath,
"--output", "appimage",
"--desktop-file", "${appDir.get().asFile}/amethyst.desktop",
"--icon-file", "${appDir.get().asFile}/amethyst.png",
)
environment("OUTPUT", outFile.get().asFile.absolutePath)
environment("ARCH", "x86_64")
// Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2).
// AppImage standard env var: extracts + runs without mounting.
environment("APPIMAGE_EXTRACT_AND_RUN", "1")
}
+4
View File
@@ -1,4 +1,8 @@
[versions] [versions]
# Amethyst app version — single source of truth consumed by both Android (amethyst/)
# and Desktop (desktopApp/). Android versionCode is bumped independently in
# amethyst/build.gradle because it must be a monotonic integer.
app = "1.08.0"
accompanistAdaptive = "0.37.3" accompanistAdaptive = "0.37.3"
cachemapVersion = "0.2.4" cachemapVersion = "0.2.4"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.10.3"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# AppImage launcher for Amethyst Desktop.
# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime.
# jpackage puts app resources at usr/lib/app/<platform>/vlc/ inside the AppDir.
set -eu
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins"
export PATH="${HERE}/usr/bin:${PATH}"
export APPDIR="${HERE}"
exec "${HERE}/usr/bin/Amethyst" "$@"
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=Amethyst
GenericName=Nostr Client
Comment=Nostr client for desktop
Exec=Amethyst %u
Icon=amethyst
Categories=Network;InstantMessaging;
Terminal=false
StartupNotify=true
StartupWMClass=Amethyst
Keywords=nostr;client;social;chat;
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Single source of truth for Amethyst Desktop release asset naming.
#
# Contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# <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
#
# Consumed by: .github/workflows/create-release.yml, .github/workflows/bump-*.yml,
# BUILDING.md, local release runbooks.
#
# Usage:
# source scripts/asset-name.sh
# collect_assets <family> <arch> <version> <dest_dir>
#
# Expected 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-windows-x64.zip
# amethyst-desktop-1.08.0-linux-x64.deb
# amethyst-desktop-1.08.0-linux-x64.rpm
# amethyst-desktop-1.08.0-linux-x64.AppImage
# amethyst-desktop-1.08.0-linux-x64.tar.gz
set -euo pipefail
# Print the canonical asset filename for a given family/arch/version/extension.
# Usage: asset_name <family> <arch> <version> <ext>
asset_name() {
local family="$1" arch="$2" version="$3" ext="$4"
printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
}
# Copy + rename build outputs into <dest_dir> using the canonical naming scheme.
# Usage: collect_assets <family> <arch> <version> <dest_dir>
# Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives).
collect_assets() {
local family="$1" arch="$2" version="$3" dest="$4"
# Normalize internal matrix family aliases to canonical asset-name families.
[[ "$family" == "linux-portable" ]] && family="linux"
mkdir -p "$dest"
shopt -s nullglob
# Compose Desktop jpackage outputs (main-release/<format>/*.ext)
local src ext base dst
for src in \
desktopApp/build/compose/binaries/main-release/dmg/*.dmg \
desktopApp/build/compose/binaries/main-release/msi/*.msi \
desktopApp/build/compose/binaries/main-release/deb/*.deb \
desktopApp/build/compose/binaries/main-release/rpm/*.rpm \
desktopApp/build/appimage/*.AppImage \
desktopApp/build/portable/*.tar.gz \
desktopApp/build/portable/*.zip \
; do
[ -f "$src" ] || continue
base="$(basename "$src")"
case "$base" in
*.tar.gz) ext="tar.gz" ;;
*) ext="${base##*.}" ;;
esac
dst="$dest/$(asset_name "$family" "$arch" "$version" "$ext")"
cp "$src" "$dst"
echo "Collected: $dst"
done
shopt -u nullglob
}