Build release artifacts #29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| 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-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-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/**/*.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 |