diff --git a/.github/actions/assert-stable-release/action.yml b/.github/actions/assert-stable-release/action.yml new file mode 100644 index 000000000..9d754a447 --- /dev/null +++ b/.github/actions/assert-stable-release/action.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0c0bdba38 --- /dev/null +++ b/.github/dependabot.yml @@ -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: + - "*" diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml new file mode 100644 index 000000000..478409386 --- /dev/null +++ b/.github/workflows/bump-homebrew.yml @@ -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'] + }); diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml new file mode 100644 index 000000000..be3cc9115 --- /dev/null +++ b/.github/workflows/bump-winget.yml @@ -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'] + }); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fc619bc3f..8b81801f2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -3,267 +3,184 @@ name: Create Release Assets on: push: 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: contents: write +env: + # Asset naming contract: amethyst-desktop---. + # 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: - create-release: - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - steps: - - 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 + # --------------------------------------------------------------------------- + # 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: 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 + - { 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 (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: - name: Checkout code uses: actions/checkout@v6 @@ -280,26 +197,110 @@ jobs: path: | ~/.gradle/caches ~/.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: | - ${{ runner.os }}-gradle- + ${{ runner.os }}-android-gradle- - - name: Build Desktop Distribution - run: ./gradlew :desktopApp:${{ matrix.task }} + - name: Build AAB + run: ./gradlew clean bundleRelease --stacktrace - - 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 }} + - name: Sign AAB (Google Play) + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1 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 + 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@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 }} diff --git a/.gitignore b/.gitignore index aa8cf0a66..387265dae 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,11 @@ desktopApp/src/jvmMain/appResources/linux/ desktopApp/src/jvmMain/appResources/macos/ 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 .worktrees/ .claude/worktrees/ diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 000000000..611db4f12 --- /dev/null +++ b/BUILDING.md @@ -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---. +``` + +Where: + +| Field | Values | +|---|---| +| `` | Tag stripped of leading `v` (e.g. `1.08.0`) | +| `` | `macos`, `windows`, `linux` | +| `` | `x64`, `arm64` | +| `` | `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 (~25–30 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`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | +| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | +| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.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 --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: 1–2 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: 24–72h. + +### 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 + +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) diff --git a/README.md b/README.md index dcf7f0753..bd5cc3036 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Join the social network you control. ## Download and Install +### Android + [Get it on Zap Store](https://github.com/zapstore/zapstore/releases) @@ -33,6 +35,24 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) alt="Get it on Google Play" 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). + ## Supported Features @@ -249,22 +269,18 @@ For the Play build: ## Deploying -1. Generate a new signing key -``` -keytool -genkey -v -keystore -alias -keyalg RSA -keysize 2048 -validity 10000 -openssl base64 < | tr -d '\n' | tee some_signing_key.jks.base64.txt -``` -2. Create four Secret Key variables on your GitHub repository and fill in the signing key information - - `KEY_ALIAS` <- `` - - `KEY_PASSWORD` <- `` - - `KEY_STORE_PASSWORD` <- `` - - `SIGNING_KEY` <- the data from `` -3. Change the `versionCode` and `versionName` on `amethyst/build.gradle` -4. Commit and push. -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. +Full release + bootstrap runbooks (Android AAB upload, desktop packaging, +Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live +in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and +[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time). + +TL;DR for cutting a release: + +1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`) +2. Bump `versionCode` in `amethyst/build.gradle` +3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags` +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 +5. Upload AAB to Play Store manually (existing step) ## Using the Quartz library diff --git a/amethyst/build.gradle b/amethyst/build.gradle index f52cb777c..c83ae17f8 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -55,7 +55,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() versionCode = 442 - versionName = generateVersionName("1.08.0") + versionName = generateVersionName(libs.versions.app.get()) buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/build.gradle b/build.gradle index f065a2b44..437eb14d5 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,12 @@ plugins { 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 { + version = libs.versions.app.get() + configurations.configureEach { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f413928a3..bd014b97b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,6 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.io.File +import java.nio.file.Files plugins { alias(libs.plugins.jetbrainsKotlinJvm) @@ -7,6 +9,10 @@ plugins { 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 { main { kotlin.srcDir("src/jvmMain/kotlin") @@ -88,11 +94,11 @@ compose.desktop { nativeDistributions { 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 packageName = "Amethyst" - packageVersion = "1.0.0" + packageVersion = appVersion description = "Nostr client for desktop" vendor = "Amethyst Contributors" @@ -109,6 +115,12 @@ compose.desktop { linux { 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") { 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") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7453c2a6..b36d6c335 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,8 @@ [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" cachemapVersion = "0.2.4" composeMultiplatform = "1.10.3" diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 000000000..d4065af01 --- /dev/null +++ b/packaging/appimage/AppRun @@ -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//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" "$@" diff --git a/packaging/appimage/amethyst.desktop b/packaging/appimage/amethyst.desktop new file mode 100644 index 000000000..5c59c74de --- /dev/null +++ b/packaging/appimage/amethyst.desktop @@ -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; diff --git a/packaging/appimage/amethyst.png b/packaging/appimage/amethyst.png new file mode 100644 index 000000000..030e45ade Binary files /dev/null and b/packaging/appimage/amethyst.png differ diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh new file mode 100755 index 000000000..eab8f6bed --- /dev/null +++ b/scripts/asset-name.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Single source of truth for Amethyst Desktop release asset naming. +# +# Contract: amethyst-desktop---. +# = tag stripped of leading 'v' (e.g. "1.08.0") +# = macos | windows | linux +# = x64 | arm64 +# = 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 +# +# 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 +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 using the canonical naming scheme. +# Usage: collect_assets +# 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//*.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 +}