Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: native binary distribution via homebrew #432

Merged
merged 6 commits into from
Sep 19, 2024
Merged
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
48 changes: 45 additions & 3 deletions .github/actions/build-binaries/macos/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,66 @@ inputs:
required: true
version:
description: "The version to use for this artifact"
apple_team_id:
description: "The Apple Team ID"
required: true
apple_bundle_id:
description: "The bundle ID to be used for packaging and notarisation"
required: true
apple_cert_id:
description: "The Apple Developer ID certificate ID"
required: true
apple_notary_user:
description: "The Apple user to notarise the package"
require: true
apple_notary_password:
description: "The Apple password to notarise the package"
require: true

runs:
using: "composite"
steps:
- name: Build binary
shell: bash
run: |
poetry run poe package_unix
export APPLE_CERT_ID="${{ inputs.apple_cert_id }}"
export APPLE_BUNDLE_ID="${{ inputs.apple_bundle_id }}"
poetry run poe package_mac
env:
APPLE_CERT_ID: ${{ inputs.apple_cert_id }}
APPLE_BUNDLE_ID: ${{ inputs.apple_bundle_id }}

- name: Add metadata to binary
shell: bash
run: |
echo brew > ${{ github.workspace }}/dist/algokit/_internal/algokit/resources/distribution-method

# Workaround an issue with PyInstaller where Python.framework was incorrectly signed during the build
- name: Codesign python.framework
shell: bash
run: |
codesign --force --sign "${{ inputs.apple_cert_id }}" --timestamp "${{ github.workspace }}/dist/algokit/_internal/Python.framework"

- name: Notarize
uses: lando/notarize-action@v2
with:
appstore-connect-team-id: ${{ inputs.apple_team_id }}
appstore-connect-username: ${{ inputs.apple_notary_user }}
appstore-connect-password: ${{ inputs.apple_notary_password }}
primary-bundle-id: ${{ inputs.apple_bundle_id }}
product-path: "${{ github.workspace }}/dist/algokit"
tool: notarytool
verbose: true

- name: Package binary artifact
shell: bash
run: |
cd dist/algokit/
tar -zcf ${{ inputs.artifacts_dir }}/${{ inputs.package_name }}.tar.gz *
tar -zcf ${{ inputs.artifacts_dir }}/${{ inputs.package_name }}-brew.tar.gz *
cd ../..

- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.package_name }}
path: ${{ inputs.artifacts_dir }}/${{ inputs.package_name }}.tar.gz
path: ${{ inputs.artifacts_dir }}/${{ inputs.package_name }}-brew.tar.gz
40 changes: 40 additions & 0 deletions .github/actions/install-apple-dev-id-cert/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: "Install Apple Developer ID certificate"
description: "Install Apple Developer ID certificate to macos-build keychain"
inputs:
cert_data:
description: "Base64 string represents the Apple developer ID certificate"
required: true
cert_password:
description: "The password to unlock the Apple developer ID certificate"
required: true

runs:
using: "composite"
steps:
- name: Install cert
shell: bash
env:
APPLE_CERT_DATA: ${{ inputs.cert_data }}
APPLE_CERT_PASSWORD: ${{ inputs.cert_password }}
run: |
# Export certs
echo "$APPLE_CERT_DATA" | base64 --decode > /tmp/certs.p12

# Create keychain
security create-keychain -p actions macos-build.keychain
security default-keychain -s macos-build.keychain
security unlock-keychain -p actions macos-build.keychain
security set-keychain-settings -t 3600 -u macos-build.keychain
echo "Keychain created"

# Import certs to keychain
security import /tmp/certs.p12 -k ~/Library/Keychains/macos-build.keychain -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productsign
echo "Cert imported"

# Key signing
security set-key-partition-list -S apple-tool:,apple: -s -k actions macos-build.keychain
echo "Key signed"

# Delete temp file
rm /tmp/certs.p12
echo "Done"
12 changes: 12 additions & 0 deletions .github/workflows/build-binaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,25 @@ jobs:
version: ${{ inputs.release_version }}
artifacts_dir: ${{ env.ARTIFACTS_DIR }}

- name: Install Apple Developer Id Cert
if: runner.os == 'macOS'
uses: ./.github/actions/install-apple-dev-id-cert
with:
cert_data: ${{ secrets.APPLE_CERT_DATA }}
cert_password: ${{ secrets.APPLE_CERT_PASSWORD }}

- name: Build macOS binary
if: ${{ runner.os == 'macOS' }}
uses: ./.github/actions/build-binaries/macos
with:
package_name: ${{ env.PACKAGE_NAME }}
version: ${{ inputs.release_version }}
artifacts_dir: ${{ env.ARTIFACTS_DIR }}
apple_team_id: ${{ secrets.APPLE_TEAM_ID }}
apple_bundle_id: ${{ inputs.production_release == 'true' && vars.APPLE_BUNDLE_ID || format('beta.{0}', vars.APPLE_BUNDLE_ID) }}
apple_cert_id: ${{ secrets.APPLE_CERT_ID }}
apple_notary_user: ${{ secrets.APPLE_NOTARY_USER }}
apple_notary_password: ${{ secrets.APPLE_NOTARY_PASSWORD }}

- name: Add binary to path
run: |
Expand Down
26 changes: 21 additions & 5 deletions .github/workflows/publish-release-packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Publish packages to public repositories
on:
workflow_call:
inputs:
artifactName:
wheelArtifactName:
required: true
type: string
description: "The github artifact holding the wheel file which will be published"
Expand Down Expand Up @@ -67,24 +67,40 @@ jobs:
uses: actions/checkout@v4

# Download either via release or provided artifact
- name: Download release
- name: Download wheel from release
if: ${{ github.event_name == 'workflow_dispatch' }}
run: gh release download v${{ inputs.release_version }} --pattern "*.whl" --dir dist
env:
GH_TOKEN: ${{ github.token }}

- name: Download artifact
- name: Download wheel from artifact
if: ${{ github.event_name == 'workflow_call' }}
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifactName }}
name: ${{ inputs.wheelArtifactName }}
path: dist

- name: Download macOS binary from release
if: ${{ github.event_name == 'workflow_dispatch' }}
run: gh release download ${{ inputs.release }} --pattern "*-brew.tar.gz" --dir dist
env:
GH_TOKEN: ${{ github.token }}

- name: Download macOS binary from artifact
uses: actions/download-artifact@v4
if: ${{ github.event_name == 'workflow_call' }}
with:
name: ${{ inputs.binaryArtifactName }}
path: dist

- name: Set Git user as GitHub actions
run: git config --global user.email "[email protected]" && git config --global user.name "github-actions"

- name: ls dist folder
run: ls -la dist

- name: Update homebrew cask
run: scripts/update-brew-cask.sh "dist/algokit*-py3-none-any.whl" "algorandfoundation/homebrew-tap"
run: scripts/update-brew-cask.sh "dist/algokit*-py3-none-any.whl" "dist/algokit*-macos_arm64-brew.tar.gz" "dist/algokit*-macos_x64-brew.tar.gz" "algorandfoundation/homebrew-tap"
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}

Expand Down
10 changes: 10 additions & 0 deletions entitlements.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ docs_title = {shell = "(echo \"# AlgoKit CLI Reference Documentation\\n\\n\"; ca
docs = ["docs_generate", "docs_toc", "docs_title"]
package_unix = "pyinstaller --clean --onedir --hidden-import jinja2_ansible_filters --hidden-import multiformats_config --copy-metadata algokit --name algokit --noconfirm src/algokit/__main__.py --add-data './misc/multiformats_config:multiformats_config/' --add-data './src/algokit/resources:algokit/resources/'"
package_windows = { cmd = "scripts/package_windows.bat" }
package_mac = "pyinstaller --clean --onedir --hidden-import jinja2_ansible_filters --hidden-import multiformats_config --copy-metadata algokit --name algokit --noconfirm src/algokit/__main__.py --add-data './misc/multiformats_config/multibase-table.json:multiformats_config/' --add-data './misc/multiformats_config/multicodec-table.json:multiformats_config/' --add-data './src/algokit/resources:algokit/resources/' --osx-bundle-identifier \"$APPLE_BUNDLE_ID\" --codesign-identity \"$APPLE_CERT_ID\" --osx-entitlements-file './entitlements.xml'"

[tool.ruff]
line-length = 120
lint.select = [
Expand Down
102 changes: 55 additions & 47 deletions scripts/update-brew-cask.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
#script arguments
wheel_files=( $1 )
wheel_file=${wheel_files[0]}
homebrew_tap_repo=$2
arm_artifacts=( $2 )
arm_artifact=${arm_artifacts[0]}
intel_artifacts=( $3 )
intel_artifact=${intel_artifacts[0]}
Comment on lines +6 to +9
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way to extract the artifact file name? The input is dist/algokit*-py3-none-any.whl

homebrew_tap_repo=$4

#globals
command=algokit

#error codes
MISSING_WHEEL=1
CASK_GENERATION_FAILED=2
PR_CREATION_FAILED=3
MISSING_EXECUTABLE=2
CASK_GENERATION_FAILED=3
PR_CREATION_FAILED=4

if [[ ! -f $wheel_file ]]; then
>&2 echo "$wheel_file not found. 🚫"
Expand All @@ -20,6 +25,21 @@ else
echo "Found $wheel_file 🎉"
fi

if [[ ! -f $arm_artifact ]]; then
>&2 echo "$arm_artifact not found. 🚫"
exit $MISSING_EXECUTABLE
else
echo "Found $arm_artifact 🎉"
fi

if [[ ! -f $intel_artifact ]]; then
>&2 echo "$intel_artifact not found. 🚫"
exit $MISSING_EXECUTABLE
else
echo "Found $intel_artifact 🎉"
fi


get_metadata() {
local field=$1
grep "^$field:" $metadata | cut -f 2 -d : | xargs
Expand All @@ -29,10 +49,10 @@ create_cask() {
repo="https://github.com/${GITHUB_REPOSITORY}"
homepage="$repo"

wheel=`basename $wheel_file`
echo "Creating brew cask from $wheel_file"
echo "Creating brew cask"

#determine package_name, version and release tag from .whl
# determine package_name, version and release tag from .whl
wheel=`basename $wheel_file`
package_name=`echo $wheel | cut -d- -f1`

version=None
Expand All @@ -50,78 +70,66 @@ create_cask() {
echo Version: $version
echo Release Tag: $release_tag

url="$repo/releases/download/$release_tag/$wheel"
#get other metadata from wheel
# get other metadata from wheel
unzip -o $wheel_file -d . >/dev/null 2>&1
metadata=`echo $wheel | cut -f 1,2 -d "-"`.dist-info/METADATA

desc=`get_metadata Summary`
license=`get_metadata License`

echo "Calculating sha256 of $url..."
sha256=`curl -s -L $url | sha256sum | cut -f 1 -d ' '`
arm_binary_url="$repo/releases/download/$release_tag/$(basename $arm_artifact)"
echo "Calculating sha256 of $arm_binary_url..."
arm_sha256=`curl -s -L $arm_binary_url | sha256sum | cut -f 1 -d ' '`

ruby=${command}.rb

echo "Outputting $ruby..."
intel_binary_url="$repo/releases/download/$release_tag/$(basename $intel_artifact)"
echo "Calculating sha256 of $intel_binary_url..."
intel_sha256=`curl -s -L $intel_binary_url | sha256sum | cut -f 1 -d ' '`

cat << EOF > $ruby
# typed: false
# frozen_string_literal: true
cask_file=${command}.rb
echo "Outputting $cask_file..."

cask "$command" do
cat << EOF > $cask_file
cask "$package_name" do
arch arm: "arm64", intel: "x64"
version "$version"
sha256 "$sha256"
sha256 arm: "$arm_sha256", intel: "$intel_sha256"

url "$repo/releases/download/v#{version}/algokit-#{version}-py3-none-any.whl"
name "$command"
url "$repo/releases/download/v#{version}/algokit-#{version}-macos_#{arch}.tar.gz"
name "$package_name"
desc "$desc"
homepage "$homepage"

depends_on formula: "pipx"
container type: :naked

installer script: {
executable: "pipx",
args: ["install", "--force", "#{staged_path}/algokit-#{version}-py3-none-any.whl"],
print_stderr: false,
}
installer script: {
executable: "pipx",
args: ["ensurepath"],
}
installer script: {
executable: "bash",
args: ["-c", "echo \$(which pipx) uninstall $package_name >#{staged_path}/uninstall.sh"],
}

uninstall script: {
executable: "bash",
args: ["#{staged_path}/uninstall.sh"],
}
binary "#{staged_path}/#{token}"

postflight do
set_permissions "#{staged_path}/#{token}", "0755"
end

uninstall delete: "/usr/local/bin/#{token}"
end
EOF

if [[ ! -f $ruby ]]; then
>&2 echo "Failed to generate $ruby 🚫"
if [[ ! -f $cask_file ]]; then
>&2 echo "Failed to generate $cask_file 🚫"
exit $CASK_GENERATION_FAILED
else
echo "Created $ruby 🎉"
echo "Created $cask_file 🎉"
fi
}

create_pr() {
local full_ruby=`realpath $ruby`
local full_cask_filepath=`realpath $cask_file`
echo "Cloning $homebrew_tap_repo..."
clone_dir=`mktemp -d`
git clone "https://oauth2:${TAP_GITHUB_TOKEN}@github.com/${homebrew_tap_repo}.git" $clone_dir

echo "Commiting Casks/$ruby..."
echo "Commiting Casks/$cask_file..."
pushd $clone_dir
dest_branch="$command-update-$version"
git checkout -b $dest_branch
mkdir -p $clone_dir/Casks
cp $full_ruby $clone_dir/Casks
cp $full_cask_filepath $clone_dir/Casks
message="Updating $command to $version"
git add .
git commit --message "$message"
Expand Down
Loading