Skip to content

Release

Release #2

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
version:
description: Release version without a v prefix, for example 0.1.0
required: true
type: string
permissions:
contents: read
concurrency:
group: release-${{ inputs.version }}
cancel-in-progress: false
jobs:
validate:
name: Validate release
runs-on: macos-26
outputs:
release_sha: ${{ steps.release.outputs.release_sha }}
release_version: ${{ steps.release.outputs.release_version }}
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
- name: Validate version and release commit
id: release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "Releases must be dispatched from the main branch." >&2
exit 1
fi
if [[ ! "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Release version must be plain semantic version such as 0.1.0." >&2
exit 1
fi
embedded_version=$(sed -nE 's/^[[:space:]]*internal static let version: String = "([^"]+)"$/\1/p' Sources/fxcodex-cli/AppCommand.swift)
if [[ -z "$embedded_version" ]]; then
echo "Could not read AppCommand.version." >&2
exit 1
fi
if [[ "$embedded_version" != "$RELEASE_VERSION" ]]; then
echo "Release $RELEASE_VERSION does not match AppCommand.version $embedded_version." >&2
exit 1
fi
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" >/dev/null 2>&1; then
echo "Tag $RELEASE_VERSION already exists." >&2
exit 1
fi
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
echo "Release $RELEASE_VERSION already exists." >&2
exit 1
fi
echo "release_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
resolve:
name: Resolve release dependencies
needs: validate
runs-on: macos-26
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ needs.validate.outputs.release_sha }}
- name: Show Swift toolchain
run: swift --version
- name: Resolve dependencies
run: |
set -euo pipefail
swift package resolve
test -f Package.resolved
- name: Upload dependency snapshot
uses: actions/upload-artifact@v7
with:
name: release-dependencies
path: Package.resolved
if-no-files-found: error
retention-days: 7
build:
name: Build ${{ matrix.architecture }}
needs:
- validate
- resolve
strategy:
fail-fast: false
matrix:
include:
- architecture: aarch64
runner: macos-26
artifact: fxcodex-aarch64-apple-darwin
- architecture: x86_64
runner: macos-26-intel
artifact: fxcodex-x86_64-apple-darwin
runs-on: ${{ matrix.runner }}
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ needs.validate.outputs.release_sha }}
- name: Download dependency snapshot
uses: actions/download-artifact@v8
with:
name: release-dependencies
path: .
- name: Show Swift toolchain
run: swift --version
- name: Verify dependency snapshot
run: swift package resolve --force-resolved-versions
- name: Test
run: make test
- name: Build release executable
run: make release
- name: Verify executable and checksum
shell: bash
env:
ARTIFACT: ${{ matrix.artifact }}
RELEASE_VERSION: ${{ needs.validate.outputs.release_version }}
run: |
set -euo pipefail
test -f "dist/$ARTIFACT"
test -f "dist/$ARTIFACT.sha256"
test "$(dist/$ARTIFACT version)" = "$RELEASE_VERSION"
(cd dist && shasum -a 256 --check "$ARTIFACT.sha256")
- name: Upload architecture artifacts
uses: actions/upload-artifact@v7
with:
name: release-architecture-${{ matrix.architecture }}
path: |
dist/${{ matrix.artifact }}
dist/${{ matrix.artifact }}.sha256
if-no-files-found: error
retention-days: 7
publish:
name: Publish GitHub Release
needs:
- build
- resolve
- validate
runs-on: macos-26
environment: release
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ needs.validate.outputs.release_sha }}
- name: Download architecture artifacts
uses: actions/download-artifact@v8
with:
pattern: release-architecture-*
path: dist
merge-multiple: true
- name: Download dependency snapshot
uses: actions/download-artifact@v8
with:
name: release-dependencies
path: dist
- name: Build universal executable
shell: bash
run: |
set -euo pipefail
arm_artifact=dist/fxcodex-aarch64-apple-darwin
intel_artifact=dist/fxcodex-x86_64-apple-darwin
universal_artifact=dist/fxcodex-universal-apple-darwin
xcrun lipo -create \
"$arm_artifact" \
"$intel_artifact" \
-output "$universal_artifact"
chmod 755 "$universal_artifact"
xcrun lipo "$universal_artifact" -verify_arch arm64 x86_64
- name: Import Developer ID identity
shell: bash
env:
CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_P12_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
test -n "$CERTIFICATE_BASE64"
test -n "$CERTIFICATE_PASSWORD"
certificate_path="$RUNNER_TEMP/developer-id.p12"
keychain_path="$RUNNER_TEMP/fxcodex-signing.keychain-db"
keychain_password=$(uuidgen)
printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-f pkcs12 \
-k "$keychain_path" \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple: \
-s \
-k "$keychain_password" \
"$keychain_path"
security list-keychains -d user -s "$keychain_path"
identity=$(security find-identity -v -p codesigning \
| awk '/Developer ID Application/ { print $2; exit }')
if [[ -z "$identity" ]]; then
echo "The imported Developer ID Application identity is not discoverable." >&2
exit 1
fi
signing_probe="$RUNNER_TEMP/fxcodex-signing-probe"
cp /usr/bin/true "$signing_probe"
codesign \
--force \
--keychain "$keychain_path" \
--options runtime \
--sign "$identity" \
--timestamp=none \
"$signing_probe"
codesign --verify --strict --verbose=2 "$signing_probe"
rm -f "$signing_probe"
echo "SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV"
echo "SIGNING_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
- name: Prepare notarization credentials
shell: bash
env:
API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
test -n "$API_KEY_BASE64"
key_path="$RUNNER_TEMP/AuthKey.p8"
printf '%s' "$API_KEY_BASE64" | base64 -D > "$key_path"
chmod 600 "$key_path"
echo "NOTARY_KEY_PATH=$key_path" >> "$GITHUB_ENV"
- name: Sign release executables
env:
SIGNING_IDENTITY: ${{ env.SIGNING_IDENTITY }}
SIGNING_KEYCHAIN: ${{ env.SIGNING_KEYCHAIN }}
run: |
./Scripts/sign-release.sh \
dist/fxcodex-aarch64-apple-darwin \
dist/fxcodex-x86_64-apple-darwin \
dist/fxcodex-universal-apple-darwin
- name: Notarize release executables
env:
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
NOTARY_KEY_PATH: ${{ env.NOTARY_KEY_PATH }}
run: |
./Scripts/notarize-release.sh \
dist/fxcodex-aarch64-apple-darwin \
dist/fxcodex-x86_64-apple-darwin \
dist/fxcodex-universal-apple-darwin
- name: Verify release bundle
shell: bash
run: |
set -euo pipefail
test -f dist/Package.resolved
test "$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')" = 7
(cd dist && shasum -a 256 --check ./*.sha256)
- name: Recheck release availability
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.validate.outputs.release_version }}
run: |
set -euo pipefail
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" >/dev/null 2>&1; then
echo "Tag $RELEASE_VERSION was created while the release was building." >&2
exit 1
fi
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
echo "Release $RELEASE_VERSION was created while the release was building." >&2
exit 1
fi
- name: Publish release
id: publish
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_MARKER: <!-- fxcodex-release-run:${{ github.run_id }}-${{ github.run_attempt }} -->
RELEASE_SHA: ${{ needs.validate.outputs.release_sha }}
RELEASE_VERSION: ${{ needs.validate.outputs.release_version }}
run: |
gh release create "$RELEASE_VERSION" dist/* \
--target "$RELEASE_SHA" \
--generate-notes \
--notes "$RELEASE_MARKER" \
--title "$RELEASE_VERSION"
- name: Clean up failed publication
if: ${{ failure() && steps.publish.outcome == 'failure' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_MARKER: <!-- fxcodex-release-run:${{ github.run_id }}-${{ github.run_attempt }} -->
RELEASE_VERSION: ${{ needs.validate.outputs.release_version }}
run: |
set -euo pipefail
release_is_draft=$(gh release view "$RELEASE_VERSION" \
--json isDraft \
--jq '.isDraft' \
2>/dev/null || true)
release_body=$(gh release view "$RELEASE_VERSION" \
--json body \
--jq '.body' \
2>/dev/null || true)
if \
[[ "$release_is_draft" == "true" ]] \
&& [[ "$release_body" == *"$RELEASE_MARKER"* ]]
then
gh release delete "$RELEASE_VERSION" --cleanup-tag --yes
fi
- name: Remove signing material
if: ${{ always() }}
shell: bash
run: |
security delete-keychain "$RUNNER_TEMP/fxcodex-signing.keychain-db" 2>/dev/null || true
rm -f \
"$RUNNER_TEMP/developer-id.p12" \
"$RUNNER_TEMP/AuthKey.p8" \
"$RUNNER_TEMP/fxcodex-signing-probe"