Skip to content

Release

Release #140

Workflow file for this run

name: Release
on:
workflow_call:
inputs:
mode:
type: string
required: true
stable_action:
type: string
required: true
source_ref:
type: string
required: false
default: main
qualification_run_id:
type: string
required: false
default: ""
dry_run:
type: boolean
required: false
default: false
outputs:
version:
value: ${{ jobs.prepare.outputs.version }}
workflow_dispatch:
inputs:
mode:
description: Publication channel
type: choice
default: stable
options: [stable, dev]
stable_action:
description: Prepare a version PR
type: choice
default: prepare
options: [prepare]
release_branch:
description: Linear-derived branch name for the version PR (prepare only)
type: string
default: ""
source_ref:
description: Exact 40-character source commit for dev npm packages
type: string
default: main
dry_run:
description: Validate without publishing
type: boolean
default: false
permissions:
contents: write
packages: write
id-token: write
env:
# Fall back to local compilation when the shared sccache backend returns a
# transient error (e.g. webdav 403) instead of failing the whole build.
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
jobs:
publish-npm-dev:
if: inputs.mode == 'dev'
uses: ./.github/workflows/publish-npm-dev.yml
with:
source_ref: ${{ inputs.source_ref }}
dry_run: ${{ inputs.dry_run }}
secrets: inherit
prepare:
if: inputs.mode == 'stable'
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
outputs:
version: ${{ steps.bump.outputs.version || steps.qualification_version.outputs.version || steps.merged_version.outputs.version }}
ready: ${{ steps.release_state.outputs.ready }}
source_ref: ${{ steps.release_source.outputs.ref }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
qualification_head_sha: ${{ steps.qualification.outputs.head_sha }}
release_commit: ${{ steps.merged_version.outputs.commit }}
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.REPO_ACCESS_TOKEN }}
fetch-depth: 0
- name: Compute next version
id: bump
if: ${{ inputs.stable_action == 'prepare' || inputs.dry_run }}
run: |
LATEST_TAG=$(git tag --list "v[0-9]*" --sort=-v:refname | head -n1)
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG="v0.0.0"
fi
CURRENT_VERSION=${LATEST_TAG#v}
echo "Latest tag: $LATEST_TAG"
WORKSPACE_VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml)
if [ "$WORKSPACE_VERSION" != "$CURRENT_VERSION" ]; then
echo "::error::main already contains unreleased version v${WORKSPACE_VERSION}; run the stable publish action instead"
exit 1
fi
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "Bump type: patch"
echo "New version: v${VERSION}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Read qualification version
id: qualification_version
if: ${{ inputs.stable_action == 'qualification' }}
run: |
VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml)
LATEST_TAG=$(git tag --list "v[0-9]*" --sort=-v:refname | head -n1)
if [ "v${VERSION}" = "$LATEST_TAG" ]; then
echo "::error::Qualification requires an unreleased version commit"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate qualification package versions
if: ${{ inputs.stable_action == 'qualification' }}
env:
VERSION: ${{ steps.qualification_version.outputs.version }}
run: |
cargo metadata --format-version 1 --locked >/dev/null
for pkg in packages/core packages/commands packages/sdk packages/testing \
client-sdks/platform/typescript client-sdks/manager/typescript \
packages/bindings crates/alien-bindings-node \
packages/bindings/npm/darwin-arm64 packages/bindings/npm/darwin-x64 \
packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu; do
ACTUAL=$(node -p "require('./${pkg}/package.json').version")
if [ "$ACTUAL" != "$VERSION" ]; then
echo "::error::${pkg}/package.json has version ${ACTUAL}, expected ${VERSION}"
exit 1
fi
done
- name: Read merged release version
id: merged_version
if: ${{ inputs.stable_action == 'publish' && !inputs.dry_run }}
run: |
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "::error::Stable publishing must run from main"
exit 1
fi
VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml)
VERSION_TAG="v${VERSION}"
if git rev-parse --verify --quiet "refs/tags/${VERSION_TAG}" >/dev/null; then
RELEASE_COMMIT=$(git rev-list -n1 "$VERSION_TAG")
else
LATEST_TAG=$(git tag --list "v[0-9]*" --sort=-v:refname | head -n1)
RELEASE_COMMIT=""
while read -r COMMIT; do
COMMIT_VERSION=$(git show "${COMMIT}:Cargo.toml" | sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p')
if [ "$COMMIT_VERSION" = "$VERSION" ]; then
RELEASE_COMMIT="$COMMIT"
break
fi
done < <(git rev-list --reverse "${LATEST_TAG}..HEAD")
fi
if [ -z "$RELEASE_COMMIT" ]; then
echo "::error::Could not find the merged commit that introduced v${VERSION}"
exit 1
fi
COMMIT_VERSION=$(git show "${RELEASE_COMMIT}:Cargo.toml" |
sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p')
if [ "$COMMIT_VERSION" != "$VERSION" ]; then
echo "::error::${VERSION_TAG} does not point to a v${VERSION} source tree"
exit 1
fi
echo "Publishing merged release v${VERSION} from ${RELEASE_COMMIT}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "commit=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
- name: Set workspace Cargo version
if: ${{ inputs.stable_action == 'prepare' || inputs.dry_run }}
run: |
python3 - <<EOF
import re, os
v = os.environ['VERSION']
with open('Cargo.toml', 'r') as f:
content = f.read()
content = re.sub(r'(\[workspace\.package\]\nversion = )"[^"]+"', rf'\1"{v}"', content)
content = re.sub(r'(path = "[^"]+", version = )"[^"]+"', rf'\1"{v}"', content)
with open('Cargo.toml', 'w') as f:
f.write(content)
EOF
env:
VERSION: ${{ steps.bump.outputs.version }}
- name: Set JavaScript package versions
if: ${{ inputs.stable_action == 'prepare' || inputs.dry_run }}
run: |
VERSION="${{ steps.bump.outputs.version }}"
# The bindings addon ships as a wrapper + per-platform prebuild packages
# (crate manifest, wrapper, and each npm/<triple> skeleton) that must all
# carry the release version BEFORE the native build on the runners, so the
# loader embeds it and publish injects matching optionalDependencies.
for pkg in packages/core packages/commands packages/sdk packages/testing \
client-sdks/platform/typescript client-sdks/manager/typescript \
packages/bindings crates/alien-bindings-node \
packages/bindings/npm/darwin-arm64 packages/bindings/npm/darwin-x64 \
packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu \
packages/ai-gateway; do
node -e "
const fs = require('fs');
const path = '${pkg}/package.json';
const content = fs.readFileSync(path, 'utf8');
fs.writeFileSync(path, content.replace(/\"version\": \"[^\"]*\"/, '\"version\": \"${VERSION}\"'));
"
done
# Both generated SDKs publish a JSR manifest alongside package.json.
# Keep the two registries on the release version.
node -e "
const fs = require('fs');
for (const path of [
'client-sdks/platform/typescript/jsr.json',
'client-sdks/manager/typescript/jsr.json',
]) {
const content = fs.readFileSync(path, 'utf8');
fs.writeFileSync(path, content.replace(/\"version\": \"[^\"]*\"/, '\"version\": \"${VERSION}\"'));
}
"
# Stamp package metadata in generated npm locks when present.
node -e "
const fs = require('fs');
for (const sdk of ['platform', 'manager']) {
const lockPath = 'client-sdks/' + sdk + '/typescript/package-lock.json';
if (!fs.existsSync(lockPath)) continue;
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
lock.version = '${VERSION}';
lock.packages[''].version = '${VERSION}';
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\\n');
}
"
# Both checked-in generation workflows use the version from gen.yaml.
# Advance them with the release so later regeneration cannot reset manifests.
node -e "
const fs = require('fs');
for (const sdk of ['platform', 'manager']) {
const root = 'client-sdks/' + sdk + '/typescript/.speakeasy/';
const configPath = root + 'gen.yaml';
const config = fs.readFileSync(configPath, 'utf8');
const configVersionPattern = /(typescript:\\n version: )[^\\n]+/;
if (!configVersionPattern.test(config)) {
throw new Error(sdk + ' SDK version not found in gen.yaml');
}
const updatedConfig = config.replace(
configVersionPattern,
(_, prefix) => prefix + '${VERSION}',
);
fs.writeFileSync(configPath, updatedConfig);
const lockPath = root + 'gen.lock';
const lock = fs.readFileSync(lockPath, 'utf8');
const lockVersionPattern = /( releaseVersion: )[^\\n]+/;
if (!lockVersionPattern.test(lock)) {
throw new Error(sdk + ' SDK releaseVersion not found in gen.lock');
}
const updatedLock = lock.replace(
lockVersionPattern,
(_, prefix) => prefix + '${VERSION}',
);
fs.writeFileSync(lockPath, updatedLock);
}
"
- name: Regenerate Cargo lockfile
if: ${{ inputs.stable_action == 'prepare' || inputs.dry_run }}
run: cargo metadata --format-version 1 >/dev/null
# Dry-run build jobs start from the untagged commit, so carry the exact
# versioned manifests that a real release would commit into those fresh
# checkouts. This keeps the addon binary, wrapper, and platform packages
# on one computed version without mutating git or publishing anything.
- name: Upload versioned manifests for dry run
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@v6
with:
name: release-version-manifests
retention-days: 30
path: |
Cargo.toml
Cargo.lock
packages/core/package.json
packages/commands/package.json
packages/sdk/package.json
packages/testing/package.json
client-sdks/platform/typescript/package.json
client-sdks/platform/typescript/jsr.json
client-sdks/platform/typescript/package-lock.json
client-sdks/platform/typescript/.speakeasy/gen.yaml
client-sdks/platform/typescript/.speakeasy/gen.lock
client-sdks/manager/typescript/package.json
client-sdks/manager/typescript/jsr.json
client-sdks/manager/typescript/package-lock.json
client-sdks/manager/typescript/examples/package-lock.json
client-sdks/manager/typescript/.speakeasy/gen.yaml
client-sdks/manager/typescript/.speakeasy/gen.lock
packages/bindings/package.json
crates/alien-bindings-node/package.json
packages/bindings/npm/darwin-arm64/package.json
packages/bindings/npm/darwin-x64/package.json
packages/bindings/npm/linux-x64-gnu/package.json
packages/bindings/npm/linux-arm64-gnu/package.json
packages/ai-gateway/package.json
- name: Open release pull request
if: ${{ inputs.stable_action == 'prepare' && !inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
run: |
VERSION="${{ steps.bump.outputs.version }}"
if ! grep -qE '^[a-z0-9][a-z0-9._-]*/alien-[0-9]+-[a-z0-9]+(-[a-z0-9]+)*$' <<< "$RELEASE_BRANCH"; then
echo "::error::release_branch must be the Linear-derived branch for the release issue"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add --update
git commit -m "chore: release v${VERSION}"
BRANCH="$RELEASE_BRANCH"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
PR_URL=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url')
if [ -z "$PR_URL" ]; then
echo "::error::${BRANCH} exists without an open release PR"
exit 1
fi
echo "Release PR already exists: ${PR_URL}"
exit 0
fi
git switch -c "$BRANCH"
git push --set-upstream origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore: release v${VERSION}" \
--body "Version-only release PR generated by the stable release workflow. The **Release qualification** check builds and records the exact artifact set. After every required check passes and this merges, run the stable **publish** action from \`main\` to promote those qualified artifacts."
- name: Validate merged package versions
if: ${{ inputs.stable_action == 'publish' && !inputs.dry_run }}
env:
VERSION: ${{ steps.merged_version.outputs.version }}
run: |
git checkout --detach "${{ steps.merged_version.outputs.commit }}"
cargo metadata --format-version 1 --locked >/dev/null
for pkg in packages/core packages/commands packages/sdk packages/testing \
client-sdks/platform/typescript client-sdks/manager/typescript \
packages/bindings crates/alien-bindings-node \
packages/bindings/npm/darwin-arm64 packages/bindings/npm/darwin-x64 \
packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu; do
ACTUAL=$(node -p "require('./${pkg}/package.json').version")
if [ "$ACTUAL" != "$VERSION" ]; then
echo "::error::${pkg}/package.json has version ${ACTUAL}, expected ${VERSION}"
exit 1
fi
done
- name: Select same-run qualification artifacts
id: qualification
if: ${{ inputs.stable_action == 'publish' && !inputs.dry_run }}
env:
RELEASE_COMMIT: ${{ steps.merged_version.outputs.commit }}
QUALIFICATION_RUN_ID: ${{ inputs.qualification_run_id }}
run: |
if ! grep -qE '^[0-9]+$' <<< "$QUALIFICATION_RUN_ID"; then
echo "::error::Stable publication requires its orchestrator run ID"
exit 1
fi
echo "run_id=$QUALIFICATION_RUN_ID" >> "$GITHUB_OUTPUT"
echo "head_sha=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
- name: Select release source
id: release_source
run: |
if [ "${{ inputs.stable_action }}" = "qualification" ]; then
echo "ref=${{ inputs.source_ref }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${{ steps.merged_version.outputs.commit }}" >> "$GITHUB_OUTPUT"
fi
- name: Select downstream release state
id: release_state
run: |
if [ "${{ inputs.stable_action }}" = "qualification" ] || \
[ "${{ inputs.stable_action }}" = "publish" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
fi
# ─── Changelog generation ──────────────────────────────────────────
generate-changelog:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGELOG-release.md
- name: Upload changelog
uses: actions/upload-artifact@v6
with:
name: changelog
retention-days: 30
path: CHANGELOG-release.md
# ─── Registry publishing (crates.io + npm) ──────────────────────────
publish-crates:
needs: [prepare]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' }}
runs-on: depot-ubuntu-24.04-arm-4
timeout-minutes: 60
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- uses: dtolnay/rust-toolchain@nightly
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/".insteadOf "https://github.com/"
- uses: depot/setup-action@v1
- uses: mozilla-actions/sccache-action@v0.0.10
continue-on-error: true
with:
version: v0.16.0
- name: Install protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
repo-token: ${{ github.token }}
- name: Download qualified crates
if: ${{ inputs.stable_action == 'publish' }}
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-crates
path: qualified/crates
- name: Publish crates
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p qualified/crates
crates_io_user_agent="Alien release workflow (https://github.com/alienplatform/alien)"
wait_for_crates_io_index() {
local crate=$1 expected_checksum=$2 index_path published_checksum
local index_response index_status
case "${#crate}" in
1) index_path="1/${crate}" ;;
2) index_path="2/${crate}" ;;
3) index_path="3/${crate:0:1}/${crate}" ;;
*) index_path="${crate:0:2}/${crate:2:2}/${crate}" ;;
esac
index_response="crates-io-index-${crate}.json"
for attempt in $(seq 1 30); do
if ! index_status=$(curl \
--user-agent "$crates_io_user_agent" \
--silent --show-error \
--output "$index_response" \
--write-out '%{http_code}' \
"https://index.crates.io/${index_path}"); then
echo "crates.io index request failed for ${crate}; retrying"
sleep 10
continue
fi
if [ "$index_status" = "200" ]; then
if ! published_checksum=$(jq -s -e -r \
--arg version "$VERSION" \
'map(select(.vers == $version)) | last | .cksum // ""' \
"$index_response"); then
echo "crates.io index returned malformed data for ${crate}; retrying"
sleep 10
continue
fi
elif [ "$index_status" = "404" ]; then
published_checksum=""
else
echo "crates.io index returned HTTP ${index_status} for ${crate}; retrying"
sleep 10
continue
fi
if [ "$published_checksum" = "$expected_checksum" ]; then
echo "${crate}@${VERSION} is available in the crates.io index"
return 0
fi
if [ -n "$published_checksum" ]; then
echo "::error::${crate}@${VERSION} reached the index with a different checksum"
return 1
fi
echo "Waiting for ${crate}@${VERSION} index propagation (${attempt}/30)..."
sleep 10
done
echo "::error::Timed out waiting for ${crate}@${VERSION} in the crates.io index"
return 1
}
for crate in \
alien-error-derive \
alien-macros \
alien-error \
alien-core \
alien-client-core \
alien-aws-clients \
alien-gcp-clients \
alien-azure-clients \
alien-k8s-clients \
alien-platform-api \
alien-manager-api \
alien-client-config \
alien-permissions \
alien-bindings \
alien-worker-protocol \
alien-sdk \
alien-commands \
alien-commands-client; do
if [ "${{ inputs.stable_action }}" = "qualification" ]; then
echo "Recording package inputs for $crate..."
depot cargo package -p "$crate" --list |
LC_ALL=C sort > "qualified/crates/${crate}-${VERSION}.files"
continue
fi
depot cargo package -p "$crate" --list |
LC_ALL=C sort > "${crate}-${VERSION}.files"
if ! cmp -s \
"${crate}-${VERSION}.files" \
"qualified/crates/${crate}-${VERSION}.files"; then
echo "::error::${crate}@${VERSION} package inputs differ from qualification"
diff -u \
"qualified/crates/${crate}-${VERSION}.files" \
"${crate}-${VERSION}.files" || true
exit 1
fi
# Cargo validates normalized registry dependencies while assembling
# the archive. A dependent crate cannot be packaged until the
# qualified prerequisite version exists in crates.io, so final
# assembly happens here in dependency order.
echo "Packaging $crate..."
depot cargo package -p "$crate" --no-verify
package="target/package/${crate}-${VERSION}.crate"
checksum=$(sha256sum "$package" | awk '{print $1}')
metadata_response="crates-io-metadata-${crate}.json"
if ! metadata_status=$(curl \
--user-agent "$crates_io_user_agent" \
--silent --show-error \
--output "$metadata_response" \
--write-out '%{http_code}' \
"https://crates.io/api/v1/crates/${crate}/${VERSION}"); then
echo "::error::Unable to query crates.io for ${crate}@${VERSION}"
exit 1
fi
if [ "$metadata_status" = "200" ]; then
if ! published_checksum=$(jq -e -r '.version.checksum' \
"$metadata_response"); then
echo "::error::crates.io returned malformed metadata for ${crate}@${VERSION}"
exit 1
fi
elif [ "$metadata_status" = "404" ]; then
published_checksum=""
else
echo "::error::crates.io returned HTTP ${metadata_status} for ${crate}@${VERSION}"
exit 1
fi
if [ -n "$published_checksum" ]; then
if [ "$published_checksum" != "$checksum" ]; then
echo "::error::${crate}@${VERSION} exists with a different checksum"
exit 1
fi
echo "${crate}@${VERSION} already matches the qualified package"
wait_for_crates_io_index "$crate" "$checksum"
continue
fi
echo "Publishing $crate..."
depot cargo publish -p "$crate" --no-verify
wait_for_crates_io_index "$crate" "$checksum"
done
- name: Upload qualified crates
if: ${{ inputs.stable_action == 'qualification' }}
uses: actions/upload-artifact@v6
with:
name: qualified-crates
retention-days: 30
if-no-files-found: error
path: qualified/crates/
publish-npm:
needs: [prepare]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' }}
runs-on: depot-ubuntu-24.04-arm-4
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- uses: pnpm/action-setup@v6
with:
version: 10.11.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- uses: oven-sh/setup-bun@v2
if: ${{ inputs.stable_action == 'qualification' }}
with:
bun-version: "1.3.14"
- name: Validate npm auth
if: ${{ inputs.stable_action == 'publish' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm whoami
- name: Install dependencies
if: ${{ inputs.stable_action == 'qualification' }}
run: pnpm install --frozen-lockfile
- name: Build
if: ${{ inputs.stable_action == 'qualification' }}
run: pnpm build
env:
NODE_OPTIONS: "--max-old-space-size=8192"
- name: Download qualified npm packages
if: ${{ inputs.stable_action == 'publish' }}
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-npm
path: qualified/npm
- name: Publish npm packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ "${{ inputs.stable_action }}" = "qualification" ]; then
mkdir -p qualified/npm
for package in core commands sdk testing; do
pnpm --filter "@alienplatform/${package}" pack --pack-destination qualified/npm
done
exit 0
fi
publish_tarball() {
local tarball="$1" package="$2"
local integrity
integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
local published
published=$(npm view "${package}@${{ needs.prepare.outputs.version }}" dist.integrity 2>/dev/null || true)
if [ -n "$published" ]; then
if [ "$published" != "$integrity" ]; then
echo "::error::${package}@${{ needs.prepare.outputs.version }} exists with different contents"
exit 1
fi
echo "${package}@${{ needs.prepare.outputs.version }} already matches the qualified package"
else
npm publish "$tarball" --access public
fi
}
publish_tarball qualified/npm/alienplatform-core-${{ needs.prepare.outputs.version }}.tgz @alienplatform/core
publish_tarball qualified/npm/alienplatform-commands-${{ needs.prepare.outputs.version }}.tgz @alienplatform/commands
publish_tarball qualified/npm/alienplatform-sdk-${{ needs.prepare.outputs.version }}.tgz @alienplatform/sdk
publish_tarball qualified/npm/alienplatform-testing-${{ needs.prepare.outputs.version }}.tgz @alienplatform/testing
- name: Upload qualified npm packages
if: ${{ inputs.stable_action == 'qualification' }}
uses: actions/upload-artifact@v6
with:
name: qualified-npm
retention-days: 30
if-no-files-found: error
path: qualified/npm/
publish-client-sdks:
needs: [prepare]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' }}
runs-on: depot-ubuntu-24.04-arm-4
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Validate npm auth
if: ${{ inputs.stable_action == 'publish' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm whoami
- name: Build platform API SDK
if: ${{ inputs.stable_action == 'qualification' }}
working-directory: client-sdks/platform/typescript
env:
NODE_OPTIONS: "--max-old-space-size=8192"
run: |
npm install
npm run build
- name: Build manager API SDK
if: ${{ inputs.stable_action == 'qualification' }}
working-directory: client-sdks/manager/typescript
run: |
npm install
npm run build
- name: Pack qualified client SDKs
if: ${{ inputs.stable_action == 'qualification' }}
run: |
mkdir -p qualified/client-sdks
destination="$PWD/qualified/client-sdks"
(cd client-sdks/platform/typescript && npm pack --pack-destination "$destination")
(cd client-sdks/manager/typescript && npm pack --pack-destination "$destination")
- name: Upload qualified client SDKs
if: ${{ inputs.stable_action == 'qualification' }}
uses: actions/upload-artifact@v6
with:
name: qualified-client-sdks
retention-days: 30
if-no-files-found: error
path: qualified/client-sdks/
- name: Download qualified client SDKs
if: ${{ inputs.stable_action == 'publish' }}
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-client-sdks
path: qualified/client-sdks
- name: Publish qualified client SDKs
if: ${{ inputs.stable_action == 'publish' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
publish_tarball() {
local tarball="$1" package="$2"
local integrity
integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
local published
published=$(npm view "${package}@${VERSION}" dist.integrity 2>/dev/null || true)
if [ -n "$published" ]; then
if [ "$published" != "$integrity" ]; then
echo "::error::${package}@${VERSION} exists with different contents"
exit 1
fi
echo "${package}@${VERSION} already matches the qualified package"
else
npm publish "$tarball" --access public
fi
}
publish_tarball "qualified/client-sdks/alienplatform-platform-api-${VERSION}.tgz" "@alienplatform/platform-api"
publish_tarball "qualified/client-sdks/alienplatform-manager-api-${VERSION}.tgz" "@alienplatform/manager-api"
# ─── Native addon prebuilds (@alienplatform/bindings) ──────────────
#
# The bindings addon publishes as a thin wrapper plus per-platform prebuild
# packages (`@alienplatform/bindings-<triple>`) that carry a compiled `.node`,
# so `npm install @alienplatform/bindings` needs no Rust toolchain. Each triple
# builds on its native runner (darwin-x64 cross-builds on the arm mac — same
# toolchain via `--target`). Qualification records the four native artifacts,
# runs the consumer smoke tests, then packs the exact npm tarballs that the
# publish action later promotes.
build-addon:
needs: prepare
if: inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification'
strategy:
fail-fast: false
matrix:
include:
# gateway_target/gateway_artifact drive the alien-ai-gateway launcher
# binary built alongside the addon: darwin legs share the addon's
# target; linux legs build it statically against musl, so one binary
# (per arch) serves both the glibc and Alpine prebuild packages.
- runner: depot-macos-15
os: macos
target: aarch64-apple-darwin
triple: darwin-arm64
expect_arch: arm64
gateway_target: aarch64-apple-darwin
gateway_artifact: darwin-arm64
- runner: depot-macos-15
os: macos
target: x86_64-apple-darwin
triple: darwin-x64
expect_arch: x86_64
gateway_target: x86_64-apple-darwin
gateway_artifact: darwin-x64
- runner: depot-ubuntu-24.04-16
os: linux
target: x86_64-unknown-linux-gnu
triple: linux-x64-gnu
expect_arch: x86-64
gateway_target: x86_64-unknown-linux-musl
gateway_artifact: linux-x64
- runner: depot-ubuntu-24.04-arm-16
os: linux
target: aarch64-unknown-linux-gnu
triple: linux-arm64-gnu
expect_arch: aarch64
gateway_target: aarch64-unknown-linux-musl
gateway_artifact: linux-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Apply computed version for dry run
if: ${{ inputs.dry_run }}
uses: actions/download-artifact@v7
with:
name: release-version-manifests
path: .
- name: Validate bindings release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION"
- name: Validate ai-gateway release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION"
# Rust toolchain: Linux via the shared composite; macOS uses dtolnay +
# brew protoc directly (plain `napi build`, not `depot cargo`, so no
# Depot/sccache setup here). The addon depends on alien-bindings, whose
# build.rs needs protoc.
- name: Setup Rust (linux)
if: matrix.os == 'linux'
uses: ./.github/actions/setup-rust
with:
depot-project-id: ${{ vars.DEPOT_PROJECT_ID }}
repo-access-token: ${{ secrets.REPO_ACCESS_TOKEN }}
install-protoc: "true"
- uses: dtolnay/rust-toolchain@nightly
if: matrix.os == 'macos'
- name: Configure git credentials for cargo (macos)
if: matrix.os == 'macos'
run: git config --global url."https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/".insteadOf "https://github.com/"
- name: Install protoc (macos)
if: matrix.os == 'macos'
run: brew install protobuf
- name: Add cross target (darwin-x64)
if: matrix.target == 'x86_64-apple-darwin'
run: rustup target add x86_64-apple-darwin
- uses: pnpm/action-setup@v6
with:
version: 10.11.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build addon (${{ matrix.triple }})
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
pnpm --filter @alienplatform/bindings exec napi build \
--platform --release --target ${{ matrix.target }} \
--cwd "$GITHUB_WORKSPACE/crates/alien-bindings-node"
- name: Stage addon into platform package
run: |
cp "crates/alien-bindings-node/alien-bindings-node.${{ matrix.triple }}.node" \
"packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node"
# Closes the wrong-arch hole on the two legs the smoke step doesn't cover.
- name: Assert staged binary architecture
run: file "packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node" | grep -q "${{ matrix.expect_arch }}"
- name: Upload addon artifact
uses: actions/upload-artifact@v6
with:
name: addon-${{ matrix.triple }}
retention-days: 30
path: packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node
# The AI gateway ships as a standalone launcher binary, not an addon. It
# reuses this matrix (same runners/toolchains; it depends on
# alien-bindings' credential resolver, so protoc is already set up).
- name: Add gateway build target (${{ matrix.gateway_target }})
run: rustup target add ${{ matrix.gateway_target }}
- name: Build ai-gateway binary (${{ matrix.gateway_artifact }})
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
cargo build --release -p alien-ai-gateway --bin alien-ai-gateway \
--target ${{ matrix.gateway_target }}
- name: Assert ai-gateway binary architecture
run: file "target/${{ matrix.gateway_target }}/release/alien-ai-gateway" | grep -q "${{ matrix.expect_arch }}"
- name: Upload ai-gateway binary artifact
uses: actions/upload-artifact@v6
with:
name: ai-gateway-bin-${{ matrix.gateway_artifact }}
retention-days: 30
path: target/${{ matrix.gateway_target }}/release/alien-ai-gateway
# This job deliberately has no Rust compiler or protoc setup. It
# starts from the already-built artifacts and proves that a consumer whose
# only direct dependency is @alienplatform/bindings gets the right optional
# platform package. Native runners additionally exercise npm + Node and Bun
# installs with a real local-provider KV operation.
smoke-addon:
needs: [prepare, build-addon]
if: inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification'
strategy:
fail-fast: false
matrix:
include:
- runner: depot-macos-15
triple: darwin-arm64
execute: true
- runner: depot-macos-15
triple: darwin-x64
execute: false
- runner: depot-ubuntu-24.04-16
triple: linux-x64-gnu
execute: true
- runner: depot-ubuntu-24.04-arm-16
triple: linux-arm64-gnu
execute: true
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Apply computed version for dry run
if: ${{ inputs.dry_run }}
uses: actions/download-artifact@v7
with:
name: release-version-manifests
path: .
- name: Validate bindings release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION"
- name: Validate ai-gateway release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION"
- uses: pnpm/action-setup@v6
with:
version: 10.11.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- uses: oven-sh/setup-bun@v2
if: matrix.execute
with:
bun-version: "1.3.14"
- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Build packages used by addon smoke test
run: |
pnpm --filter @alienplatform/core build
pnpm --filter @alienplatform/bindings build
- name: Download staged addon (${{ matrix.triple }})
uses: actions/download-artifact@v7
with:
name: addon-${{ matrix.triple }}
path: packages/bindings/npm/${{ matrix.triple }}
- name: Verify wrapper-only prebuild install (${{ matrix.triple }})
env:
EXECUTE_NATIVE: ${{ matrix.execute }}
run: |
args=("${{ matrix.triple }}")
if [ "$EXECUTE_NATIVE" = "true" ]; then args+=(--execute); fi
bash scripts/smoke-addon-prebuild.sh "${args[@]}"
qualify-bindings:
needs: [prepare, smoke-addon, publish-npm]
# The wrapper pins @alienplatform/core, so qualify it only after the core
# package has been packed successfully and every native smoke test passes.
if: inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' && always() && needs.smoke-addon.result == 'success' && needs.publish-npm.result == 'success'
runs-on: depot-ubuntu-24.04-arm-4
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Apply computed version for dry run
if: ${{ inputs.dry_run }}
uses: actions/download-artifact@v7
with:
name: release-version-manifests
path: .
- name: Validate bindings release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION"
- name: Validate ai-gateway release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION"
- uses: pnpm/action-setup@v6
with:
version: 10.11.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download qualification addon artifacts
uses: actions/download-artifact@v7
with:
pattern: addon-*
path: ./addons
merge-multiple: false
- name: Stage addons into platform packages
run: |
for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do
cp "./addons/addon-${triple}/alien-bindings-node.${triple}.node" \
"packages/bindings/npm/${triple}/alien-bindings-node.${triple}.node"
done
- name: Build wrapper
run: |
pnpm --filter @alienplatform/core build
pnpm --filter @alienplatform/bindings build
- name: Pack platform prebuild packages
run: |
mkdir -p qualified/bindings
for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do
npm pack "packages/bindings/npm/${triple}" --pack-destination "$PWD/qualified/bindings"
done
# Inject exact-version optionalDependencies explicitly (see the script) —
# more deterministic and reviewable than `napi prepublish`.
- name: Inject optionalDependencies into the wrapper
run: node packages/bindings/scripts/inject-optional-deps.mjs
# pnpm, not npm: only pnpm rewrites the `workspace:` range on
# `@alienplatform/core` into the released version, and npm rejects a
# published package that still carries the protocol.
- name: Pack bindings wrapper
run: pnpm --filter @alienplatform/bindings pack --pack-destination "$PWD/qualified/bindings"
- name: Upload qualified bindings packages
uses: actions/upload-artifact@v6
with:
name: qualified-bindings
retention-days: 30
if-no-files-found: error
path: qualified/bindings/
publish-bindings:
needs: [prepare, publish-npm]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 15
steps:
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Validate npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm whoami
- name: Download qualified bindings packages
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-bindings
path: qualified/bindings
- name: Publish qualified bindings packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
publish_tarball() {
local tarball="$1" package="$2"
local integrity
integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
local published
published=$(npm view "${package}@${VERSION}" dist.integrity 2>/dev/null || true)
if [ -n "$published" ]; then
if [ "$published" != "$integrity" ]; then
echo "::error::${package}@${VERSION} exists with different contents"
exit 1
fi
echo "${package}@${VERSION} already matches the qualified package"
else
npm publish "$tarball" --access public
fi
}
for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do
publish_tarball \
"qualified/bindings/alienplatform-bindings-${triple}-${VERSION}.tgz" \
"@alienplatform/bindings-${triple}"
done
publish_tarball \
"qualified/bindings/alienplatform-bindings-${VERSION}.tgz" \
"@alienplatform/bindings"
qualify-ai-gateway:
needs: [prepare, build-addon, publish-npm]
# Same ordering rules as qualify-bindings: the wrapper pins
# `@alienplatform/core: ^<release>`, so it is qualified only after core packs.
if: inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' && always() && needs.build-addon.result == 'success' && needs.publish-npm.result == 'success'
runs-on: depot-ubuntu-24.04-arm-4
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Apply computed version for dry run
if: ${{ inputs.dry_run }}
uses: actions/download-artifact@v7
with:
name: release-version-manifests
path: .
- name: Validate ai-gateway release version lockstep
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION"
- uses: pnpm/action-setup@v6
with:
version: 10.11.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download ai-gateway binaries
uses: actions/download-artifact@v7
with:
pattern: ai-gateway-bin-*
path: ./gateway-bins
merge-multiple: false
- name: Generate prebuild manifests
run: node packages/ai-gateway/scripts/generate-prebuilds.mjs
# The linux binaries are static musl builds, so a triple's gnu and musl
# packages ship the same executable. Artifact download drops the execute
# bit; restore it so npm packs the binary runnable.
- name: Stage binaries into prebuild packages
run: |
for pair in darwin-arm64:darwin-arm64 darwin-x64:darwin-x64 \
linux-x64-gnu:linux-x64 linux-x64-musl:linux-x64 \
linux-arm64-gnu:linux-arm64 linux-arm64-musl:linux-arm64; do
triple="${pair%%:*}"
source="${pair##*:}"
cp "./gateway-bins/ai-gateway-bin-${source}/alien-ai-gateway" \
"packages/ai-gateway/npm/${triple}/alien-ai-gateway"
chmod 755 "packages/ai-gateway/npm/${triple}/alien-ai-gateway"
done
# Install no longer builds workspace packages, so the wrapper's tsc needs
# its core dependency built explicitly first.
- name: Build wrapper
run: |
pnpm --filter @alienplatform/core build
pnpm --filter @alienplatform/ai-gateway build
- name: Pack platform prebuild packages
run: |
mkdir -p qualified/ai-gateway
for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-x64-musl linux-arm64-gnu linux-arm64-musl; do
npm pack "packages/ai-gateway/npm/${triple}" --pack-destination "$PWD/qualified/ai-gateway"
done
# Pinned before the wrapper is packed, so the qualified tarball already
# carries the exact-version optionalDependencies it will publish with.
- name: Inject optionalDependencies into the wrapper
run: node packages/ai-gateway/scripts/inject-optional-deps.mjs
# pnpm, not npm: only pnpm rewrites the `workspace:` range on
# `@alienplatform/core` into the released version, and npm rejects a
# published package that still carries the protocol.
- name: Pack ai-gateway wrapper
run: pnpm --filter @alienplatform/ai-gateway pack --pack-destination "$PWD/qualified/ai-gateway"
- name: Upload qualified ai-gateway packages
uses: actions/upload-artifact@v6
with:
name: qualified-ai-gateway
retention-days: 30
if-no-files-found: error
path: qualified/ai-gateway/
publish-ai-gateway:
needs: [prepare, publish-npm]
# Skipped on a dry run: publishing is permanent and there is no qualification run to download from.
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' && !inputs.dry_run }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 15
steps:
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Validate npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm whoami
- name: Download qualified ai-gateway packages
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-ai-gateway
path: qualified/ai-gateway
# Prebuild packages publish FIRST so an install of the wrapper can never
# race ahead of the optionalDependencies it pins.
- name: Publish qualified ai-gateway packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
publish_tarball() {
local tarball="$1" package="$2"
local integrity
integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
local published
published=$(npm view "${package}@${VERSION}" dist.integrity 2>/dev/null || true)
if [ -n "$published" ]; then
if [ "$published" != "$integrity" ]; then
echo "::error::${package}@${VERSION} exists with different contents"
exit 1
fi
echo "${package}@${VERSION} already matches the qualified package"
else
npm publish "$tarball" --access public
fi
}
for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-x64-musl linux-arm64-gnu linux-arm64-musl; do
publish_tarball \
"qualified/ai-gateway/alienplatform-ai-gateway-${triple}-${VERSION}.tgz" \
"@alienplatform/ai-gateway-${triple}"
done
publish_tarball \
"qualified/ai-gateway/alienplatform-ai-gateway-${VERSION}.tgz" \
"@alienplatform/ai-gateway"
# ─── Binary builds (4 targets, all parallel) ───────────────────────
build-binaries-linux-x86_64:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-16
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Restore binaries from cache
id: binary-cache
uses: actions/cache@v5
with:
path: |
target/x86_64-unknown-linux-musl/release/alien
target/x86_64-unknown-linux-musl/release/alien-deploy
target/x86_64-unknown-linux-musl/release/alien-operator
target/x86_64-unknown-linux-musl/release/alien-worker-runtime
target/x86_64-unknown-linux-musl/release/alien-manager
key: release-linux-x86_64-v${{ needs.prepare.outputs.version }}
- uses: ./.github/actions/setup-rust
if: steps.binary-cache.outputs.cache-hit != 'true'
with:
depot-project-id: ${{ vars.DEPOT_PROJECT_ID }}
repo-access-token: ${{ secrets.REPO_ACCESS_TOKEN }}
targets: x86_64-unknown-linux-musl
install-protoc: "true"
- name: Build x86_64 linux binaries
if: steps.binary-cache.outputs.cache-hit != 'true'
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
depot cargo build --release \
-p alien-cli -p alien-deploy-cli -p alien-operator -p alien-worker-runtime -p alien-manager \
--target x86_64-unknown-linux-musl
- name: Stage binaries for upload
run: |
mkdir -p staged/x86_64-unknown-linux-musl
for binary in alien alien-deploy alien-operator alien-worker-runtime alien-manager; do
cp "target/x86_64-unknown-linux-musl/release/${binary}" "staged/x86_64-unknown-linux-musl/${binary}"
done
- name: Upload x86_64 linux binaries
uses: actions/upload-artifact@v6
with:
name: binaries-x86_64-unknown-linux-musl
retention-days: 30
path: staged/x86_64-unknown-linux-musl/
build-binaries-linux-aarch64:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-arm-16
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Restore binaries from cache
id: binary-cache
uses: actions/cache@v5
with:
path: |
target/aarch64-unknown-linux-musl/release/alien
target/aarch64-unknown-linux-musl/release/alien-deploy
target/aarch64-unknown-linux-musl/release/alien-operator
target/aarch64-unknown-linux-musl/release/alien-worker-runtime
target/aarch64-unknown-linux-musl/release/alien-manager
key: release-linux-aarch64-v${{ needs.prepare.outputs.version }}
- uses: ./.github/actions/setup-rust
if: steps.binary-cache.outputs.cache-hit != 'true'
with:
depot-project-id: ${{ vars.DEPOT_PROJECT_ID }}
repo-access-token: ${{ secrets.REPO_ACCESS_TOKEN }}
targets: aarch64-unknown-linux-musl
install-protoc: "true"
- name: Build aarch64 linux binaries
if: steps.binary-cache.outputs.cache-hit != 'true'
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
depot cargo build --release \
-p alien-cli -p alien-deploy-cli -p alien-operator -p alien-worker-runtime -p alien-manager \
--target aarch64-unknown-linux-musl
- name: Stage binaries for upload
run: |
mkdir -p staged/aarch64-unknown-linux-musl
for binary in alien alien-deploy alien-operator alien-worker-runtime alien-manager; do
cp "target/aarch64-unknown-linux-musl/release/${binary}" "staged/aarch64-unknown-linux-musl/${binary}"
done
- name: Upload aarch64 linux binaries
uses: actions/upload-artifact@v6
with:
name: binaries-aarch64-unknown-linux-musl
retention-days: 30
path: staged/aarch64-unknown-linux-musl/
# ─── macOS build (aarch64 only, native) ────────────────────────────
build-binaries-darwin:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-macos-15
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Restore binaries from cache
id: binary-cache
uses: actions/cache@v5
with:
path: |
target/aarch64-apple-darwin/release/alien
target/aarch64-apple-darwin/release/alien-deploy
target/aarch64-apple-darwin/release/alien-operator
key: release-darwin-aarch64-v${{ needs.prepare.outputs.version }}
- uses: dtolnay/rust-toolchain@nightly
if: steps.binary-cache.outputs.cache-hit != 'true'
- name: Configure git credentials for cargo
if: steps.binary-cache.outputs.cache-hit != 'true'
run: |
git config --global url."https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/".insteadOf "https://github.com/"
- name: Setup Depot
if: steps.binary-cache.outputs.cache-hit != 'true'
uses: depot/setup-action@v1
- name: Install sccache
if: steps.binary-cache.outputs.cache-hit != 'true'
continue-on-error: true
uses: mozilla-actions/sccache-action@v0.0.10
with:
version: v0.16.0
- name: Install protoc
if: steps.binary-cache.outputs.cache-hit != 'true'
run: brew install protobuf
- name: Build aarch64 darwin binaries
if: steps.binary-cache.outputs.cache-hit != 'true'
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
depot cargo build --release \
-p alien-cli -p alien-deploy-cli -p alien-operator \
--target aarch64-apple-darwin
- name: Stage binaries for upload
run: |
mkdir -p staged/aarch64-apple-darwin
for binary in alien alien-deploy alien-operator; do
cp "target/aarch64-apple-darwin/release/${binary}" "staged/aarch64-apple-darwin/${binary}"
done
- name: Upload aarch64 darwin binaries
uses: actions/upload-artifact@v6
with:
name: binaries-aarch64-apple-darwin
retention-days: 30
path: staged/aarch64-apple-darwin/
# ─── Windows build (x86_64, native MSVC) ──────────────────────────
build-binaries-windows:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-windows-2025-16
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Restore binaries from cache
id: binary-cache
uses: actions/cache@v5
with:
path: |
target/x86_64-pc-windows-msvc/release/alien.exe
target/x86_64-pc-windows-msvc/release/alien-deploy.exe
target/x86_64-pc-windows-msvc/release/alien-operator.exe
key: release-windows-x86_64-v${{ needs.prepare.outputs.version }}
- uses: dtolnay/rust-toolchain@nightly
if: steps.binary-cache.outputs.cache-hit != 'true'
- name: Configure git credentials for cargo
if: steps.binary-cache.outputs.cache-hit != 'true'
shell: bash
run: |
git config --global url."https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/".insteadOf "https://github.com/"
# depot cargo is Linux/macOS only — use sccache directly on Windows
- name: Install sccache
if: steps.binary-cache.outputs.cache-hit != 'true'
continue-on-error: true
uses: mozilla-actions/sccache-action@v0.0.10
with:
version: v0.16.0
- name: Install build tools (protoc, nasm, cmake)
if: steps.binary-cache.outputs.cache-hit != 'true'
shell: bash
run: choco install protoc nasm cmake -y
- name: Build windows binaries
if: steps.binary-cache.outputs.cache-hit != 'true'
shell: bash
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
AWS_LC_SYS_CMAKE_BUILDER: "1"
run: |
export PATH="/c/Program Files/NASM:$PATH"
cargo build --release \
-p alien-cli -p alien-deploy-cli -p alien-operator \
--target x86_64-pc-windows-msvc
- name: Stage binaries for upload
shell: bash
run: |
mkdir -p staged/x86_64-pc-windows-msvc
cp target/x86_64-pc-windows-msvc/release/alien.exe staged/x86_64-pc-windows-msvc/
cp target/x86_64-pc-windows-msvc/release/alien-deploy.exe staged/x86_64-pc-windows-msvc/
cp target/x86_64-pc-windows-msvc/release/alien-operator.exe staged/x86_64-pc-windows-msvc/
- name: Upload windows binaries
uses: actions/upload-artifact@v6
with:
name: binaries-x86_64-pc-windows-msvc
retention-days: 30
path: staged/x86_64-pc-windows-msvc/
# ─── Upload binaries to S3 ──────────────────────────────────────────
upload-binaries:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
run-id: ${{ inputs.stable_action == 'publish' && needs.prepare.outputs.qualification_run_id || github.run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
pattern: binaries-*
path: ./artifacts
merge-multiple: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::216938125589:role/alien-releases-upload
aws-region: us-east-1
- name: Upload to S3 (versioned + latest)
run: |
VERSION="${{ needs.prepare.outputs.version }}"
# Artifacts are downloaded as: ./artifacts/binaries-<target>/<binary>
upload() {
local binary=$1 target=$2 os=$3 arch=$4
local src="./artifacts/binaries-${target}/${binary}"
local versioned="s3://alien-releases-prod/${binary}/v${VERSION}/${os}-${arch}/${binary}"
local checksum
checksum=$(sha256sum "$src" | awk '{print $1}')
local published
published=$(aws s3api head-object \
--bucket alien-releases-prod \
--key "${binary}/v${VERSION}/${os}-${arch}/${binary}" \
--query 'Metadata.sha256' --output text 2>/dev/null || true)
if [ -n "$published" ] && [ "$published" != "None" ] && [ "$published" != "$checksum" ]; then
echo "::error::${versioned} exists with different contents"
exit 1
fi
if [ "$published" != "$checksum" ]; then
aws s3 cp "$src" "$versioned" --metadata "sha256=${checksum}"
fi
}
for binary in alien alien-deploy alien-operator alien-worker-runtime alien-manager; do
upload "$binary" x86_64-unknown-linux-musl linux x86_64
upload "$binary" aarch64-unknown-linux-musl linux aarch64
done
for binary in alien alien-deploy alien-operator; do
upload "$binary" aarch64-apple-darwin darwin aarch64
done
upload_exe() {
local binary=$1 target=$2 os=$3 arch=$4
local src="./artifacts/binaries-${target}/${binary}.exe"
local versioned="s3://alien-releases-prod/${binary}/v${VERSION}/${os}-${arch}/${binary}.exe"
local checksum
checksum=$(sha256sum "$src" | awk '{print $1}')
local published
published=$(aws s3api head-object \
--bucket alien-releases-prod \
--key "${binary}/v${VERSION}/${os}-${arch}/${binary}.exe" \
--query 'Metadata.sha256' --output text 2>/dev/null || true)
if [ -n "$published" ] && [ "$published" != "None" ] && [ "$published" != "$checksum" ]; then
echo "::error::${versioned} exists with different contents"
exit 1
fi
if [ "$published" != "$checksum" ]; then
aws s3 cp "$src" "$versioned" --metadata "sha256=${checksum}"
fi
}
for binary in alien alien-deploy alien-operator; do
upload_exe "$binary" x86_64-pc-windows-msvc windows x86_64
done
# ─── GitHub Release ────────────────────────────────────────────────
qualify-github-release:
needs: [prepare, generate-changelog, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- name: Download all binary artifacts
uses: actions/download-artifact@v7
with:
pattern: binaries-*
path: ./artifacts
merge-multiple: false
- name: Download changelog
uses: actions/download-artifact@v7
with:
name: changelog
path: .
- name: Create release archives
run: |
VERSION="${{ needs.prepare.outputs.version }}"
mkdir -p dist
# Linux x86_64 tarball
tar -czf "dist/alien-v${VERSION}-x86_64-unknown-linux-musl.tar.gz" \
-C artifacts/binaries-x86_64-unknown-linux-musl \
alien alien-deploy alien-operator alien-worker-runtime alien-manager
# Linux aarch64 tarball
tar -czf "dist/alien-v${VERSION}-aarch64-unknown-linux-musl.tar.gz" \
-C artifacts/binaries-aarch64-unknown-linux-musl \
alien alien-deploy alien-operator alien-worker-runtime alien-manager
# macOS aarch64 tarball
tar -czf "dist/alien-v${VERSION}-aarch64-apple-darwin.tar.gz" \
-C artifacts/binaries-aarch64-apple-darwin \
alien alien-deploy alien-operator
# Windows x86_64 zip
(cd artifacts/binaries-x86_64-pc-windows-msvc && \
zip "../../dist/alien-v${VERSION}-x86_64-pc-windows-msvc.zip" \
alien.exe alien-deploy.exe alien-operator.exe)
- name: Generate checksums
run: |
VERSION="${{ needs.prepare.outputs.version }}"
cd dist
sha256sum alien-v${VERSION}-*.tar.gz alien-v${VERSION}-*.zip > "alien-v${VERSION}-checksums.txt"
- name: Upload qualified GitHub release payload
uses: actions/upload-artifact@v6
with:
name: qualified-github-release
retention-days: 30
if-no-files-found: error
path: |
CHANGELOG-release.md
dist/
# ─── Docker images + Helm chart ─────────────────────────────────────
qualify-images:
needs: [prepare, build-addon, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-arm-16
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- name: Download linux binaries
uses: actions/download-artifact@v7
with:
run-id: ${{ inputs.stable_action == 'publish' && needs.prepare.outputs.qualification_run_id || github.run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
pattern: binaries-*-unknown-linux-musl
path: ./linux-bins
merge-multiple: false
- name: Place binaries in target layout for Dockerfiles
run: |
for target in x86_64-unknown-linux-musl aarch64-unknown-linux-musl; do
mkdir -p "target/${target}/release"
for binary in alien-operator alien-worker-runtime alien-manager; do
cp "./linux-bins/binaries-${target}/${binary}" "target/${target}/release/${binary}"
chmod +x "target/${target}/release/${binary}"
done
done
- name: Download Linux AI gateway binaries
uses: actions/download-artifact@v7
with:
pattern: ai-gateway-bin-linux-*
path: ./gateway-bins
merge-multiple: false
- name: Place AI gateway binaries in target layout for Dockerfiles
run: |
cp ./gateway-bins/ai-gateway-bin-linux-x64/alien-ai-gateway \
target/x86_64-unknown-linux-musl/release/alien-ai-gateway
cp ./gateway-bins/ai-gateway-bin-linux-arm64/alien-ai-gateway \
target/aarch64-unknown-linux-musl/release/alien-ai-gateway
chmod +x \
target/x86_64-unknown-linux-musl/release/alien-ai-gateway \
target/aarch64-unknown-linux-musl/release/alien-ai-gateway
- name: Download Linux Worker addons
uses: actions/download-artifact@v7
with:
run-id: ${{ inputs.stable_action == 'publish' && needs.prepare.outputs.qualification_run_id || github.run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
pattern: addon-linux-*-gnu
path: ./linux-addons
merge-multiple: true
- name: Place Worker addons in Docker build context
run: |
cp ./linux-addons/alien-bindings-node.linux-x64-gnu.node \
crates/alien-bindings-node/alien-bindings-node.linux-x64-gnu.node
cp ./linux-addons/alien-bindings-node.linux-arm64-gnu.node \
crates/alien-bindings-node/alien-bindings-node.linux-arm64-gnu.node
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Depot
uses: depot/setup-action@v1
- name: Metadata (alien-base)
id: meta_base
uses: docker/metadata-action@v5
with:
images: ghcr.io/alienplatform/alien-base
tags: |
type=raw,value=qualification-${{ inputs.source_ref }}
- name: Publish alien-base
id: publish_base
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: docker/Dockerfile.alien-base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta_base.outputs.tags }}
labels: ${{ steps.meta_base.outputs.labels }}
provenance: true
sbom: true
- name: Metadata (alien-worker-typescript)
id: meta_worker_typescript
uses: docker/metadata-action@v5
with:
images: ghcr.io/alienplatform/alien-worker-typescript
tags: |
type=raw,value=qualification-${{ inputs.source_ref }}
- name: Publish alien-worker-typescript
id: publish_worker_typescript
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: docker/Dockerfile.alien-worker-typescript
build-args: |
ALIEN_WORKER_BASE=ghcr.io/alienplatform/alien-base@${{ steps.publish_base.outputs.digest }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta_worker_typescript.outputs.tags }}
labels: ${{ steps.meta_worker_typescript.outputs.labels }}
provenance: true
sbom: true
- name: Smoke-test Worker base images
env:
GENERIC_BASE: ghcr.io/alienplatform/alien-base@${{ steps.publish_base.outputs.digest }}
TYPESCRIPT_BASE: ghcr.io/alienplatform/alien-worker-typescript@${{ steps.publish_worker_typescript.outputs.digest }}
run: |
docker run --rm --platform linux/arm64 --entrypoint /bin/sh "$GENERIC_BASE" \
-c 'test ! -e /usr/local/bin/bun && test -x /app/alien-worker-runtime'
docker run --rm --platform linux/arm64 --entrypoint /usr/local/bin/bun "$TYPESCRIPT_BASE" \
-e 'const a=require("/app/alien-bindings.node"); if (!a.version()) throw new Error("native version() returned empty")'
- name: Metadata (alien-builder)
id: meta_builder
uses: docker/metadata-action@v5
with:
images: ghcr.io/alienplatform/alien-builder
tags: |
type=raw,value=qualification-${{ inputs.source_ref }}
- name: Publish alien-builder
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: docker/Dockerfile.alien-builder
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta_builder.outputs.tags }}
labels: ${{ steps.meta_builder.outputs.labels }}
provenance: true
sbom: true
- name: Metadata (alien-operator)
id: meta_agent
uses: docker/metadata-action@v5
with:
images: ghcr.io/alienplatform/alien-operator
tags: |
type=raw,value=qualification-${{ inputs.source_ref }}
- name: Publish alien-operator
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: docker/Dockerfile.alien-operator
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta_agent.outputs.tags }}
labels: ${{ steps.meta_agent.outputs.labels }}
provenance: true
sbom: true
- name: Metadata (alien-manager)
id: meta_manager
uses: docker/metadata-action@v5
with:
images: ghcr.io/alienplatform/alien-manager
tags: |
type=raw,value=qualification-${{ inputs.source_ref }}
- name: Publish alien-manager
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: docker/Dockerfile.alien-manager
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta_manager.outputs.tags }}
labels: ${{ steps.meta_manager.outputs.labels }}
provenance: true
sbom: true
- name: Record qualified image digests
env:
RELEASE_COMMIT: ${{ inputs.source_ref }}
run: |
DIGESTS='{}'
for name in alien-base alien-worker-typescript alien-builder alien-operator alien-manager; do
qualified="ghcr.io/alienplatform/${name}:qualification-${RELEASE_COMMIT}"
digest=$(docker buildx imagetools inspect "$qualified" |
sed -n 's/^Digest:[[:space:]]*//p' | head -n1)
if [ -z "$digest" ]; then
echo "::error::Qualified image ${qualified} has no manifest digest"
exit 1
fi
DIGESTS=$(jq --arg name "$name" --arg digest "$digest" \
'. + {($name): $digest}' <<< "$DIGESTS")
done
jq -n \
--arg releaseCommit "$RELEASE_COMMIT" \
--argjson images "$DIGESTS" \
'{releaseCommit: $releaseCommit, images: $images}' \
> qualified-image-digests.json
- name: Upload qualified image digests
uses: actions/upload-artifact@v6
with:
name: qualified-image-digests
retention-days: 30
if-no-files-found: error
path: qualified-image-digests.json
publish-images:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download qualified image digests
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-image-digests
path: qualification
- name: Promote qualified image manifests
env:
VERSION: ${{ needs.prepare.outputs.version }}
RELEASE_COMMIT: ${{ needs.prepare.outputs.qualification_head_sha }}
run: |
recorded_commit=$(jq -r '.releaseCommit' qualification/qualified-image-digests.json)
if [ "$recorded_commit" != "$RELEASE_COMMIT" ]; then
echo "::error::Qualified images belong to ${recorded_commit}, expected ${RELEASE_COMMIT}"
exit 1
fi
jq -r '.images | to_entries[] | [.key, .value] | @tsv' \
qualification/qualified-image-digests.json |
while IFS=$'\t' read -r name source_digest; do
image="ghcr.io/alienplatform/${name}"
if ! docker buildx imagetools inspect "${image}@${source_digest}" >/dev/null; then
echo "::error::Qualified image ${image}@${source_digest} is unavailable"
exit 1
fi
versioned="${image}:v${VERSION}"
published_digest=$(docker buildx imagetools inspect "$versioned" 2>/dev/null |
sed -n 's/^Digest:[[:space:]]*//p' | head -n1 || true)
if [ -n "$published_digest" ] && [ "$published_digest" != "$source_digest" ]; then
echo "::error::${versioned} exists with a different manifest"
exit 1
fi
docker buildx imagetools create \
--tag "$versioned" \
--tag "${image}:latest" \
"${image}@${source_digest}"
done
# Publish the GitHub release only after the registries and binary store have
# accepted their qualified artifacts. Homebrew follows because its formula
# references these release assets.
create-github-release:
needs:
- prepare
- publish-crates
- publish-npm
- publish-client-sdks
- publish-bindings
- publish-ai-gateway
- upload-binaries
- publish-images
- publish-npm-cli-wrapper
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.release_commit }}
fetch-depth: 0
- name: Download qualified GitHub release payload
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-github-release
path: .
- name: Require immutable GitHub release protections
env:
GH_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
run: |
enabled=$(gh api \
-H "X-GitHub-Api-Version: 2026-03-10" \
"/repos/${GITHUB_REPOSITORY}/immutable-releases" \
--jq '.enabled')
if [ "$enabled" != "true" ]; then
echo "::error::Immutable GitHub releases must be enabled"
exit 1
fi
ruleset_id=$(gh api --paginate \
-H "X-GitHub-Api-Version: 2026-03-10" \
"/repos/${GITHUB_REPOSITORY}/rulesets" \
--jq '.[] | select(
.name == "Immutable release tags" and
.target == "tag" and
.enforcement == "active"
) | .id' | head -n 1)
if [ -z "$ruleset_id" ]; then
echo "::error::The active Immutable release tags ruleset is required"
exit 1
fi
ruleset=$(gh api \
-H "X-GitHub-Api-Version: 2026-03-10" \
"/repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}")
if ! jq -e '
.conditions.ref_name.include == ["refs/tags/v*"] and
.conditions.ref_name.exclude == [] and
([.rules[].type] | index("deletion")) != null and
([.rules[].type] | index("update")) != null and
(.bypass_actors | length) == 0
' <<< "$ruleset" >/dev/null; then
echo "::error::Release tags must prohibit updates and deletion without bypasses"
exit 1
fi
- name: Verify existing release tag
id: verify_tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.prepare.outputs.version }}"
TAG="v${VERSION}"
COMMIT="${{ needs.prepare.outputs.release_commit }}"
FULL_REF="refs/tags/${TAG}"
MATCHING_REFS=$(gh api --paginate --slurp \
"/repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" |
jq -c --arg ref "$FULL_REF" '[add[] | select(.ref == $ref)]')
MATCH_COUNT=$(jq 'length' <<< "$MATCHING_REFS")
if [ "$MATCH_COUNT" = "0" ]; then
echo "${TAG} does not exist; GitHub Release creation will create it"
echo "exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$MATCH_COUNT" != "1" ]; then
echo "::error::Expected at most one exact ${FULL_REF}"
exit 1
fi
REF_JSON=$(jq -c '.[0]' <<< "$MATCHING_REFS")
TAG_TYPE=$(jq -r '.object.type' <<< "$REF_JSON")
TAG_COMMIT=$(jq -r '.object.sha' <<< "$REF_JSON")
if [ "$TAG_TYPE" = "tag" ]; then
TAG_COMMIT=$(gh api \
"/repos/${GITHUB_REPOSITORY}/git/tags/${TAG_COMMIT}" \
--jq '.object.sha')
elif [ "$TAG_TYPE" != "commit" ]; then
echo "::error::${TAG} points to unsupported Git object type ${TAG_TYPE}"
exit 1
fi
if [ "$TAG_COMMIT" != "$COMMIT" ]; then
echo "::error::${TAG} already points to ${TAG_COMMIT}, expected ${COMMIT}"
exit 1
fi
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "${TAG} points to the qualified release commit"
- name: Verify existing GitHub release assets
id: verify_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
releases=$(gh api --paginate --slurp \
"/repos/${GITHUB_REPOSITORY}/releases" |
jq -c --arg tag "v${VERSION}" '[add[] | select(.tag_name == $tag)]')
release_count=$(jq 'length' <<< "$releases")
if [ "$release_count" -gt 1 ]; then
echo "::error::Multiple GitHub releases use v${VERSION}"
exit 1
fi
if [ "$release_count" = "1" ]; then
release=$(jq -c '.[0]' <<< "$releases")
else
release='{"assets":[],"draft":true}'
fi
complete=true
for file in dist/*; do
name=$(basename "$file")
published=$(jq -r --arg name "$name" \
'.assets[] | select(.name == $name) | .digest // empty' <<< "$release")
if [ -z "$published" ]; then
complete=false
continue
fi
checksum="sha256:$(sha256sum "$file" | awk '{print $1}')"
if [ "$published" != "$checksum" ]; then
echo "::error::GitHub release asset ${name} exists with different contents"
exit 1
fi
done
if [ "$(jq -r '.draft' <<< "$release")" = "true" ]; then
complete=false
fi
echo "complete=${complete}" >> "$GITHUB_OUTPUT"
- name: Stage GitHub Release
id: stage_release
if: ${{ steps.verify_release.outputs.complete != 'true' }}
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: v${{ needs.prepare.outputs.version }}
target_commitish: ${{ needs.prepare.outputs.release_commit }}
name: Alien v${{ needs.prepare.outputs.version }}
body_path: CHANGELOG-release.md
files: |
dist/alien-v*-*.tar.gz
dist/alien-v*-*.zip
dist/alien-v*-checksums.txt
draft: true
prerelease: false
- name: Publish staged GitHub Release
if: ${{ steps.verify_release.outputs.complete != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.prepare.outputs.version }}"
TAG="v${VERSION}"
COMMIT="${{ needs.prepare.outputs.release_commit }}"
staged_release=$(gh api \
"/repos/${GITHUB_REPOSITORY}/releases/${{ steps.stage_release.outputs.id }}")
if ! jq -e \
--arg tag "$TAG" \
--arg commit "$COMMIT" \
'.draft == true and
.tag_name == $tag and
.target_commitish == $commit' \
<<< "$staged_release" >/dev/null; then
echo "::error::Staged release does not target the qualified commit"
exit 1
fi
published_release=$(gh api \
--method PATCH \
"/repos/${GITHUB_REPOSITORY}/releases/${{ steps.stage_release.outputs.id }}" \
--field draft=false \
--raw-field make_latest=true)
if ! jq -e '.draft == false and .immutable == true' \
<<< "$published_release" >/dev/null; then
echo "::error::GitHub did not publish an immutable release"
exit 1
fi
- name: Verify published release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.prepare.outputs.version }}"
TAG="v${VERSION}"
COMMIT="${{ needs.prepare.outputs.release_commit }}"
FULL_REF="refs/tags/${TAG}"
MATCHING_REFS=$(gh api --paginate --slurp \
"/repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" |
jq -c --arg ref "$FULL_REF" '[add[] | select(.ref == $ref)]')
if [ "$(jq 'length' <<< "$MATCHING_REFS")" != "1" ]; then
echo "::error::Expected exactly one ${FULL_REF} after release publication"
exit 1
fi
REF_JSON=$(jq -c '.[0]' <<< "$MATCHING_REFS")
TAG_TYPE=$(jq -r '.object.type' <<< "$REF_JSON")
TAG_COMMIT=$(jq -r '.object.sha' <<< "$REF_JSON")
if [ "$TAG_TYPE" = "tag" ]; then
TAG_COMMIT=$(gh api \
"/repos/${GITHUB_REPOSITORY}/git/tags/${TAG_COMMIT}" \
--jq '.object.sha')
elif [ "$TAG_TYPE" != "commit" ]; then
echo "::error::${TAG} points to unsupported Git object type ${TAG_TYPE}"
exit 1
fi
if [ "$TAG_COMMIT" != "$COMMIT" ]; then
echo "::error::${TAG} points to ${TAG_COMMIT}, expected ${COMMIT}"
exit 1
fi
echo "${TAG} points to the qualified release commit"
# ─── Homebrew tap ──────────────────────────────────────────────────
publish-homebrew-tap:
needs: [prepare, create-github-release]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- name: Download release checksums
run: |
VERSION="${{ needs.prepare.outputs.version }}"
gh release download "v${VERSION}" \
--repo "${{ github.repository }}" \
--pattern "alien-v${VERSION}-checksums.txt" \
--dir .
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Parse checksums
id: checksums
run: |
VERSION="${{ needs.prepare.outputs.version }}"
FILE="alien-v${VERSION}-checksums.txt"
{
echo "linux_x64=$(grep 'x86_64-unknown-linux-musl' "$FILE" | awk '{print $1}')"
echo "linux_arm64=$(grep 'aarch64-unknown-linux-musl' "$FILE" | awk '{print $1}')"
echo "darwin_arm64=$(grep 'aarch64-apple-darwin' "$FILE" | awk '{print $1}')"
} >> "$GITHUB_OUTPUT"
- name: Generate Homebrew formula
run: |
VERSION="${{ needs.prepare.outputs.version }}"
REPO="${{ github.repository }}"
cat > alien.rb << 'FORMULA_EOF'
class Alien < Formula
desc "Alien Developer Platform CLI"
homepage "https://alien.dev"
version "VERSION_PLACEHOLDER"
license "Apache-2.0"
on_macos do
on_arm do
url "https://github.com/REPO_PLACEHOLDER/releases/download/vVERSION_PLACEHOLDER/alien-vVERSION_PLACEHOLDER-aarch64-apple-darwin.tar.gz"
sha256 "SHA_DARWIN_ARM64_PLACEHOLDER"
end
end
on_linux do
on_intel do
url "https://github.com/REPO_PLACEHOLDER/releases/download/vVERSION_PLACEHOLDER/alien-vVERSION_PLACEHOLDER-x86_64-unknown-linux-musl.tar.gz"
sha256 "SHA_LINUX_X64_PLACEHOLDER"
end
on_arm do
url "https://github.com/REPO_PLACEHOLDER/releases/download/vVERSION_PLACEHOLDER/alien-vVERSION_PLACEHOLDER-aarch64-unknown-linux-musl.tar.gz"
sha256 "SHA_LINUX_ARM64_PLACEHOLDER"
end
end
def install
bin.install "alien"
bin.install "alien-deploy"
end
test do
assert_match version.to_s, shell_output("#{bin}/alien --version")
end
end
FORMULA_EOF
sed -i "s|VERSION_PLACEHOLDER|${VERSION}|g" alien.rb
sed -i "s|REPO_PLACEHOLDER|${REPO}|g" alien.rb
sed -i "s|SHA_LINUX_X64_PLACEHOLDER|${{ steps.checksums.outputs.linux_x64 }}|g" alien.rb
sed -i "s|SHA_LINUX_ARM64_PLACEHOLDER|${{ steps.checksums.outputs.linux_arm64 }}|g" alien.rb
sed -i "s|SHA_DARWIN_ARM64_PLACEHOLDER|${{ steps.checksums.outputs.darwin_arm64 }}|g" alien.rb
- name: Push formula to tap
run: |
git clone "https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/alienplatform/homebrew-tap.git" tap
mkdir -p tap/Formula
if [ -f tap/Formula/alien.rb ] &&
grep -q "version \"${{ needs.prepare.outputs.version }}\"" tap/Formula/alien.rb; then
if ! cmp -s alien.rb tap/Formula/alien.rb; then
echo "::error::Homebrew already has v${{ needs.prepare.outputs.version }} with different contents"
exit 1
fi
echo "Homebrew formula already matches v${{ needs.prepare.outputs.version }}"
exit 0
fi
cp alien.rb tap/Formula/alien.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/alien.rb
git diff --staged --quiet || git commit -m "Update alien to v${{ needs.prepare.outputs.version }}"
git push
# A release channel is one atomic pointer. Consumers resolve it before
# downloading immutable versioned binaries; independent latest object copies
# cannot provide an atomic release boundary.
publish-stable-channel:
needs: [prepare, publish-homebrew-tap]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 5
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::216938125589:role/alien-releases-upload
aws-region: us-east-1
- name: Publish stable channel
run: |
printf 'v%s\n' '${{ needs.prepare.outputs.version }}' > stable
published=$(aws s3 cp \
s3://alien-releases-prod/channels/stable - 2>/dev/null || true)
if [ "$published" = "$(cat stable)" ]; then
echo "Stable channel already points to ${published}"
exit 0
fi
aws s3 cp stable s3://alien-releases-prod/channels/stable \
--cache-control 'no-cache'
aws cloudfront create-invalidation \
--distribution-id ${{ vars.RELEASES_CLOUDFRONT_DISTRIBUTION_ID }} \
--paths '/channels/stable'
# ─── npm CLI wrapper ───────────────────────────────────────────────
qualify-npm-cli-wrapper:
needs: [prepare, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows]
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.source_ref }}
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Download all binary artifacts
uses: actions/download-artifact@v7
with:
pattern: binaries-*
path: ./artifacts
merge-multiple: false
- name: Build qualified npm CLI packages
env:
MODE: qualify
OUTPUT_DIR: ${{ github.workspace }}/qualified/cli
VERSION: ${{ needs.prepare.outputs.version }}
run: bash scripts/build-npm-packages.sh
- name: Upload qualified npm CLI packages
uses: actions/upload-artifact@v6
with:
name: qualified-npm-cli
retention-days: 30
if-no-files-found: error
path: qualified/cli/
publish-npm-cli-wrapper:
needs: prepare
if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' }}
runs-on: depot-ubuntu-24.04-arm
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.release_commit }}
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Validate npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm whoami
- name: Download qualified npm CLI packages
uses: actions/download-artifact@v7
with:
run-id: ${{ needs.prepare.outputs.qualification_run_id }}
github-token: ${{ secrets.REPO_ACCESS_TOKEN }}
name: qualified-npm-cli
path: qualified/cli
- name: Publish qualified npm CLI packages
env:
MODE: publish-existing
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
OUTPUT_DIR: ${{ github.workspace }}/qualified/cli
VERSION: ${{ needs.prepare.outputs.version }}
run: bash scripts/build-npm-packages.sh