name: Release
permissions:
"contents": "write"
on:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Require changelog entry
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
if ! grep -Fq "## ${version} - " CHANGELOG.md && ! grep -Fxq "## ${version}" CHANGELOG.md; then
echo "::error::CHANGELOG.md must include a ## ${version} section before publishing ${GITHUB_REF_NAME}." >&2
exit 1
fi
- name: Install dist
shell: bash
run: ./.github/scripts/install-dist.sh
- name: Cache dist
uses: actions/upload-artifact@v6
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --allow-dirty --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Require preinstalled Rust in container jobs
if: ${{ matrix.container }}
shell: bash
run: |
if command -v cargo > /dev/null 2>&1; then
exit 0
fi
echo "::error::Containerized release jobs must provide cargo up front. Refusing to bootstrap Rust from the network." >&2
echo "::error::Use a prebuilt image or add an explicitly reviewed secure toolchain setup before enabling matrix.container." >&2
exit 1
- name: Install dist
shell: bash
run: ./.github/scripts/install-dist.sh
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --allow-dirty --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
{
echo "paths<<EOF"
dist print-upload-files-from-manifest --manifest dist-manifest.json
echo "EOF"
} >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --allow-dirty --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
{
echo "paths<<EOF"
jq --raw-output ".upload_files[]" dist-manifest.json
echo "EOF"
} >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
- name: Fetch artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --allow-dirty --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
name: artifacts-dist-manifest
path: dist-manifest.json
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
shell: bash
run: |
# Write and read notes from a file to avoid quoting breaking things
release_args=(
"${{ needs.plan.outputs.tag }}"
--target "$RELEASE_COMMIT"
--title "$ANNOUNCEMENT_TITLE"
--notes-file "$RUNNER_TEMP/notes.txt"
)
if [[ -n "$PRERELEASE_FLAG" ]]; then
release_args+=("$PRERELEASE_FLAG")
fi
echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt"
gh release create "${release_args[@]}" artifacts/*
build-menubar-app:
needs:
- plan
- host
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
runs-on: "macos-15"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }}
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
outputs:
artifact: ${{ steps.package.outputs.artifact }}
sha256: ${{ steps.package.outputs.sha256 }}
url: ${{ steps.package.outputs.url }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Read release version
id: version
shell: bash
run: |
version="$(python3 - <<'PY'
import pathlib
import tomllib
print(tomllib.loads(pathlib.Path("Cargo.toml").read_text())["package"]["version"])
PY
)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Test menu bar app
run: |
xcodebuild \
-project macos/ClipmemMenuBar/ClipmemMenuBar.xcodeproj \
-scheme ClipmemMenuBar \
-configuration Debug \
-destination 'platform=macOS' \
-derivedDataPath /tmp/clipmem-menubar-test-derived \
test
- name: Import Developer ID certificate
shell: bash
run: |
required=(
APPLE_DEVELOPER_ID_CERTIFICATE_BASE64
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD
APPLE_TEAM_ID
)
for name in "${required[@]}"; do
if [[ -z "${!name:-}" ]]; then
echo "::error::Missing required secret ${name}" >&2
exit 1
fi
done
keychain_password="$(openssl rand -base64 24)"
keychain_path="$RUNNER_TEMP/clipmem-signing.keychain-db"
certificate_path="$RUNNER_TEMP/developer-id.p12"
echo "$APPLE_DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode > "$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" \
-k "$keychain_path" \
-P "$APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
existing_keychains=()
while IFS= read -r keychain; do
if [[ -n "$keychain" ]]; then
existing_keychains+=("$keychain")
fi
done < <(security list-keychains -d user | sed 's/[ "]//g')
security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path"
- name: Build signed Release app
shell: bash
run: |
xcodebuild \
-project macos/ClipmemMenuBar/ClipmemMenuBar.xcodeproj \
-scheme ClipmemMenuBar \
-configuration Release \
-destination 'platform=macOS' \
-derivedDataPath /tmp/clipmem-menubar-release-derived \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CODE_SIGN_IDENTITY="Developer ID Application" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \
OTHER_CODE_SIGN_FLAGS="--timestamp" \
MARKETING_VERSION="${{ steps.version.outputs.version }}" \
CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}" \
CLIPMEM_DEFAULT_LAUNCH_AT_LOGIN=YES \
build
- name: Notarize and staple app
id: package
shell: bash
run: |
app_path="/tmp/clipmem-menubar-release-derived/Build/Products/Release/ClipmemMenuBar.app"
artifact="clipmem-app-aarch64-apple-darwin.zip"
notarization_zip="$RUNNER_TEMP/clipmem-app-notarization.zip"
final_zip="$RUNNER_TEMP/${artifact}"
if [[ ! -d "$app_path" ]]; then
echo "::error::Built app was not found at ${app_path}" >&2
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$app_path"
ditto -c -k --keepParent "$app_path" "$notarization_zip"
if [[ -n "${APP_STORE_CONNECT_API_KEY_BASE64:-}" && -n "${APP_STORE_CONNECT_KEY_ID:-}" && -n "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then
key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > "$key_path"
xcrun notarytool submit "$notarization_zip" \
--key "$key_path" \
--key-id "$APP_STORE_CONNECT_KEY_ID" \
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
--wait
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
xcrun notarytool submit "$notarization_zip" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait
else
echo "::error::Missing notarization credentials. Configure App Store Connect API key secrets or APPLE_ID plus APPLE_APP_SPECIFIC_PASSWORD." >&2
exit 1
fi
xcrun stapler staple "$app_path"
xcrun stapler validate "$app_path"
spctl --assess --type execute --verbose "$app_path"
ditto -c -k --keepParent "$app_path" "$final_zip"
shasum -a 256 "$final_zip" | awk '{print $1}' > "${final_zip}.sha256"
gh release upload "${{ needs.plan.outputs.tag }}" "$final_zip" "${final_zip}.sha256" --clobber
sha256="$(cat "${final_zip}.sha256")"
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${{ needs.plan.outputs.tag }}/${artifact}"
{
echo "artifact=${artifact}"
echo "sha256=${sha256}"
echo "url=${url}"
} >> "$GITHUB_OUTPUT"
- name: Upload app artifact copy
uses: actions/upload-artifact@v6
with:
name: clipmem-app-aarch64-apple-darwin
path: |
${{ runner.temp }}/clipmem-app-aarch64-apple-darwin.zip
${{ runner.temp }}/clipmem-app-aarch64-apple-darwin.zip.sha256
publish-homebrew-formula:
needs:
- plan
- host
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
GITHUB_USER: "axo bot"
GITHUB_EMAIL: "admin+bot@axo.dev"
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: true
repository: "tristanmanchester/homebrew-tap"
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ github.repository }}
ref: ${{ github.sha }}
path: clipmem
- name: Fetch homebrew formulae
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: Formula/
merge-multiple: true
- name: Commit formula files
shell: bash
run: |
git config --global user.name "${GITHUB_USER}"
git config --global user.email "${GITHUB_EMAIL}"
while IFS= read -r release; do
filename="$(jq '.artifacts[] | select(endswith(".rb"))' --raw-output <<<"$release")"
name="${filename%.rb}"
version="$(jq '.app_version' --raw-output <<<"$release")"
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
brew update
./clipmem/.github/scripts/fix-homebrew-formula.sh "Formula/${filename}"
# We avoid reformatting user-provided data such as the app description and homepage.
brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true
git add "Formula/${filename}"
git commit -m "${name} ${version}"
done < <(jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)' <<<"$PLAN")
git push
publish-homebrew-cask:
needs:
- plan
- build-menubar-app
- publish-homebrew-formula
runs-on: "ubuntu-22.04"
env:
GITHUB_USER: "clipmem release bot"
GITHUB_EMAIL: "admin+bot@axo.dev"
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
path: clipmem
- uses: actions/checkout@v6
with:
persist-credentials: true
repository: "tristanmanchester/homebrew-tap"
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Commit cask file
shell: bash
working-directory: homebrew-tap
run: |
git config --global user.name "${GITHUB_USER}"
git config --global user.email "${GITHUB_EMAIL}"
../clipmem/.github/scripts/write-menubar-cask.sh \
"Casks/clipmem-app.rb" \
"${{ needs.build-menubar-app.outputs.version }}" \
"${{ needs.build-menubar-app.outputs.sha256 }}" \
"${{ needs.build-menubar-app.outputs.url }}"
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
brew update
brew style --fix "Casks/clipmem-app.rb" || true
git add "Casks/clipmem-app.rb"
if git diff --cached --quiet; then
echo "No cask changes to publish."
exit 0
fi
git commit -m "clipmem-app ${{ needs.build-menubar-app.outputs.version }}"
git push
custom-publish-crate:
needs:
- plan
- host
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
uses: ./.github/workflows/publish-crate.yml
permissions:
"contents": "read"
"id-token": "write"
announce:
needs:
- plan
- host
- publish-homebrew-formula
- publish-homebrew-cask
- build-menubar-app
- custom-publish-crate
if: ${{ always() && needs.host.result == 'success' && (needs.build-menubar-app.result == 'skipped' || needs.build-menubar-app.result == 'success') && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.publish-homebrew-cask.result == 'skipped' || needs.publish-homebrew-cask.result == 'success') && (needs.custom-publish-crate.result == 'skipped' || needs.custom-publish-crate.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive