Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion clients/cli/.github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
tags:
- '*'

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -32,9 +35,68 @@ jobs:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.VERSION }}
- name: Upload built binaries
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
- name: Create draft GitHub Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # pin@v1
with:
draft: true
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

sign_and_notarize:
runs-on: macos-latest
needs: release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download built binaries
uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Sign CLI binaries
run: bash ./build/sign_and_notarize.sh
env:
SIGNING_CERTIFICATE: ${{ secrets.SIGNING_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
DIST_DIR: "dist"
NOTARIZATION_APPLE_ID: ${{ secrets.NOTARIZATION_APPLE_ID }}
NOTARIZATION_APP_PASSWORD: ${{ secrets.NOTARIZATION_APP_PASSWORD }}
NOTARIZATION_TEAM_ID: ${{ secrets.NOTARIZATION_TEAM_ID }}
- name: Upload signed binaries to Draft Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # pin@v1
with:
files: |
dist/phrase_macosx_*.zip
dist/*.tar.gz
dist/phrase_windows_*.exe.zip
dist/phrase_windows_*.exe
fail_on_unmatched_files: true
overwrite: true
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish GitHub Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # pin@v1
with:
draft: false
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
brew:
runs-on: ubuntu-latest
needs: release
needs: sign_and_notarize
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
50 changes: 1 addition & 49 deletions clients/cli/build/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,52 +25,4 @@ cp dist/phrase_linux_arm64 dist/linux/arm64

docker buildx build --tag "${IMAGE}" --tag ${IMAGE_LATEST} --platform linux/amd64,linux/arm64 -f ./Dockerfile --push .

# Create release
function create_release_data()
{
cat <<EOF
{
"tag_name": "${VERSION}",
"name": "${VERSION}",
"draft": true,
"prerelease": false,
"body": "https://github.com/phrase/phrase-cli/blob/master/CHANGELOG.md"
}
EOF
}

echo "Create release $VERSION"
api_url="https://api.github.com/repos/phrase/phrase-cli/releases"
response="$(curl -H "Authorization: token ${GITHUB_TOKEN}" --data "$(create_release_data)" ${api_url})"
release_id=$(echo $response | python -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")

if [ -z "$release_id" ]
then
echo "Failed to create GitHub release"
echo $response
exit 1
else
echo "New release created created with id: ${release_id}"
fi

# Upload artifacts
DIST_DIR="./dist"
for file in "$DIST_DIR"/*; do
if [ -f "$file" ]; then
echo "Uploading ${file}"
asset="https://uploads.github.com/repos/phrase/phrase-cli/releases/${release_id}/assets?name=$(basename "$file")"
curl -sS --data-binary @"$file" -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/octet-stream" $asset > /dev/null
echo Hash: $(sha256sum $file)
fi
done

echo "Publishing release"
curl \
--silent \
-X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/phrase/phrase-cli/releases/${release_id}" \
-d '{"draft": false}' > /dev/null

echo "Release successful"
echo "Artifacts built and ready in dist/ directory. GitHub Release creation handled in GitHub Actions workflow."
144 changes: 144 additions & 0 deletions clients/cli/build/sign_and_notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/bin/bash
set -euo pipefail
umask 077

CERTIFICATE_BASE64="${SIGNING_CERTIFICATE}"
P12_PASSWORD="${CERTIFICATE_PASSWORD}"
SIGNING_IDENTITY="${SIGNING_IDENTITY}"
KEYCHAIN_PASSWORD="${KEYCHAIN_PASSWORD}"
DIST_DIR="${DIST_DIR:-dist}"

CERTIFICATE_PATH="$(pwd)/build_certificate.p12"
KEYCHAIN_PATH="$(pwd)/my-signing.keychain-db"

# Basic env validation to fail fast
require_env() {
local name="$1" value="$2"
if [[ -z "$value" ]]; then
echo "❌ Missing required environment variable: $name" >&2
exit 1
fi
}

require_env "SIGNING_CERTIFICATE" "${CERTIFICATE_BASE64}"
require_env "CERTIFICATE_PASSWORD" "${P12_PASSWORD}"
require_env "SIGNING_IDENTITY" "${SIGNING_IDENTITY}"
require_env "KEYCHAIN_PASSWORD" "${KEYCHAIN_PASSWORD}"
require_env "NOTARIZATION_APPLE_ID" "${NOTARIZATION_APPLE_ID:-}"
require_env "NOTARIZATION_APP_PASSWORD" "${NOTARIZATION_APP_PASSWORD:-}"
require_env "NOTARIZATION_TEAM_ID" "${NOTARIZATION_TEAM_ID:-}"


cleanup() {
echo "🧹 Cleaning up keychain and certificate..."
# Attempt to delete the temporary keychain
security delete-keychain "$KEYCHAIN_PATH" || true
# Remove certificate file
rm -f "$CERTIFICATE_PATH" || true
}
trap cleanup EXIT

echo "🔐 Setting up certificate and keychain..."

# Decode the certificate (macOS-only)
echo "$CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$CERTIFICATE_PATH"
# Restrict permissions on sensitive certificate material
chmod 600 "$CERTIFICATE_PATH"

# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

# Add keychain to the search list (prepend to existing list)
# This is required for codesign to find the identity
EXISTING_KEYCHAINS=$(security list-keychains -d user | tr -d '"' | tr '\n' ' ')
security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS

# Import certificate into keychain
security import "$CERTIFICATE_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

# Set the custom keychain as the default for this session
security default-keychain -s "$KEYCHAIN_PATH"

# Debug: show keychain search list
echo "🔎 Keychain search list:"
security list-keychains -d user

# Show available signing identities for visibility
echo "🔎 Available signing identities (codesigning):"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true

# Also check all keychains
echo "🔎 All available signing identities:"
security find-identity -v -p codesigning || true

# Extract the SHA-1 hash from the keychain for reliable signing
# This avoids issues with identity name matching
IDENTITY_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}')
if [[ -z "$IDENTITY_HASH" ]]; then
echo "❌ No Developer ID Application identity found in keychain" >&2
exit 1
fi
echo "🔑 Using identity hash: $IDENTITY_HASH"

# Find and sign all macOS binaries dynamically
echo "🔎 Searching for macOS binaries in $DIST_DIR..."

shopt -s nullglob
for binary in "$DIST_DIR"/phrase_macosx_*; do
[[ "$binary" == *.tar.gz ]] && continue
[[ ! -f "$binary" ]] && continue
echo "🔏 Signing $binary..."
codesign --force --timestamp --options runtime --keychain "$KEYCHAIN_PATH" --sign "$IDENTITY_HASH" "$binary"
codesign --verify --verbose=2 "$binary"
done

echo "✅ All macOS binaries signed successfully."

# --- Recreate tar.gz with signed binaries (for Homebrew) ---
echo "📦 Recreating tar.gz archives with signed binaries..."
for binary in "$DIST_DIR"/phrase_macosx_*; do
[[ "$binary" == *.tar.gz ]] && continue
[[ "$binary" == *.zip ]] && continue
[[ ! -f "$binary" ]] && continue
relbin="${binary#${DIST_DIR}/}"
# Remove old tar.gz if exists
rm -f "$DIST_DIR/${relbin}.tar.gz"
# Create new tar.gz with signed binary renamed to 'phrase'
echo "Creating $DIST_DIR/${relbin}.tar.gz with signed binary..."
(
cd "$DIST_DIR"
cp "$relbin" phrase
tar --create phrase | gzip -n > "${relbin}.tar.gz"
rm phrase
)
done

# --- Zip artifacts for notarization ---
echo "📦 Zipping macOS binaries for notarization..."
shopt -s nullglob
for bin in "$DIST_DIR"/phrase_macosx_*; do
[[ "$bin" == *.tar.gz ]] && continue
relbin="${bin#${DIST_DIR}/}"
echo "Creating $DIST_DIR/${relbin}.zip"
(
cd "$DIST_DIR" && /usr/bin/zip -o "${relbin}.zip" "${relbin}"
)
done

# --- Notarization via Apple notarytool (Apple ID + app-specific password) ---
echo "📝 Notarizing zipped binaries with Apple Notary (Apple ID)..."
for zip in "$DIST_DIR"/phrase_macosx_*.zip; do
[[ -e "$zip" ]] || continue
echo "Submitting $zip to Apple Notary..."
xcrun notarytool submit "$zip" \
--apple-id "$NOTARIZATION_APPLE_ID" \
--password "$NOTARIZATION_APP_PASSWORD" \
--team-id "$NOTARIZATION_TEAM_ID" \
--wait
echo "ℹ️ Notarization complete for $zip."
done

echo "🎉 Signing and notarization finished."
Loading