Skip to content

Build release artifacts #30

Build release artifacts

Build release artifacts #30

name: Build release artifacts
"on":
workflow_dispatch:
inputs:
tag:
description: "Release tag. Defaults to v<release version>."
required: false
type: string
artifact_scope:
description: "Artifacts to build"
required: true
default: all
type: choice
options:
- all
- macos
- windows
publish_release:
description: "Create or update the GitHub Release"
required: true
default: true
type: boolean
draft:
description: "Create the GitHub Release as a draft"
required: true
default: true
type: boolean
prerelease:
description: "Mark the GitHub Release as a prerelease"
required: true
default: false
type: boolean
permissions:
contents: write
concurrency:
group: build-release-artifacts-${{ github.ref }}
cancel-in-progress: false
jobs:
metadata:
name: Resolve release metadata
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.release.outputs.version }}
build: ${{ steps.release.outputs.build }}
tag: ${{ steps.release.outputs.tag }}
artifact_path: ${{ steps.release.outputs.artifact_path }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve release metadata
id: release
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import plistlib
import re
import sys
import tomllib
from pathlib import Path
def read_properties(path):
values = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
errors = []
release = read_properties(Path("version.properties"))
version = release.get("VERSION_NAME", "")
build = release.get("VERSION_CODE", "")
with Path("macOS/Sources/ClipDock/Resources/AppInfo.plist").open("rb") as handle:
macos_info = plistlib.load(handle)
with Path("Server/Cargo.toml").open("rb") as handle:
server_manifest = tomllib.load(handle)
if not re.fullmatch(r"[0-9]+(\.[0-9]+){1,2}", version):
errors.append("VERSION_NAME must be numeric, such as 0.1.8")
if not re.fullmatch(r"[1-9][0-9]*", build):
errors.append("VERSION_CODE must be a positive integer")
macos_version = str(macos_info.get("CFBundleShortVersionString", ""))
macos_build = str(macos_info.get("CFBundleVersion", ""))
server_version = str(server_manifest.get("package", {}).get("version", ""))
if macos_version != version:
errors.append(f"macOS CFBundleShortVersionString {macos_version!r} does not match VERSION_NAME {version!r}")
if macos_build != build:
errors.append(f"macOS CFBundleVersion {macos_build!r} does not match VERSION_CODE {build!r}")
if server_version != version:
errors.append(f"Server Cargo.toml version {server_version!r} does not match VERSION_NAME {version!r}")
tag = os.environ.get("INPUT_TAG", "").strip() or f"v{version}"
if not re.fullmatch(r"[A-Za-z0-9._/-]+", tag):
errors.append("tag may only contain letters, numbers, dot, underscore, slash, or dash")
if errors:
for error in errors:
print(error, file=sys.stderr)
raise SystemExit(1)
artifact_path = f".codex/artifacts/release/{version}"
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
output.write(f"version={version}\n")
output.write(f"build={build}\n")
output.write(f"tag={tag}\n")
output.write(f"artifact_path={artifact_path}\n")
PY
build-macos-app:
name: Build macOS app DMGs
runs-on: macos-15
needs: metadata
timeout-minutes: 75
env:
APP_BUNDLE_NAME: ClipDock
APP_EXECUTABLE_NAME: ClipDock
BUNDLE_IDENTIFIER: com.apkdv.clipdock
APP_ARCHS: arm64 x86_64
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Print toolchain
working-directory: macOS
run: |
sw_vers
xcodebuild -version
swift --version
cargo --version
- name: Build release bundle
working-directory: macOS
env:
APP_VERSION: ${{ needs.metadata.outputs.version }}
APP_BUILD: ${{ needs.metadata.outputs.build }}
RELEASE_DIR: ${{ github.workspace }}/${{ needs.metadata.outputs.artifact_path }}/macos
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY || '-' }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: scripts/release-macos.sh
- name: Upload macOS workflow artifact
uses: actions/upload-artifact@v7
with:
name: ClipDock-${{ needs.metadata.outputs.version }}-macos
path: |
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/macos/ClipDock-${{ needs.metadata.outputs.version }}-*.dmg
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/macos/ClipDock-release-manifest.txt
if-no-files-found: error
retention-days: 30
build-windows-app:
name: Build Windows app installers
if: ${{ inputs.artifact_scope == 'all' || inputs.artifact_scope == 'windows' }}
needs: metadata
runs-on: windows-latest
timeout-minutes: 90
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: Windows/package-lock.json
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
workspaces: Windows/src-tauri
- name: Print Windows toolchain
working-directory: Windows
run: |
set -euo pipefail
node --version
npm --version
rustc -vV
cargo --version
- name: Resolve Windows app version
id: winver
working-directory: Windows
run: |
set -euo pipefail
version="$(node -p "require('./src-tauri/tauri.conf.json').version")"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install frontend dependencies
working-directory: Windows
run: npm ci
- name: Build Tauri Windows installers
working-directory: Windows
run: npm run tauri:build -- --bundles nsis,msi
- name: Collect Windows release artifacts
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.version }}
WIN_VERSION: ${{ steps.winver.outputs.version }}
run: |
set -euo pipefail
artifact_root="$PWD/${{ needs.metadata.outputs.artifact_path }}/windows"
bundle_dir="Windows/src-tauri/target/release/bundle"
mkdir -p "$artifact_root"
rm -f "$artifact_root"/*.msi "$artifact_root"/*.exe \
"$artifact_root"/ClipDock-Windows-release-manifest.txt
msi_source="$(find "$bundle_dir/msi" -maxdepth 1 -type f -name '*.msi' | sort | head -n 1 || true)"
exe_source="$(find "$bundle_dir/nsis" -maxdepth 1 -type f -name '*.exe' | sort | head -n 1 || true)"
if [[ -z "$msi_source" && -z "$exe_source" ]]; then
echo "no Windows installers were produced" >&2
find "$bundle_dir" -type f || true
exit 1
fi
manifest_path="$artifact_root/ClipDock-Windows-release-manifest.txt"
{
printf 'name=ClipDock\n'
printf 'platform=windows\n'
printf 'identifier=com.apkdv.clipdock.panel\n'
printf 'app_version=%s\n' "$WIN_VERSION"
printf 'release_version=%s\n' "$RELEASE_VERSION"
} > "$manifest_path"
if [[ -n "$msi_source" ]]; then
msi_path="$artifact_root/ClipDock-$WIN_VERSION-windows-x64.msi"
cp "$msi_source" "$msi_path"
printf 'msi=%s\n' "$msi_path" >> "$manifest_path"
fi
if [[ -n "$exe_source" ]]; then
exe_path="$artifact_root/ClipDock-$WIN_VERSION-windows-x64-setup.exe"
cp "$exe_source" "$exe_path"
printf 'nsis=%s\n' "$exe_path" >> "$manifest_path"
fi
cat "$manifest_path"
- name: Upload Windows workflow artifact
uses: actions/upload-artifact@v7
with:
name: ClipDock-${{ needs.metadata.outputs.version }}-windows
path: |
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/windows/*.msi
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/windows/*.exe
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/windows/ClipDock-Windows-release-manifest.txt
if-no-files-found: error
retention-days: 30
build-android-app:
name: Build Android app APK
if: ${{ inputs.artifact_scope == 'all' }}
needs: metadata
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Install Android SDK packages
shell: bash
run: |
set -euo pipefail
if [[ -z "${ANDROID_HOME:-}" ]]; then
echo "ANDROID_HOME is not set" >&2
exit 1
fi
sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [[ ! -x "$sdkmanager" ]]; then
echo "sdkmanager not found: $sdkmanager" >&2
exit 1
fi
"$sdkmanager" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.3.13750724"
- name: Print Android toolchain
working-directory: Android
shell: bash
run: |
set -euo pipefail
java -version
./gradlew --version
rustc -vV
cargo --version
- name: Add Rust Android targets
shell: bash
run: rustup target add aarch64-linux-android
- name: Prepare Android signing key
shell: bash
env:
ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
ANDROID_RELEASE_KEYSTORE: ${{ runner.temp }}/clipdock-release.jks
run: |
set -euo pipefail
: "${ANDROID_RELEASE_KEYSTORE_BASE64:?ANDROID_RELEASE_KEYSTORE_BASE64 secret is required}"
: "${ANDROID_RELEASE_KEYSTORE_PASSWORD:?ANDROID_RELEASE_KEYSTORE_PASSWORD secret is required}"
: "${ANDROID_RELEASE_KEY_ALIAS:?ANDROID_RELEASE_KEY_ALIAS secret is required}"
: "${ANDROID_RELEASE_KEY_PASSWORD:?ANDROID_RELEASE_KEY_PASSWORD secret is required}"
printf '%s' "$ANDROID_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$ANDROID_RELEASE_KEYSTORE"
chmod 600 "$ANDROID_RELEASE_KEYSTORE"
keytool -list \
-keystore "$ANDROID_RELEASE_KEYSTORE" \
-storepass "$ANDROID_RELEASE_KEYSTORE_PASSWORD" \
-alias "$ANDROID_RELEASE_KEY_ALIAS" >/dev/null
- name: Build Android release package
working-directory: Android
env:
ANDROID_RELEASE_KEYSTORE: ${{ runner.temp }}/clipdock-release.jks
ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
CLIPDOCK_RUST_PROFILE: release
run: ./gradlew --no-daemon :app:assembleRelease
- name: Collect Android release artifacts
shell: bash
run: |
set -euo pipefail
version="${{ needs.metadata.outputs.version }}"
build="${{ needs.metadata.outputs.build }}"
artifact_root="$PWD/${{ needs.metadata.outputs.artifact_path }}/android"
mkdir -p "$artifact_root"
rm -f "$artifact_root"/*.apk "$artifact_root"/*.aab "$artifact_root"/ClipDock-Android-release-manifest.txt
apk_sources=()
while IFS= read -r artifact; do
apk_sources+=("$artifact")
done < <(find Android/app/build/outputs/apk/release -maxdepth 1 -type f -name "*.apk" | sort)
if [[ "${#apk_sources[@]}" -ne 1 ]]; then
echo "expected exactly one release APK, found ${#apk_sources[@]}" >&2
exit 1
fi
apk_path="$artifact_root/ClipDock-$version-$build-android-release-signed.apk"
manifest_path="$artifact_root/ClipDock-Android-release-manifest.txt"
cp "${apk_sources[0]}" "$apk_path"
{
printf 'name=ClipDock\n'
printf 'platform=android\n'
printf 'application_id=com.apkdv.clipdock\n'
printf 'version=%s\n' "$version"
printf 'version_code=%s\n' "$build"
printf 'artifact_format=apk\n'
printf 'signed=true\n'
printf 'apk=%s\n' "$apk_path"
} > "$manifest_path"
- name: Upload Android workflow artifact
uses: actions/upload-artifact@v7
with:
name: ClipDock-${{ needs.metadata.outputs.version }}-android
path: |
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/android/*.apk
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/android/ClipDock-Android-release-manifest.txt
if-no-files-found: error
retention-days: 30
build-server:
name: Build server ${{ matrix.artifact_label }}
if: ${{ inputs.artifact_scope == 'all' }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- artifact_label: linux-x86_64
runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- artifact_label: linux-arm64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Print Rust toolchain
shell: bash
run: |
set -euo pipefail
uname -a || true
rustc -vV
cargo --version
- name: Add Rust target
shell: bash
run: rustup target add "${{ matrix.target }}"
- name: Build and package server
shell: bash
run: |
set -euo pipefail
artifact_root="$PWD/${{ needs.metadata.outputs.artifact_path }}"
Server/scripts/package-server.sh \
--target "${{ matrix.target }}" \
--version "${{ needs.metadata.outputs.version }}" \
--output-dir "$artifact_root/server/${{ matrix.artifact_label }}"
- name: Upload server workflow artifact
uses: actions/upload-artifact@v7
with:
name: ClipDock-Server-${{ needs.metadata.outputs.version }}-${{ matrix.artifact_label }}
path: |
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/server/${{ matrix.artifact_label }}/*.tar.gz
.codex/artifacts/release/${{ needs.metadata.outputs.version }}/server/${{ matrix.artifact_label }}/*.manifest.txt
if-no-files-found: error
retention-days: 30
publish-release:
name: Publish GitHub Release
if: ${{ inputs.publish_release && inputs.artifact_scope == 'all' }}
needs:
- metadata
- build-android-app
- build-macos-app
- build-windows-app
- build-server
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download release artifacts
uses: actions/download-artifact@v7
with:
path: release-artifacts
- name: Generate release notes from commits
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.version }}
RELEASE_BUILD: ${{ needs.metadata.outputs.build }}
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
RELEASE_NOTES: release-notes.md
run: |
set -euo pipefail
target_commit="$GITHUB_SHA"
if git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}" >/dev/null; then
target_commit="$(git rev-list -n 1 "$RELEASE_TAG")"
previous_tag="$(git describe --tags --abbrev=0 "$RELEASE_TAG^" 2>/dev/null || true)"
else
previous_tag="$(git describe --tags --abbrev=0 "$target_commit" 2>/dev/null || true)"
fi
if [[ -n "$previous_tag" ]]; then
commit_range="$previous_tag..$target_commit"
else
commit_range="$target_commit"
fi
{
echo "## ClipDock $RELEASE_VERSION"
echo
echo "- Build: $RELEASE_BUILD"
echo "- Commit: $target_commit"
if [[ -n "$previous_tag" ]]; then
echo "- Changes since: $previous_tag"
fi
echo
echo "## Commits"
echo
} > "$RELEASE_NOTES"
commit_count="$(git rev-list --count "$commit_range" 2>/dev/null || true)"
if [[ "${commit_count:-0}" =~ ^[0-9]+$ && "$commit_count" -gt 0 ]]; then
git log --format="- %s (%h)" --reverse "$commit_range" >> "$RELEASE_NOTES"
else
git log -1 --format="- %s (%h)" "$target_commit" >> "$RELEASE_NOTES"
fi
cat "$RELEASE_NOTES"
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.metadata.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ClipDock ${{ needs.metadata.outputs.version }}
body_path: release-notes.md
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: true
overwrite_files: true
files: |
release-artifacts/**/*.apk
release-artifacts/**/*.dmg
release-artifacts/**/*.msi
release-artifacts/**/*-setup.exe
release-artifacts/**/*.tar.gz
publish-macos-release:
name: Publish macOS GitHub Release
if: ${{ inputs.publish_release && inputs.artifact_scope == 'macos' }}
needs:
- metadata
- build-macos-app
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download macOS release artifacts
uses: actions/download-artifact@v7
with:
path: release-artifacts
- name: Generate release notes from commits
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.version }}
RELEASE_BUILD: ${{ needs.metadata.outputs.build }}
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
RELEASE_NOTES: release-notes.md
run: |
set -euo pipefail
target_commit="$GITHUB_SHA"
if git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}" >/dev/null; then
target_commit="$(git rev-list -n 1 "$RELEASE_TAG")"
previous_tag="$(git describe --tags --abbrev=0 "$RELEASE_TAG^" 2>/dev/null || true)"
else
previous_tag="$(git describe --tags --abbrev=0 "$target_commit" 2>/dev/null || true)"
fi
if [[ -n "$previous_tag" ]]; then
commit_range="$previous_tag..$target_commit"
else
commit_range="$target_commit"
fi
{
echo "## ClipDock $RELEASE_VERSION"
echo
echo "- Build: $RELEASE_BUILD"
echo "- Commit: $target_commit"
echo "- Scope: macOS"
if [[ -n "$previous_tag" ]]; then
echo "- Changes since: $previous_tag"
fi
echo
echo "## Commits"
echo
} > "$RELEASE_NOTES"
commit_count="$(git rev-list --count "$commit_range" 2>/dev/null || true)"
if [[ "${commit_count:-0}" =~ ^[0-9]+$ && "$commit_count" -gt 0 ]]; then
git log --format="- %s (%h)" --reverse "$commit_range" >> "$RELEASE_NOTES"
else
git log -1 --format="- %s (%h)" "$target_commit" >> "$RELEASE_NOTES"
fi
cat "$RELEASE_NOTES"
- name: Publish macOS GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.metadata.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ClipDock ${{ needs.metadata.outputs.version }}
body_path: release-notes.md
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: true
overwrite_files: true
files: |
release-artifacts/**/*.dmg
release-artifacts/**/*release-manifest.txt
publish-windows-release:
name: Publish Windows GitHub Release
if: ${{ inputs.publish_release && inputs.artifact_scope == 'windows' }}
needs:
- metadata
- build-windows-app
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Windows release artifacts
uses: actions/download-artifact@v7
with:
path: release-artifacts
- name: Generate release notes from commits
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.version }}
RELEASE_BUILD: ${{ needs.metadata.outputs.build }}
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
RELEASE_NOTES: release-notes.md
run: |
set -euo pipefail
target_commit="$GITHUB_SHA"
if git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}" >/dev/null; then
target_commit="$(git rev-list -n 1 "$RELEASE_TAG")"
previous_tag="$(git describe --tags --abbrev=0 "$RELEASE_TAG^" 2>/dev/null || true)"
else
previous_tag="$(git describe --tags --abbrev=0 "$target_commit" 2>/dev/null || true)"
fi
if [[ -n "$previous_tag" ]]; then
commit_range="$previous_tag..$target_commit"
else
commit_range="$target_commit"
fi
{
echo "## ClipDock $RELEASE_VERSION"
echo
echo "- Build: $RELEASE_BUILD"
echo "- Commit: $target_commit"
echo "- Scope: Windows"
if [[ -n "$previous_tag" ]]; then
echo "- Changes since: $previous_tag"
fi
echo
echo "## Commits"
echo
} > "$RELEASE_NOTES"
commit_count="$(git rev-list --count "$commit_range" 2>/dev/null || true)"
if [[ "${commit_count:-0}" =~ ^[0-9]+$ && "$commit_count" -gt 0 ]]; then
git log --format="- %s (%h)" --reverse "$commit_range" >> "$RELEASE_NOTES"
else
git log -1 --format="- %s (%h)" "$target_commit" >> "$RELEASE_NOTES"
fi
cat "$RELEASE_NOTES"
- name: Publish Windows GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.metadata.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ClipDock ${{ needs.metadata.outputs.version }}
body_path: release-notes.md
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: true
overwrite_files: true
files: |
release-artifacts/**/*.msi
release-artifacts/**/*-setup.exe
release-artifacts/**/*release-manifest.txt