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
202 changes: 13 additions & 189 deletions .github/workflows/deploy-release.yml
Original file line number Diff line number Diff line change
@@ -1,201 +1,32 @@
name: Deploy Release
name: Build & Publish Binaries (reusable)

on:
push:
tags:
- "v*"
workflow_dispatch:
workflow_call:
inputs:
skip-tests:
description: "Skip tests before release"
ref:
description: "Tag ref to build (e.g. refs/tags/v2.5.2). Must already exist and point at HEAD."
required: true
type: string
dry-run:
description: "Dry run: create the (pre)release + binaries, but skip the Homebrew tap push and macOS notarization."
required: false
default: false
type: boolean
version:
description: "Version to release (optional, for manual releases)"
required: false
type: string

permissions:
contents: write
packages: write
id-token: write

jobs:
test:
name: Test before release
# Run tests for non-prerelease versions (v1.0.0) or when manually triggered without skip
if: |
(github.event_name == 'push' && !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-rc') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-alpha')) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.skip-tests == 'false')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
cache: true

- name: Download dependencies
run: go mod download

- name: Run tests
run: go test -race -shuffle=on ./...

smoke-test:
name: Smoke test before release
# Run smoke tests for non-prerelease versions or when manually triggered without skip
if: |
(github.event_name == 'push' && !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-rc') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-alpha')) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.skip-tests == 'false')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
cache: true

- name: Build CLI
run: |
# Strip 'v' prefix from tag to get version (e.g., v1.2.3 -> 1.2.3)
VERSION="${GITHUB_REF_NAME#v}"
COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)

go build -ldflags "\
-X github.com/cerebriumai/cerebrium/internal/version.Version=${VERSION} \
-X github.com/cerebriumai/cerebrium/internal/version.Commit=${COMMIT} \
-X github.com/cerebriumai/cerebrium/internal/version.BuildDate=${BUILD_DATE}" \
-o bin/cerebrium ./cmd/cerebrium

# Save build metadata for verification
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "COMMIT=${COMMIT}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV

- name: Add bin to PATH
run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH

- name: Create Cerebrium config
run: |
mkdir -p ~/.cerebrium
cat > ~/.cerebrium/config.yaml << EOF
project: p-12345678
EOF

- name: Run smoke tests
run: |
EXPECTED_OUTPUT="cerebrium ${VERSION} (commit: ${COMMIT}, built: ${BUILD_DATE})"

# Verify version output matches exactly
VERSION_OUTPUT=$(cerebrium version)
echo "Version output: $VERSION_OUTPUT"
echo "Expected: $EXPECTED_OUTPUT"
if [[ "$VERSION_OUTPUT" != "$EXPECTED_OUTPUT" ]]; then
echo "ERROR: Version output doesn't match expected"
exit 1
fi

# Verify projects current shows our configured project
PROJECT_OUTPUT=$(cerebrium projects current)
echo "Project output: $PROJECT_OUTPUT"
if [[ ! "$PROJECT_OUTPUT" =~ "p-12345678" ]]; then
echo "ERROR: Project output doesn't contain expected project ID"
exit 1
fi

pip-smoke-test:
name: Pip smoke test before release
# Run on all releases (including prereleases) since PyPI supports them
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.skip-tests == 'false')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
cache: true

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Build Go binary
run: |
# Strip 'v' prefix from tag to get version (e.g., v1.2.3 -> 1.2.3)
VERSION="${GITHUB_REF_NAME#v}"
COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)

go build -ldflags "\
-X github.com/cerebriumai/cerebrium/internal/version.Version=${VERSION} \
-X github.com/cerebriumai/cerebrium/internal/version.Commit=${COMMIT} \
-X github.com/cerebriumai/cerebrium/internal/version.BuildDate=${BUILD_DATE}" \
-o bin/cerebrium ./cmd/cerebrium

# Save build metadata for verification
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "COMMIT=${COMMIT}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV

- name: Pre-install Go binary for pip wrapper
run: |
# Extract VERSION from cerebrium_cli.py (GitHub/semver format)
PIP_VERSION=$(grep "^VERSION = " python-wrapper/cerebrium_cli.py | sed 's/VERSION = "\(.*\)"/\1/')
echo "Pip wrapper version: $PIP_VERSION"

# Install binary where pip wrapper expects it
mkdir -p ~/.cerebrium/bin
cp bin/cerebrium ~/.cerebrium/bin/cerebrium
chmod +x ~/.cerebrium/bin/cerebrium

# Write version file so pip wrapper doesn't try to download
echo "$PIP_VERSION" > ~/.cerebrium/bin/.version

- name: Build pip wheel
working-directory: python-wrapper
run: |
pip install build
python -m build

- name: Install pip wheel
working-directory: python-wrapper
run: pip install dist/cerebrium-*.whl

- name: Run pip smoke tests
run: |
EXPECTED_OUTPUT="cerebrium ${VERSION} (commit: ${COMMIT}, built: ${BUILD_DATE})"

# Verify cerebrium runs through pip wrapper with correct version
VERSION_OUTPUT=$(cerebrium version)
echo "Version output: $VERSION_OUTPUT"
echo "Expected: $EXPECTED_OUTPUT"
if [[ "$VERSION_OUTPUT" != "$EXPECTED_OUTPUT" ]]; then
echo "ERROR: Version output doesn't match expected"
exit 1
fi

goreleaser:
name: Release
needs: [test, smoke-test, pip-smoke-test]
# Always run if tests were skipped, otherwise wait for tests to pass
if: always() && (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.smoke-test.result == 'success' || needs.smoke-test.result == 'skipped') && (needs.pip-smoke-test.result == 'success' || needs.pip-smoke-test.result == 'skipped')
name: GoReleaser
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref }}
fetch-depth: 0

- name: Set up Go
Expand All @@ -204,20 +35,13 @@ jobs:
go-version: "1.25"
cache: true

# Setup certificates and keys for signing (only for tag pushes, not manual dispatch)
- name: Setup macOS signing certificates
if: github.event_name == 'push'
run: |
# Note: For GoReleaser's cross-platform notarization, certificates are passed
# as base64-encoded environment variables directly to GoReleaser.
# No need to decode them to files when using anchore/quill backend.
echo "✓ macOS signing credentials configured"

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
# Dry run still creates the (pre)release + binaries, but skips the
# public Homebrew tap push and the slow macOS notarization.
args: ${{ inputs.dry-run && 'release --clean --skip=homebrew,notarize' || 'release --clean' }}
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
GH_PAT: ${{ secrets.GH_PAT }}
Expand Down
116 changes: 89 additions & 27 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
name: Publish to PyPI
name: Publish to PyPI (reusable)

on:
release:
types: [published]
workflow_call:
inputs:
tag:
description: "Release tag (e.g. v2.5.2 or 2.5.2)."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the version should always have the v prefix

Suggested change
description: "Release tag (e.g. v2.5.2 or 2.5.2)."
description: "Release tag (e.g. v2.5.2)."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nvm, I see it sorts it out below

required: true
type: string
dry-run:
description: "Dry run: build the wheel and run twine check, but do not publish to PyPI."
required: false
default: false
type: boolean

permissions:
id-token: write # Required for OIDC trusted publishing
Expand All @@ -15,6 +24,47 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5

# Defence in depth: refuse to publish the wrapper unless the binaries it
# downloads at runtime exist on the release. The orchestrator already
# orders this after the build, so this should pass immediately — but it
# keeps this workflow safe to call in any order.
- name: Verify release binaries exist
if: ${{ !inputs.dry-run }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="v${TAG#v}"
REQUIRED=(
checksums.txt
cerebrium_cli_darwin_amd64.tar.gz
cerebrium_cli_darwin_arm64.tar.gz
cerebrium_cli_linux_amd64.tar.gz
cerebrium_cli_linux_arm64.tar.gz
cerebrium_cli_windows_amd64.zip
cerebrium_cli_windows_arm64.zip
)
DEADLINE=$(( SECONDS + 300 ))
while :; do
ASSETS="$(gh release view "$TAG" --json assets --jq '.assets[].name' 2>/dev/null || true)"
MISSING=()
for a in "${REQUIRED[@]}"; do
grep -qx "$a" <<<"$ASSETS" || MISSING+=("$a")
done
if [ ${#MISSING[@]} -eq 0 ]; then
echo "All release binaries present for $TAG."
break
fi
if [ "$SECONDS" -ge "$DEADLINE" ]; then
echo "ERROR: release $TAG is missing binaries: ${MISSING[*]}" >&2
echo "Refusing to publish the PyPI wrapper for a release without binaries." >&2
exit 1
fi
echo "Waiting for binaries (missing: ${MISSING[*]})..."
sleep 15
done

- name: Set up Python
uses: actions/setup-python@v6
with:
Expand All @@ -26,10 +76,12 @@ jobs:
pip install build twine

- name: Update version in Python package
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# Get version from tag (e.g., "v2.1.0" or "v2.1.0-beta.1")
GITHUB_VERSION="${{ github.event.release.tag_name }}"
GITHUB_VERSION="${GITHUB_VERSION#v}" # Remove 'v' prefix
GITHUB_VERSION="${TAG#v}" # Remove 'v' prefix

# Update VERSION in cerebrium_cli.py (stores GitHub/semver format)
sed -i "s/^VERSION = \".*\"/VERSION = \"$GITHUB_VERSION\"/" python-wrapper/cerebrium_cli.py
Expand Down Expand Up @@ -58,13 +110,22 @@ jobs:
twine check dist/*

- name: Publish to PyPI
if: ${{ !inputs.dry-run }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python-wrapper/dist/
skip-existing: true

- name: Dry run — skipping PyPI publish
if: ${{ inputs.dry-run }}
run: |
echo "dry-run: built wheel and ran twine check; not publishing to PyPI."

- name: Test installation from PyPI
if: ${{ !inputs.dry-run }}
run: |
set -euo pipefail

# Get the PEP 440 version for pip install
cd python-wrapper
PYPI_VERSION=$(python -c "from cerebrium_cli import __version__; print(__version__)")
Expand All @@ -74,31 +135,32 @@ jobs:
python -m venv test_env
source test_env/bin/activate

# Retry installation with exponential backoff
# Retry installation with exponential backoff (PyPI propagation lag)
MAX_ATTEMPTS=5
ATTEMPT=1
DELAY=30

while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
installed=false
for ATTEMPT in $(seq 1 "$MAX_ATTEMPTS"); do
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS..."

if pip install cerebrium==$PYPI_VERSION; then
echo "Successfully installed cerebrium $PYPI_VERSION from PyPI"

# Test that the wrapper runs (will download binary on first run)
echo "Testing cerebrium command..."
cerebrium version && echo "Binary download and execution successful!"
exit 0
else
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
echo "Package not yet available on PyPI. Waiting ${DELAY}s before retry..."
sleep $DELAY
DELAY=$((DELAY * 2)) # Exponential backoff
fi
if pip install "cerebrium==$PYPI_VERSION"; then
installed=true
break
fi
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
echo "Package not yet available on PyPI. Waiting ${DELAY}s before retry..."
sleep "$DELAY"
DELAY=$((DELAY * 2)) # Exponential backoff
fi

ATTEMPT=$((ATTEMPT + 1))
done

echo "ERROR: Failed to install cerebrium $PYPI_VERSION from PyPI after $MAX_ATTEMPTS attempts"
exit 1
if [ "$installed" != true ]; then
echo "ERROR: Failed to install cerebrium $PYPI_VERSION from PyPI after $MAX_ATTEMPTS attempts" >&2
exit 1
fi
echo "Successfully installed cerebrium $PYPI_VERSION from PyPI"

# Run the wrapper end-to-end: downloads the real binary + checksums
# from the GitHub release. A 404 / checksum failure MUST fail the job
# (set -e ensures it does — no swallowed exit code).
echo "Testing cerebrium command (downloads binary from release)..."
cerebrium version
echo "Binary download and execution successful!"
Loading
Loading