Skip to content

Publish merged version PR #910 #509

Publish merged version PR #910

Publish merged version PR #910 #509

Workflow file for this run

name: Cut Release
run-name: >-
${{ github.event_name == 'workflow_dispatch' &&
format('Cut Release {0} {1} dry_run={2}', inputs.channel, inputs.ref, inputs.dry_run) ||
format('Publish merged version PR #{0}', github.event.pull_request.number) }}
on:
workflow_dispatch:
inputs:
channel:
description: Release channel
required: true
type: choice
default: stable
options:
- stable
- rc
ref:
description: Branch, tag, or SHA to release from
required: false
type: string
default: main
dry_run:
description: Compute the next version without tagging or publishing
required: false
type: boolean
default: false
pull_request:
branches:
- main
types:
- closed
permissions:
contents: read
pull-requests: read
concurrency:
group: release-cut
cancel-in-progress: false
env:
ALERA_UPDATE_BASE_URL: https://updates.alera.build
ALERA_R2_BUCKET: alera-updates
ALERA_STABLE_RELEASES_TO_KEEP: 5
ALERA_RC_RELEASES_TO_KEEP: 3
ALERA_FLAVOR: release
jobs:
plan:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
startsWith(github.event.pull_request.head.ref, 'release/version-'))
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
target_sha: ${{ steps.release_plan.outputs.target_sha }}
channel: ${{ steps.release_plan.outputs.channel }}
any_should_release: ${{ steps.release_plan.outputs.any_should_release }}
ready_to_publish: ${{ steps.release_plan.outputs.ready_to_publish }}
version_pr_needed: ${{ steps.release_plan.outputs.version_pr_needed }}
desktop_has_changes: ${{ steps.release_plan.outputs.desktop_has_changes }}
desktop_should_release: ${{ steps.release_plan.outputs.desktop_should_release }}
desktop_bump: ${{ steps.release_plan.outputs.desktop_bump }}
desktop_artifact_version: ${{ steps.release_plan.outputs.desktop_artifact_version }}
desktop_release_version: ${{ steps.release_plan.outputs.desktop_release_version }}
desktop_build_number: ${{ steps.release_plan.outputs.desktop_build_number }}
desktop_tag: ${{ steps.release_plan.outputs.desktop_tag }}
desktop_previous_tag: ${{ steps.release_plan.outputs.desktop_previous_tag }}
mobile_has_changes: ${{ steps.release_plan.outputs.mobile_has_changes }}
mobile_should_release: ${{ steps.release_plan.outputs.mobile_should_release }}
mobile_bump: ${{ steps.release_plan.outputs.mobile_bump }}
mobile_artifact_version: ${{ steps.release_plan.outputs.mobile_artifact_version }}
mobile_release_version: ${{ steps.release_plan.outputs.mobile_release_version }}
mobile_build_number: ${{ steps.release_plan.outputs.mobile_build_number }}
mobile_tag: ${{ steps.release_plan.outputs.mobile_tag }}
mobile_previous_tag: ${{ steps.release_plan.outputs.mobile_previous_tag }}
steps:
- name: Checkout ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
# release_plan.dart only reads git history and the pubspec files, so
# this job resolves no dependencies and needs no submodule.
submodules: false
- name: Setup Dart
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
- name: Plan product releases
id: release_plan
env:
GH_TOKEN: ${{ github.token }}
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.pull_request.merge_commit_sha }}
RELEASE_CHANNEL: ${{ inputs.channel }}
DRY_RUN: ${{ inputs.dry_run }}
EVENT_NAME: ${{ github.event_name }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
set -euo pipefail
git fetch origin main --tags --quiet
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
dart tool/release/release_plan.dart \
--target "$TARGET_REF" \
--channel "$RELEASE_CHANNEL" \
--repository "$GITHUB_REPOSITORY" \
--run-number "$GITHUB_RUN_NUMBER" \
--dry-run "$DRY_RUN" \
--github-output "$GITHUB_OUTPUT" \
--summary "$GITHUB_STEP_SUMMARY"
else
dart tool/release/prepared_release.dart inspect \
--file tool/release/prepared_release.json \
--target-sha "$TARGET_REF" \
--pr-title "$PR_TITLE" \
--repository "$GITHUB_REPOSITORY" \
--github-output "$GITHUB_OUTPUT" \
--summary "$GITHUB_STEP_SUMMARY"
prepared_channel="$(jq -r .channel tool/release/prepared_release.json)"
replan_output="$RUNNER_TEMP/replanned-release-output.txt"
replan_summary="$RUNNER_TEMP/replanned-release-summary.md"
while IFS= read -r prepared_tag; do
[[ -n "$prepared_tag" ]] || continue
git tag -d "$prepared_tag" >/dev/null 2>&1 || true
done < <(jq -r '.desktop.tag, .mobile.tag | select(length > 0)' tool/release/prepared_release.json)
dart tool/release/release_plan.dart \
--target "$TARGET_REF^" \
--channel "$prepared_channel" \
--repository "$GITHUB_REPOSITORY" \
--run-number "$GITHUB_RUN_NUMBER" \
--dry-run false \
--github-output "$replan_output" \
--summary "$replan_summary"
dart tool/release/prepared_release.dart compare-plan \
--file tool/release/prepared_release.json \
--plan-output "$replan_output"
fi
prepare_version_pr:
name: prepare version pull request
needs: plan
if: needs.plan.outputs.version_pr_needed == 'true'
permissions:
actions: write
contents: write
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout planned commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
fetch-depth: 0
submodules: false
- name: Setup Dart
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
- name: Create or reuse version pull request
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ needs.plan.outputs.target_sha }}
CHANNEL: ${{ needs.plan.outputs.channel }}
DESKTOP_SHOULD_RELEASE: ${{ needs.plan.outputs.desktop_should_release }}
DESKTOP_ARTIFACT_VERSION: ${{ needs.plan.outputs.desktop_artifact_version }}
DESKTOP_RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
DESKTOP_BUILD_NUMBER: ${{ needs.plan.outputs.desktop_build_number }}
DESKTOP_TAG: ${{ needs.plan.outputs.desktop_tag }}
DESKTOP_PREVIOUS_TAG: ${{ needs.plan.outputs.desktop_previous_tag }}
MOBILE_SHOULD_RELEASE: ${{ needs.plan.outputs.mobile_should_release }}
MOBILE_ARTIFACT_VERSION: ${{ needs.plan.outputs.mobile_artifact_version }}
MOBILE_RELEASE_VERSION: ${{ needs.plan.outputs.mobile_release_version }}
MOBILE_BUILD_NUMBER: ${{ needs.plan.outputs.mobile_build_number }}
MOBILE_TAG: ${{ needs.plan.outputs.mobile_tag }}
MOBILE_PREVIOUS_TAG: ${{ needs.plan.outputs.mobile_previous_tag }}
run: |
set -euo pipefail
git fetch origin main --tags --quiet
if [[ "$SOURCE_SHA" != "$(git rev-parse origin/main)" ]]; then
echo "::error::A non-dry release must start at the current origin/main commit."
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
prepared_output="$RUNNER_TEMP/prepared-release-output.txt"
dart tool/release/prepared_release.dart write \
--source-sha "$SOURCE_SHA" \
--channel "$CHANNEL" \
--desktop-should-release "$DESKTOP_SHOULD_RELEASE" \
--desktop-artifact-version "$DESKTOP_ARTIFACT_VERSION" \
--desktop-release-version "$DESKTOP_RELEASE_VERSION" \
--desktop-build-number "$DESKTOP_BUILD_NUMBER" \
--desktop-tag "$DESKTOP_TAG" \
--desktop-previous-tag "$DESKTOP_PREVIOUS_TAG" \
--mobile-should-release "$MOBILE_SHOULD_RELEASE" \
--mobile-artifact-version "$MOBILE_ARTIFACT_VERSION" \
--mobile-release-version "$MOBILE_RELEASE_VERSION" \
--mobile-build-number "$MOBILE_BUILD_NUMBER" \
--mobile-tag "$MOBILE_TAG" \
--mobile-previous-tag "$MOBILE_PREVIOUS_TAG" \
--output tool/release/prepared_release.json \
--github-output "$prepared_output"
VERSION_BRANCH="$(sed -n 's/^version_branch=//p' "$prepared_output")"
VERSION_TITLE="$(sed -n 's/^version_title=//p' "$prepared_output")"
if [[ "$DESKTOP_SHOULD_RELEASE" == "true" ]]; then
dart tool/release/update_pubspec_version.dart "$DESKTOP_ARTIFACT_VERSION" "$DESKTOP_BUILD_NUMBER"
fi
if [[ "$MOBILE_SHOULD_RELEASE" == "true" ]]; then
dart tool/release/update_mobile_pubspec_version.dart "$MOBILE_ARTIFACT_VERSION" "$MOBILE_BUILD_NUMBER"
fi
if [[ "$CHANNEL" == "stable" ]]; then
landing_args=()
if [[ "$DESKTOP_SHOULD_RELEASE" == "true" ]]; then
landing_args+=(--desktop-version "$DESKTOP_RELEASE_VERSION" --desktop-tag "$DESKTOP_TAG")
fi
if [[ "$MOBILE_SHOULD_RELEASE" == "true" ]]; then
landing_args+=(--mobile-version "$MOBILE_RELEASE_VERSION" --mobile-tag "$MOBILE_TAG")
fi
dart tool/release/update_landing_release_links.dart "${landing_args[@]}"
fi
git add tool/release/prepared_release.json pubspec.yaml mobile/pubspec.yaml landing/src/data/releases.json
git diff --cached --check
git commit -m "$VERSION_TITLE"
version_head="$(git rev-parse HEAD)"
existing_head="$(git ls-remote --heads origin "refs/heads/$VERSION_BRANCH" | awk '{print $1}')"
if [[ -n "$existing_head" ]]; then
git fetch origin "refs/heads/$VERSION_BRANCH:refs/remotes/origin/$VERSION_BRANCH" --quiet
if git show "origin/$VERSION_BRANCH:tool/release/prepared_release.json" >"$RUNNER_TEMP/existing-prepared-release.json" &&
dart tool/release/prepared_release.dart compare \
--file "$RUNNER_TEMP/existing-prepared-release.json" \
--source-sha "$SOURCE_SHA" \
--channel "$CHANNEL" \
--desktop-tag "$DESKTOP_TAG" \
--mobile-tag "$MOBILE_TAG" \
--treeish "origin/$VERSION_BRANCH" \
--github-output "$RUNNER_TEMP/existing-prepared-output.txt"; then
version_head="$existing_head"
else
git push --force-with-lease="refs/heads/$VERSION_BRANCH:$existing_head" \
origin "HEAD:refs/heads/$VERSION_BRANCH"
fi
else
git push origin "HEAD:refs/heads/$VERSION_BRANCH"
fi
mapfile -t open_pulls < <(gh pr list \
--repo "$GITHUB_REPOSITORY" \
--head "$VERSION_BRANCH" \
--state open \
--json number,state,mergedAt,headRefOid \
--jq '.[] | @base64')
if ((${#open_pulls[@]} > 1)); then
echo "::error::Multiple open pull requests use $VERSION_BRANCH."
exit 1
fi
if ((${#open_pulls[@]} == 1)); then
pull_json="$(printf '%s' "${open_pulls[0]}" | base64 --decode)"
remote_head="$(jq -r .headRefOid <<<"$pull_json")"
if [[ "$remote_head" != "$version_head" ]]; then
echo "::error::The existing version pull request has an unexpected head."
exit 1
fi
number="$(jq -r .number <<<"$pull_json")"
pr_url="https://github.com/$GITHUB_REPOSITORY/pull/$number"
else
body_file="$RUNNER_TEMP/version-pr-body.md"
{
echo "Automated version update for the Cut Release workflow."
echo
echo "- Source commit: \`$SOURCE_SHA\`"
echo "- Channel: \`$CHANNEL\`"
echo "- Tags: \`$DESKTOP_TAG $MOBILE_TAG\`"
echo
echo "Squash-merge this pull request after \`pr-ready\` passes. The merge event starts the Cut Release publish job."
} >"$body_file"
pr_url="$(gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$VERSION_BRANCH" \
--title "$VERSION_TITLE" \
--body-file "$body_file")"
fi
check_dispatches="$(gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow pr.yml \
--branch "$VERSION_BRANCH" \
--event workflow_dispatch \
--json headSha \
--jq "[.[] | select(.headSha == \"$version_head\")] | length")"
if [[ "$check_dispatches" == "0" ]]; then
gh workflow run pr.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$VERSION_BRANCH" \
-f base_sha="$SOURCE_SHA" \
-f head_sha="$version_head"
fi
if [[ "$CHANNEL" == "stable" ]]; then
landing_dispatches="$(gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow landing.yml \
--branch "$VERSION_BRANCH" \
--event workflow_dispatch \
--json headSha \
--jq "[.[] | select(.headSha == \"$version_head\")] | length")"
if [[ "$landing_dispatches" == "0" ]]; then
gh workflow run landing.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$VERSION_BRANCH"
fi
fi
echo "Version pull request: $pr_url" | tee -a "$GITHUB_STEP_SUMMARY"
build_desktop_app:
needs: plan
if: >-
needs.plan.outputs.ready_to_publish == 'true' &&
needs.plan.outputs.desktop_should_release == 'true'
name: build app ${{ matrix.platform }}
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
include:
- platform: macos
os: macos-latest
native_arch: arm64
- platform: windows
os: windows-latest
native_arch: x64
- platform: linux
os: ubuntu-latest
native_arch: x64
runs-on: ${{ matrix.os }}
env:
ALERA_SCCACHE_R2_ACCOUNT_ID: ${{ vars.SCCACHE_R2_ACCOUNT_ID }}
ALERA_SCCACHE_R2_BUCKET: ${{ vars.SCCACHE_R2_BUCKET }}
ALERA_SCCACHE_R2_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
ALERA_SCCACHE_R2_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Setup Flutter workspace
uses: ./.github/actions/setup-flutter-workspace
with:
rust: 'true'
extra-linux-packages: rpm
- name: Tune Windows build environment
if: runner.os == 'Windows'
uses: ./.github/actions/tune-windows-build
- name: Restore cargokit build
uses: actions/cache/restore@v6
with:
path: |
build/**/cargokit_build
build/**/ck
key: cargokit-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust/Cargo.lock') }}-
restore-keys: |
cargokit-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust/Cargo.lock') }}-
cargokit-${{ runner.os }}-${{ runner.arch }}-
- name: Apply release version locally
run: dart tool/release/update_pubspec_version.dart "${{ needs.plan.outputs.desktop_artifact_version }}" "${{ needs.plan.outputs.desktop_build_number }}"
- name: Build release bundle
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
CHANNEL: ${{ needs.plan.outputs.channel }}
BASE_URL: ${{ env.ALERA_UPDATE_BASE_URL }}
RELEASE_PAGE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ needs.plan.outputs.desktop_tag }}
ALERA_UPDATE_MANIFEST_PRIVATE_KEY: ${{ secrets.ALERA_UPDATE_MANIFEST_PRIVATE_KEY }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY: ${{ secrets.ALERA_UPDATE_MANIFEST_PUBLIC_KEY }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID: ${{ vars.ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID }}
ALERA_LINUX_GPG_PRIVATE_KEY_BASE64: ${{ secrets.ALERA_LINUX_GPG_PRIVATE_KEY_BASE64 }}
ALERA_LINUX_GPG_KEY_ID: ${{ secrets.ALERA_LINUX_GPG_KEY_ID }}
APPLE_DEVELOPER_ID_APPLICATION: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION }}
APPLE_DEVELOPER_ID_TEAM_ID: ${{ secrets.APPLE_DEVELOPER_ID_TEAM_ID }}
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
run: |
set -euo pipefail
runtime_archive_file="runtime-archive.json"
if [[ "$CHANNEL" == "rc" ]]; then
runtime_archive_file="runtime-archive-rc.json"
fi
for name in \
ALERA_UPDATE_MANIFEST_PRIVATE_KEY \
ALERA_UPDATE_MANIFEST_PUBLIC_KEY \
ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID; do
if [[ -z "${!name:-}" ]]; then
echo "::error::$name is required for signed release builds." >&2
exit 64
fi
done
archive_url="${BASE_URL}/updates/${CHANNEL}/app-archive.json"
update_base_url="${BASE_URL}/updates/${CHANNEL}"
runtime_archive_url="https://github.com/${{ github.repository }}/releases/download/${{ needs.plan.outputs.desktop_tag }}/${runtime_archive_file}"
# Auto-install is enabled on every platform. Whether it may run is
# decided at runtime, not per platform: Alera never replaces an
# installation a package manager owns, and on Linux it also refuses a
# directory it cannot write. A deb or rpm install is therefore still
# upgraded through apt or dnf, while a tarball install replaces its
# own directory, which runs no dpkg transaction and so needs no
# dependency resolution.
#
# It deliberately does not depend on Authenticode or Developer ID
# signing. Update integrity comes from the Ed25519-signed manifest,
# which carries a SHA-256 and a byte size for every artifact and is
# verified before anything is staged. Platform signing governs what
# the OS shows on first launch, not whether an update can be trusted
# in transit, so withholding auto-install until a certificate exists
# only left users on older builds.
auto_install_enabled=true
dart run desktop_updater:release publish \
--platform "$PLATFORM" \
--base-url "$update_base_url" \
--output "pages/updates/$CHANNEL" \
--channel "$CHANNEL" \
--version "${{ needs.plan.outputs.desktop_release_version }}" \
--build-number "${{ needs.plan.outputs.desktop_build_number }}" \
--package-id dev.leynier.alera \
--app-name Alera \
--dart-define="ALERA_FLAVOR=release" \
--dart-define="ALERA_UPDATE_CHANNEL=$CHANNEL" \
--dart-define="ALERA_UPDATE_ARCHIVE_URL=$archive_url" \
--dart-define="ALERA_RUNTIME_ARCHIVE_URL=$runtime_archive_url" \
--dart-define="ALERA_RUNTIME_VERSION=${{ needs.plan.outputs.desktop_release_version }}" \
--dart-define="ALERA_RELEASE_PAGE_URL=$RELEASE_PAGE_URL" \
--dart-define="ALERA_UPDATE_AUTO_INSTALL_ENABLED=$auto_install_enabled" \
--dart-define="ALERA_SIGNED_RELEASE=true" \
--dart-define="ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID=$ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID" \
--dart-define="ALERA_UPDATE_MANIFEST_PUBLIC_KEY=$ALERA_UPDATE_MANIFEST_PUBLIC_KEY"
- name: Prepare release artifact directories
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
CHANNEL: ${{ needs.plan.outputs.channel }}
run: |
set -euo pipefail
case "$PLATFORM" in
macos)
bundle_dir="build/macos/Build/Products/Release"
;;
windows)
bundle_dir="build/windows/x64/runner/Release"
;;
linux)
bundle_dir="build/linux/x64/release/bundle"
;;
*)
echo "::error::Unsupported desktop platform: $PLATFORM"
exit 64
;;
esac
if [[ ! -d "$bundle_dir" ]]; then
echo "::error::Missing release bundle $bundle_dir"
find build -maxdepth 6 -type d -print || true
exit 1
fi
index="pages/updates/$CHANNEL/app-archive.json"
test -s "$index" || { echo "::error::Missing updater index $index"; exit 1; }
mkdir -p pages/update-index-fragments release-assets
mv "$index" "pages/update-index-fragments/$PLATFORM.json"
rm -f "pages/updates/$CHANNEL/.desktop_updater_publish.json"
echo "BUNDLE_DIR=$bundle_dir" >>"$GITHUB_ENV"
- name: Stage native runtime input
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
NATIVE_ARCH: ${{ matrix.native_arch }}
run: |
set -euo pipefail
bin_name="alera"
if [[ "$PLATFORM" == "windows" ]]; then
bin_name="alera.exe"
fi
case "$PLATFORM" in
macos)
expected_runner_arch="ARM64"
runtime_root="$BUNDLE_DIR/Alera.app/Contents/Resources/alera"
;;
windows)
expected_runner_arch="X64"
runtime_root="$BUNDLE_DIR/resources/alera"
;;
linux)
expected_runner_arch="X64"
runtime_root="$BUNDLE_DIR/resources/alera"
;;
*)
echo "::error::Unsupported native runtime platform: $PLATFORM"
exit 64
;;
esac
if [[ "$RUNNER_ARCH" != "$expected_runner_arch" ]]; then
echo "::error::Expected $PLATFORM runner architecture $expected_runner_arch, got $RUNNER_ARCH."
exit 1
fi
test -s "$runtime_root/$bin_name" || {
echo "::error::Missing bundled runtime binary at $runtime_root/$bin_name"
exit 1
}
input_root="runtime-native-input/$PLATFORM/$NATIVE_ARCH"
mkdir -p "$input_root"
cp "$runtime_root/$bin_name" "$input_root/$bin_name"
tar -czf "runtime-native-${PLATFORM}-${NATIVE_ARCH}.tar.gz" \
-C runtime-native-input "$PLATFORM"
- name: Verify macOS ships arm64 only
if: matrix.platform == 'macos'
shell: bash
run: bash tool/release/verify_macos_arm64_only.sh "$BUNDLE_DIR/Alera.app"
- name: Package macOS release asset
if: matrix.platform == 'macos'
shell: bash
env:
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
set -euo pipefail
# Only the app, never the directory that contains it. BUNDLE_DIR is
# the Xcode products root on macOS (it is the runnable bundle itself
# on Windows and Linux), so archiving `.` shipped every dSYM, every
# per-pod build product and a loose copy of each framework alongside
# Alera.app: 450 MB published where the app is 271 MB.
# COPYFILE_DISABLE keeps macOS tar from adding its `._*` AppleDouble
# files for the xattrs Xcode leaves behind.
asset="release-assets/alera-${RELEASE_VERSION}-macos.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$asset" -C "$BUNDLE_DIR" Alera.app
roots="$(tar -tzf "$asset" | awk -F/ '{print $1}' | sort -u)"
if [[ "$roots" != "Alera.app" ]]; then
echo "::error::macOS asset must contain only Alera.app, got: $roots"
exit 1
fi
- name: Package Windows release assets
if: matrix.platform == 'windows'
shell: pwsh
env:
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
tar -czf "release-assets/alera-$env:RELEASE_VERSION-windows.tar.gz" -C "$env:BUNDLE_DIR" .
# Chocolatey and Scoop both unpack a zip natively.
Compress-Archive -Path "$env:BUNDLE_DIR\*" `
-DestinationPath "release-assets/alera-$env:RELEASE_VERSION-windows.zip"
- name: Package Linux release
if: matrix.platform == 'linux'
shell: bash
env:
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
ARTIFACT_VERSION: ${{ needs.plan.outputs.desktop_artifact_version }}
BUILD_NUMBER: ${{ needs.plan.outputs.desktop_build_number }}
run: |
set -euo pipefail
bash tool/release/package_linux.sh "$BUNDLE_DIR" release-assets "$RELEASE_VERSION" "$ARTIFACT_VERSION" "$BUILD_NUMBER"
# The unmanaged install path. Distributions outside the deb and rpm
# families have no package to install, and only a directory Alera
# owns can be replaced in place by the updater.
tar -czf "release-assets/alera-${RELEASE_VERSION}-linux-x64.tar.gz" \
-C "$BUNDLE_DIR" .
- name: Upload updater artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: updater-${{ matrix.platform }}
path: pages/
retention-days: 1
if-no-files-found: error
- name: Upload release asset artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: release-${{ matrix.platform }}
path: release-assets/
retention-days: 1
if-no-files-found: error
- name: Upload native runtime input
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: runtime-native-${{ matrix.platform }}-${{ matrix.native_arch }}
path: runtime-native-${{ matrix.platform }}-${{ matrix.native_arch }}.tar.gz
retention-days: 1
if-no-files-found: error
- name: Report sccache statistics
if: always()
shell: bash
run: |
set -euo pipefail
{
echo "### sccache ${{ matrix.platform }}"
echo '```text'
sccache --show-stats || true
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
build_runtime_cross:
needs: plan
if: >-
needs.plan.outputs.ready_to_publish == 'true' &&
needs.plan.outputs.desktop_should_release == 'true'
name: build runtime ${{ matrix.platform }} ${{ matrix.arch }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- platform: macos
arch: x64
target: x86_64-apple-darwin
os: macos-latest
binary: alera
- platform: windows
arch: arm64
target: aarch64-pc-windows-msvc
os: windows-latest
binary: alera.exe
- platform: linux
arch: arm64
target: aarch64-unknown-linux-gnu
os: ubuntu-latest
binary: alera
runs-on: ${{ matrix.os }}
env:
ALERA_SCCACHE_R2_ACCOUNT_ID: ${{ vars.SCCACHE_R2_ACCOUNT_ID }}
ALERA_SCCACHE_R2_BUCKET: ${{ vars.SCCACHE_R2_BUCKET }}
ALERA_SCCACHE_R2_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
ALERA_SCCACHE_R2_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Install Linux cross compiler
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Tune Windows build environment
if: runner.os == 'Windows'
uses: ./.github/actions/tune-windows-build
- name: Configure Windows ARM64 native toolchain
if: matrix.platform == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$installationPath = & $vswhere `
-latest `
-products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 `
-property installationPath
if (-not $installationPath) {
throw 'Visual Studio ARM64 build tools are unavailable.'
}
$devCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
$devEnvironment = & cmd.exe /d /s /c `
"`"$devCmd`" -no_logo -arch=arm64 -host_arch=x64 && set"
if ($LASTEXITCODE -ne 0) {
throw 'Failed to resolve the Visual Studio ARM64 environment.'
}
$environment = @{}
foreach ($line in $devEnvironment) {
$separator = $line.IndexOf('=')
if ($separator -gt 0) {
$environment[$line.Substring(0, $separator)] = $line.Substring($separator + 1)
}
}
foreach ($name in @('INCLUDE', 'LIB', 'LIBPATH')) {
if (-not $environment[$name]) {
throw "Visual Studio did not provide $name for ARM64."
}
"$name=$($environment[$name])" | Out-File `
-FilePath $env:GITHUB_ENV `
-Encoding utf8 `
-Append
}
$linker = Join-Path `
$environment['VCToolsInstallDir'] `
'bin\Hostx64\arm64\link.exe'
$ninja = Join-Path `
$installationPath `
'Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe'
$clangCl = (Get-Command clang-cl.exe -ErrorAction Stop).Source
foreach ($path in @($linker, $ninja, $clangCl)) {
if (-not (Test-Path $path -PathType Leaf)) {
throw "Required Windows ARM64 tool is unavailable: $path"
}
}
@(
"CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$linker"
'CMAKE_GENERATOR_aarch64_pc_windows_msvc=Ninja'
"CMAKE_MAKE_PROGRAM=$ninja"
"CC_aarch64_pc_windows_msvc=$clangCl"
"CXX_aarch64_pc_windows_msvc=$clangCl"
'CXXFLAGS_aarch64_pc_windows_msvc=-Xclang -fexceptions -Xclang -fcxx-exceptions'
) | Out-File `
-FilePath $env:GITHUB_ENV `
-Encoding utf8 `
-Append
- name: Setup Rust and sccache
uses: ./.github/actions/setup-rust-sccache
- name: Install Rust target
run: rustup target add "${{ matrix.target }}"
- name: Build cross-architecture runtime
shell: bash
env:
RUST_TARGET: ${{ matrix.target }}
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: |
set -euo pipefail
cargo build \
--manifest-path rust/Cargo.toml \
--locked \
-p alera-cli \
--release \
--target "$RUST_TARGET"
- name: Stage cross-architecture runtime
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
RUST_TARGET: ${{ matrix.target }}
BINARY_NAME: ${{ matrix.binary }}
run: |
set -euo pipefail
binary_path="rust/target/$RUST_TARGET/release/$BINARY_NAME"
test -s "$binary_path" || {
echo "::error::Missing cross-architecture runtime at $binary_path"
exit 1
}
input_root="runtime-cross-input/$PLATFORM/$ARCH"
mkdir -p "$input_root"
cp "$binary_path" "$input_root/$BINARY_NAME"
tar -czf "runtime-cross-${PLATFORM}-${ARCH}.tar.gz" \
-C runtime-cross-input "$PLATFORM"
- name: Upload cross-architecture runtime
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: runtime-cross-${{ matrix.platform }}-${{ matrix.arch }}
path: runtime-cross-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz
retention-days: 1
if-no-files-found: error
- name: Report sccache statistics
if: always()
shell: bash
run: |
set -euo pipefail
{
echo "### sccache ${{ matrix.platform }} ${{ matrix.arch }}"
echo '```text'
sccache --show-stats || true
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
package_runtime:
needs:
- plan
- build_desktop_app
- build_runtime_cross
if: >-
!cancelled() &&
needs.plan.outputs.ready_to_publish == 'true' &&
needs.plan.outputs.desktop_should_release == 'true' &&
needs.build_desktop_app.result == 'success' &&
needs.build_runtime_cross.result == 'success'
name: package runtimes
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Setup Dart
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
- name: Download runtime inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: runtime-*
path: runtime-input-artifacts
merge-multiple: true
- name: Extract runtime inputs
shell: bash
run: |
set -euo pipefail
mkdir -p runtime-inputs
mapfile -t archives < <(find runtime-input-artifacts -type f -name '*.tar.gz' -print | sort)
if [[ "${#archives[@]}" -ne 6 ]]; then
echo "::error::Expected 6 runtime input archives, found ${#archives[@]}."
printf '%s\n' "${archives[@]}"
exit 1
fi
for archive in "${archives[@]}"; do
tar -xzf "$archive" -C runtime-inputs
done
- name: Package standalone runtimes
working-directory: tool/release/runtime_packager
env:
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
set -euo pipefail
dart pub get
dart --packages=.dart_tool/package_config.json ../package_runtime_sidecars.dart \
--version "$RELEASE_VERSION" \
--input "$GITHUB_WORKSPACE/runtime-inputs" \
--output "$GITHUB_WORKSPACE/release-assets"
- name: Verify runtime checksums
working-directory: release-assets
run: sha256sum -c ./*.sha256
- name: Upload runtime release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: release-runtime
path: release-assets/
retention-days: 1
if-no-files-found: error
build_android:
name: build android
needs: plan
if: >-
needs.plan.outputs.ready_to_publish == 'true' &&
needs.plan.outputs.mobile_should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Setup Flutter workspace
uses: ./.github/actions/setup-flutter-workspace
with:
working-directory: mobile
submodules: third_party/xterm
linux-toolchain: 'false'
native-assets: 'false'
preflight: 'false'
- name: Apply mobile release version locally
run: dart tool/release/update_mobile_pubspec_version.dart "${{ needs.plan.outputs.mobile_artifact_version }}" "${{ needs.plan.outputs.mobile_build_number }}"
- name: Analyze mobile app
working-directory: mobile
run: flutter analyze
# flutter_soloud 5 compiles its native library in a build hook, and
# flutter test runs that hook for the Linux host, which needs ALSA headers.
- name: Install ALSA headers
run: sudo apt-get update && sudo apt-get install -y libasound2-dev
- name: Test mobile app
working-directory: mobile
run: flutter test
- name: Decode Android signing keystore
shell: bash
env:
ALERA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ALERA_ANDROID_KEYSTORE_BASE64 }}
ALERA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ALERA_ANDROID_KEYSTORE_PASSWORD }}
ALERA_ANDROID_KEY_ALIAS: ${{ secrets.ALERA_ANDROID_KEY_ALIAS }}
ALERA_ANDROID_KEY_PASSWORD: ${{ secrets.ALERA_ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
# Unlike desktop platforms, an Android APK signed with an ephemeral
# debug key silently breaks in-place updates for every installed user,
# so the release keystore is mandatory here, not warning-only.
for var in ALERA_ANDROID_KEYSTORE_BASE64 ALERA_ANDROID_KEYSTORE_PASSWORD ALERA_ANDROID_KEY_ALIAS ALERA_ANDROID_KEY_PASSWORD; do
if [[ -z "${!var:-}" ]]; then
echo "::error::$var is not configured. Android releases must be signed with the stable release keystore; see docs/release-trust.md."
exit 1
fi
done
keystore_path="$RUNNER_TEMP/alera-upload.keystore"
echo "$ALERA_ANDROID_KEYSTORE_BASE64" | base64 -d > "$keystore_path"
chmod 600 "$keystore_path"
cat > mobile/android/key.properties <<EOF
storePassword=$ALERA_ANDROID_KEYSTORE_PASSWORD
keyPassword=$ALERA_ANDROID_KEY_PASSWORD
keyAlias=$ALERA_ANDROID_KEY_ALIAS
storeFile=$keystore_path
EOF
echo "ALERA_ANDROID_KEYSTORE=$keystore_path" >> "$GITHUB_ENV"
- name: Build Android release APKs
working-directory: mobile
env:
ALERA_FIREBASE_API_KEY: ${{ vars.ALERA_FIREBASE_API_KEY }}
ALERA_FIREBASE_APP_ID: ${{ vars.ALERA_FIREBASE_APP_ID }}
ALERA_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.ALERA_FIREBASE_MESSAGING_SENDER_ID }}
ALERA_FIREBASE_PROJECT_ID: ${{ vars.ALERA_FIREBASE_PROJECT_ID }}
run: |
set -euo pipefail
for var in ALERA_FIREBASE_API_KEY ALERA_FIREBASE_APP_ID ALERA_FIREBASE_MESSAGING_SENDER_ID ALERA_FIREBASE_PROJECT_ID; do
if [[ -z "${!var:-}" ]]; then
echo "::error::$var is not configured. Android releases require the production Firebase client configuration; see docs/cloud-setup.md."
exit 1
fi
done
firebase_defines=(
--dart-define "ALERA_FIREBASE_API_KEY=$ALERA_FIREBASE_API_KEY"
--dart-define "ALERA_FIREBASE_APP_ID=$ALERA_FIREBASE_APP_ID"
--dart-define "ALERA_FIREBASE_MESSAGING_SENDER_ID=$ALERA_FIREBASE_MESSAGING_SENDER_ID"
--dart-define "ALERA_FIREBASE_PROJECT_ID=$ALERA_FIREBASE_PROJECT_ID"
)
# One arm64 APK. A fat APK that also embeds 32-bit libraries
# (ML Kit's barhopper is 4 KB aligned) fails to install on
# 16 KB page-size phones.
flutter build apk --release --target-platform android-arm64 -Pdisable-abi-filtering=true -PaleraAbiFilters=arm64-v8a "${firebase_defines[@]}"
- name: Verify Android native dependencies
shell: bash
run: bash tool/release/verify_android_native_dependencies.sh mobile/build/app/outputs/flutter-apk
- name: Verify Android APK signatures
shell: bash
run: |
set -euo pipefail
apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -1)"
for apk in mobile/build/app/outputs/flutter-apk/*-release.apk; do
"$apksigner" verify --print-certs "$apk" | tee /tmp/apk-cert.txt
grep -q "certificate SHA-256 digest" /tmp/apk-cert.txt
done
- name: Stage and verify Android release assets
shell: bash
env:
RELEASE_VERSION: ${{ needs.plan.outputs.mobile_release_version }}
run: |
set -euo pipefail
source_dir="mobile/build/app/outputs/flutter-apk"
mkdir -p mobile-release-assets
cp "$source_dir/app-release.apk" "mobile-release-assets/alera-${RELEASE_VERSION}-android.apk"
for asset in mobile-release-assets/*.apk; do
asset_name="$(basename "$asset")"
(cd mobile-release-assets && sha256sum "$asset_name" >"${asset_name}.sha256")
done
(cd mobile-release-assets && sha256sum -c ./*.sha256)
- name: Upload Android release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: mobile-android-release-assets
path: mobile-release-assets
if-no-files-found: error
retention-days: 7
publish:
needs:
- plan
- build_desktop_app
- package_runtime
- build_android
if: >-
!cancelled() &&
needs.plan.outputs.ready_to_publish == 'true' &&
needs.plan.outputs.any_should_release == 'true' &&
(needs.plan.outputs.desktop_should_release != 'true' ||
(needs.build_desktop_app.result == 'success' && needs.package_runtime.result == 'success')) &&
(needs.plan.outputs.mobile_should_release != 'true' || needs.build_android.result == 'success')
permissions:
contents: write
id-token: write
attestations: write
outputs:
desktop_assets_public: >-
${{ needs.plan.outputs.desktop_should_release != 'true' ||
(steps.publish_desktop_release.outcome == 'success' &&
steps.publish_update_archive_r2.outcome == 'success') }}
mobile_assets_public: >-
${{ needs.plan.outputs.mobile_should_release != 'true' ||
steps.publish_mobile_release.outcome == 'success' }}
landing_assets_ready: >-
${{ (needs.plan.outputs.desktop_should_release != 'true' ||
(steps.publish_desktop_release.outcome == 'success' &&
steps.publish_update_archive_r2.outcome == 'success')) &&
(needs.plan.outputs.mobile_should_release != 'true' ||
steps.publish_mobile_release.outcome == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
fetch-depth: 0
submodules: false
- name: Setup Dart
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
- name: Setup Flutter workspace
if: needs.plan.outputs.desktop_should_release == 'true'
uses: ./.github/actions/setup-flutter-workspace
with:
linux-toolchain: 'false'
native-assets: 'false'
preflight: 'false'
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Download updater artifacts
if: needs.plan.outputs.desktop_should_release == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: updater-*
path: public
merge-multiple: true
- name: Download release assets
if: needs.plan.outputs.desktop_should_release == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: release-*
path: release-assets
merge-multiple: true
- name: Download Android release assets
if: needs.plan.outputs.mobile_should_release == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: mobile-android-release-assets
path: mobile-release-assets
- name: Verify complete mobile release
if: needs.plan.outputs.mobile_should_release == 'true'
shell: bash
env:
RELEASE_VERSION: ${{ needs.plan.outputs.mobile_release_version }}
run: |
set -euo pipefail
expected=(
"alera-${RELEASE_VERSION}-android.apk"
)
for asset in "${expected[@]}"; do
test -s "mobile-release-assets/$asset" || { echo "::error::Missing $asset"; exit 1; }
test -s "mobile-release-assets/$asset.sha256" || { echo "::error::Missing $asset.sha256"; exit 1; }
done
actual_count="$(find mobile-release-assets -maxdepth 1 -type f | wc -l)"
if [[ "$actual_count" -ne 2 ]]; then
echo "::error::Expected 2 mobile release files, found $actual_count."
find mobile-release-assets -maxdepth 1 -type f -print
exit 1
fi
(cd mobile-release-assets && sha256sum -c ./*.sha256)
- name: Install Linux repository tools
if: needs.plan.outputs.desktop_should_release == 'true' && needs.plan.outputs.channel == 'stable'
run: sudo apt-get update && sudo apt-get install -y apt-utils createrepo-c
- name: Merge and verify desktop update channel
if: needs.plan.outputs.desktop_should_release == 'true'
env:
ALERA_RELEASE_CHANNEL: ${{ needs.plan.outputs.channel }}
ALERA_RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
ALERA_RELEASE_BUILD_NUMBER: ${{ needs.plan.outputs.desktop_build_number }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY: ${{ secrets.ALERA_UPDATE_MANIFEST_PUBLIC_KEY }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID: ${{ vars.ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID }}
run: |
set -euo pipefail
archive_file="public/updates/${ALERA_RELEASE_CHANNEL}/app-archive.json"
dart tool/release/merge_desktop_update_indexes.dart \
public/update-index-fragments \
"$archive_file"
dart tool/release/verify_desktop_update_channel.dart \
public \
"$archive_file"
rm -rf public/update-index-fragments
echo "ALERA_ARCHIVE_FILE=updates/${ALERA_RELEASE_CHANNEL}/app-archive.json" >>"$GITHUB_ENV"
- name: Generate and verify runtime archive
if: needs.plan.outputs.desktop_should_release == 'true'
env:
CHANNEL: ${{ needs.plan.outputs.channel }}
ALERA_RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
ALERA_RELEASE_BUILD_NUMBER: ${{ needs.plan.outputs.desktop_build_number }}
ALERA_RELEASE_CHANNEL: ${{ needs.plan.outputs.channel }}
ALERA_RELEASE_ASSETS_DIR: release-assets
ALERA_RUNTIME_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.plan.outputs.desktop_tag }}
ALERA_UPDATE_MANIFEST_PRIVATE_KEY: ${{ secrets.ALERA_UPDATE_MANIFEST_PRIVATE_KEY }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY: ${{ secrets.ALERA_UPDATE_MANIFEST_PUBLIC_KEY }}
ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID: ${{ vars.ALERA_UPDATE_MANIFEST_PUBLIC_KEY_ID }}
run: |
runtime_archive_file="runtime-archive.json"
if [[ "$CHANNEL" == "rc" ]]; then
runtime_archive_file="runtime-archive-rc.json"
fi
dart tool/release/build_runtime_archive.dart "public/$runtime_archive_file"
dart tool/release/sign_app_archive.dart "public/$runtime_archive_file"
dart tool/release/verify_runtime_archive.dart "public/$runtime_archive_file"
echo "ALERA_RUNTIME_ARCHIVE_FILE=$runtime_archive_file" >>"$GITHUB_ENV"
- name: Build signed Linux package repositories
if: needs.plan.outputs.desktop_should_release == 'true' && needs.plan.outputs.channel == 'stable'
env:
ALERA_LINUX_GPG_PRIVATE_KEY_BASE64: ${{ secrets.ALERA_LINUX_GPG_PRIVATE_KEY_BASE64 }}
ALERA_LINUX_GPG_KEY_ID: ${{ secrets.ALERA_LINUX_GPG_KEY_ID }}
run: bash tool/release/build_linux_repositories.sh public release-assets
- name: Ensure AWS CLI
if: needs.plan.outputs.desktop_should_release == 'true'
run: |
set -euo pipefail
if ! command -v aws >/dev/null 2>&1; then
python3 -m pip install --user awscli
echo "$HOME/.local/bin" >>"$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
fi
aws --version
- name: Verify R2 deployment prerequisites
if: needs.plan.outputs.desktop_should_release == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
run: |
set -euo pipefail
test -n "${AWS_ACCESS_KEY_ID:-}" || { echo "::error::R2_ACCESS_KEY_ID secret is missing."; exit 1; }
test -n "${AWS_SECRET_ACCESS_KEY:-}" || { echo "::error::R2_SECRET_ACCESS_KEY secret is missing."; exit 1; }
test -n "${CLOUDFLARE_ACCOUNT_ID:-}" || { echo "::error::CLOUDFLARE_ACCOUNT_ID secret is missing."; exit 1; }
aws s3 ls "s3://${ALERA_R2_BUCKET}" --endpoint-url "$R2_ENDPOINT" >/dev/null
- name: Verify merged version commit and prepare tags
id: release_commit
env:
RELEASE_SHA: ${{ needs.plan.outputs.target_sha }}
DESKTOP_SHOULD_RELEASE: ${{ needs.plan.outputs.desktop_should_release }}
DESKTOP_TAG: ${{ needs.plan.outputs.desktop_tag }}
MOBILE_SHOULD_RELEASE: ${{ needs.plan.outputs.mobile_should_release }}
MOBILE_TAG: ${{ needs.plan.outputs.mobile_tag }}
run: |
set -euo pipefail
git fetch origin main --tags --quiet
if [[ "$(git rev-parse HEAD)" != "$RELEASE_SHA" ]]; then
echo "::error::Publish checkout is not the merged version commit."
exit 1
fi
if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then
echo "::error::Merged version commit is not on origin/main."
exit 1
fi
tags=()
if [[ "$DESKTOP_SHOULD_RELEASE" == "true" ]]; then
tags+=("$DESKTOP_TAG")
fi
if [[ "$MOBILE_SHOULD_RELEASE" == "true" ]]; then
tags+=("$MOBILE_TAG")
fi
for tag in "${tags[@]}"; do
if git rev-parse "refs/tags/$tag^{commit}" >/dev/null 2>&1; then
if [[ "$(git rev-parse "refs/tags/$tag^{commit}")" != "$RELEASE_SHA" ]]; then
echo "::error::Tag $tag already points at another commit."
exit 1
fi
else
git tag -a "$tag" "$RELEASE_SHA" -m "$tag"
fi
done
echo "release_sha=$RELEASE_SHA" >>"$GITHUB_OUTPUT"
- name: Push release tags
id: push_refs
env:
RELEASE_SHA: ${{ needs.plan.outputs.target_sha }}
DESKTOP_SHOULD_RELEASE: ${{ needs.plan.outputs.desktop_should_release }}
DESKTOP_TAG: ${{ needs.plan.outputs.desktop_tag }}
MOBILE_SHOULD_RELEASE: ${{ needs.plan.outputs.mobile_should_release }}
MOBILE_TAG: ${{ needs.plan.outputs.mobile_tag }}
run: |
set -euo pipefail
tag_refspecs=()
prepare_refspec() {
local should_release="$1"
local tag="$2"
[[ "$should_release" == "true" ]] || return 0
local remote_target
remote_target="$(git ls-remote --tags origin "refs/tags/$tag^{}" | awk '{print $1}')"
if [[ -z "$remote_target" ]]; then
remote_target="$(git ls-remote --tags origin "refs/tags/$tag" | awk '{print $1}')"
fi
if [[ -n "$remote_target" ]]; then
if [[ "$remote_target" != "$RELEASE_SHA" ]]; then
echo "::error::Remote tag $tag raced to another commit."
exit 1
fi
return 0
fi
tag_refspecs+=("refs/tags/$tag")
}
prepare_refspec "$DESKTOP_SHOULD_RELEASE" "$DESKTOP_TAG"
prepare_refspec "$MOBILE_SHOULD_RELEASE" "$MOBILE_TAG"
if ((${#tag_refspecs[@]} > 0)); then
git push --atomic origin "${tag_refspecs[@]}"
fi
- name: Create draft desktop release
if: needs.plan.outputs.desktop_should_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.desktop_tag }}
PREVIOUS_TAG: ${{ needs.plan.outputs.desktop_previous_tag }}
run: |
set -euo pipefail
notes_args=(
--scope desktop
--repo "$GITHUB_REPOSITORY"
--tag "$TAG"
--target "${{ steps.release_commit.outputs.release_sha }}"
--output desktop-release-notes.md
)
if [[ -n "$PREVIOUS_TAG" ]]; then
notes_args+=(--previous-tag "$PREVIOUS_TAG")
fi
dart tool/release/generate_release_notes.dart "${notes_args[@]}"
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
if [[ "$is_draft" != "true" ]]; then
echo "::error::Release $TAG is already public."
exit 1
fi
gh release edit "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--notes-file desktop-release-notes.md
else
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft \
--verify-tag \
--title "$TAG" \
--notes-file desktop-release-notes.md
fi
- name: Create draft mobile release
if: needs.plan.outputs.mobile_should_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.mobile_tag }}
PREVIOUS_TAG: ${{ needs.plan.outputs.mobile_previous_tag }}
run: |
set -euo pipefail
notes_args=(
--scope mobile
--repo "$GITHUB_REPOSITORY"
--tag "$TAG"
--target "${{ steps.release_commit.outputs.release_sha }}"
--output mobile-release-notes.md
)
if [[ -n "$PREVIOUS_TAG" ]]; then
notes_args+=(--previous-tag "$PREVIOUS_TAG")
fi
dart tool/release/generate_release_notes.dart "${notes_args[@]}"
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
if [[ "$is_draft" != "true" ]]; then
echo "::error::Release $TAG is already public."
exit 1
fi
gh release edit "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--notes-file mobile-release-notes.md
else
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft \
--verify-tag \
--title "$TAG" \
--notes-file mobile-release-notes.md
fi
- name: Upload desktop release assets
if: needs.plan.outputs.desktop_should_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.desktop_tag }}
run: |
bash tool/release/upload_release_assets.sh \
"$TAG" \
"$GITHUB_REPOSITORY" \
release-assets/* \
"public/$ALERA_ARCHIVE_FILE" \
"public/$ALERA_RUNTIME_ARCHIVE_FILE"
- name: Upload mobile release assets
if: needs.plan.outputs.mobile_should_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.mobile_tag }}
run: |
bash tool/release/upload_release_assets.sh \
"$TAG" \
"$GITHUB_REPOSITORY" \
mobile-release-assets/*
- name: Attest desktop release assets
if: needs.plan.outputs.desktop_should_release == 'true'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
with:
subject-path: |
release-assets/*
public/${{ env.ALERA_ARCHIVE_FILE }}
public/${{ env.ALERA_RUNTIME_ARCHIVE_FILE }}
- name: Attest mobile release assets
if: needs.plan.outputs.mobile_should_release == 'true'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
with:
subject-path: mobile-release-assets/*
- name: Publish mobile release
if: needs.plan.outputs.mobile_should_release == 'true'
id: publish_mobile_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.mobile_tag }}
CHANNEL: ${{ needs.plan.outputs.channel }}
run: |
prerelease=false
if [[ "$CHANNEL" == "rc" ]]; then
prerelease=true
fi
gh release edit "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=false \
--prerelease="$prerelease" \
--latest=false
- name: Publish desktop release
if: needs.plan.outputs.desktop_should_release == 'true'
id: publish_desktop_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.desktop_tag }}
CHANNEL: ${{ needs.plan.outputs.channel }}
run: |
prerelease=false
latest_args=()
if [[ "$CHANNEL" == "rc" ]]; then
prerelease=true
else
latest_args+=(--latest=true)
fi
gh release edit "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=false \
--prerelease="$prerelease" \
"${latest_args[@]}"
- name: Publish update archive to R2
if: needs.plan.outputs.desktop_should_release == 'true'
id: publish_update_archive_r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
CHANNEL: ${{ needs.plan.outputs.channel }}
run: |
set -euo pipefail
aws s3 sync public/updates "s3://${ALERA_R2_BUCKET}/updates" \
--endpoint-url "$R2_ENDPOINT" \
--exclude "*/app-archive.json" \
--cache-control "public, max-age=31536000, immutable"
aws s3 cp "public/updates/${CHANNEL}/app-archive.json" \
"s3://${ALERA_R2_BUCKET}/updates/${CHANNEL}/app-archive.json" \
--endpoint-url "$R2_ENDPOINT" \
--cache-control "no-cache"
if [[ "$CHANNEL" == "stable" ]]; then
aws s3 sync public/linux "s3://${ALERA_R2_BUCKET}/linux" \
--endpoint-url "$R2_ENDPOINT" \
--cache-control "public, max-age=300"
fi
for legacy_archive in app-archive.json app-archive-rc.json; do
aws s3 rm "s3://${ALERA_R2_BUCKET}/${legacy_archive}" \
--endpoint-url "$R2_ENDPOINT"
done
- name: Cleanup old R2 desktop updates
if: needs.plan.outputs.desktop_should_release == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
CURRENT_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
set -euo pipefail
cleanup_channel() {
local channel="$1"
local keep_count="$2"
local channel_listing="$RUNNER_TEMP/alera-r2-${channel}-listing.txt"
local versions_file="$RUNNER_TEMP/alera-r2-${channel}-versions.txt"
local retained_file="$RUNNER_TEMP/alera-r2-${channel}-retained.txt"
if aws s3 ls "s3://${ALERA_R2_BUCKET}/updates/${channel}/" \
--endpoint-url "$R2_ENDPOINT" >"$channel_listing"; then
while IFS= read -r legacy_prefix; do
[[ -n "$legacy_prefix" ]] || continue
[[ "$legacy_prefix" == "releases" ]] && continue
aws s3 rm \
"s3://${ALERA_R2_BUCKET}/updates/${channel}/${legacy_prefix}" \
--endpoint-url "$R2_ENDPOINT" \
--recursive
done < <(awk '$1 == "PRE" { sub(/\/$/, "", $2); print $2 }' "$channel_listing")
else
: >"$channel_listing"
fi
if aws s3 ls \
"s3://${ALERA_R2_BUCKET}/updates/${channel}/releases/" \
--endpoint-url "$R2_ENDPOINT" >"$channel_listing"; then
awk '$1 == "PRE" { sub(/\/$/, "", $2); print $2 }' \
"$channel_listing" \
| sort -V >"$versions_file"
else
: >"$versions_file"
fi
{
tail -n "$keep_count" "$versions_file"
echo "$CURRENT_VERSION"
} | sort -u >"$retained_file"
while IFS= read -r version; do
[[ -n "$version" ]] || continue
if grep -Fxq "$version" "$retained_file"; then
continue
fi
aws s3 rm \
"s3://${ALERA_R2_BUCKET}/updates/${channel}/releases/${version}" \
--endpoint-url "$R2_ENDPOINT" \
--recursive
done <"$versions_file"
}
cleanup_channel stable "$ALERA_STABLE_RELEASES_TO_KEEP"
cleanup_channel rc "$ALERA_RC_RELEASES_TO_KEEP"
- name: Cleanup unpublished release refs on failure
if: failure() && steps.push_refs.outcome == 'success'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DESKTOP_SHOULD_RELEASE: ${{ needs.plan.outputs.desktop_should_release }}
DESKTOP_TAG: ${{ needs.plan.outputs.desktop_tag }}
DESKTOP_PUBLISHED: ${{ steps.publish_desktop_release.outcome == 'success' }}
MOBILE_SHOULD_RELEASE: ${{ needs.plan.outputs.mobile_should_release }}
MOBILE_TAG: ${{ needs.plan.outputs.mobile_tag }}
MOBILE_PUBLISHED: ${{ steps.publish_mobile_release.outcome == 'success' }}
run: |
set +e
delete_unpublished_release() {
local tag="$1"
local max_attempts=3
for ((attempt = 1; attempt <= max_attempts; attempt++)); do
if gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes; then
return 0
fi
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::Release $tag is already absent."
return 0
fi
if ((attempt < max_attempts)); then
echo "::warning::Failed to delete release $tag on attempt $attempt; retrying."
sleep "$((attempt * 2))"
fi
done
echo "::error::Failed to delete unpublished release $tag after $max_attempts attempts." >&2
return 1
}
cleanup_failed=false
delete_refspecs=()
if [[ "$DESKTOP_SHOULD_RELEASE" == "true" && "$DESKTOP_PUBLISHED" != "true" ]]; then
delete_unpublished_release "$DESKTOP_TAG" || cleanup_failed=true
delete_refspecs+=(":refs/tags/$DESKTOP_TAG")
fi
if [[ "$MOBILE_SHOULD_RELEASE" == "true" && "$MOBILE_PUBLISHED" != "true" ]]; then
delete_unpublished_release "$MOBILE_TAG" || cleanup_failed=true
delete_refspecs+=(":refs/tags/$MOBILE_TAG")
fi
if ((${#delete_refspecs[@]} > 0)); then
git push --atomic origin "${delete_refspecs[@]}"
fi
if [[ "$cleanup_failed" == "true" ]]; then
exit 1
fi
# The stable version commit updates landing's release pin before the
# artifacts exist. This job starts only after publish has made the relevant
# GitHub releases public and completed the desktop R2 sync when a desktop
# release is included. A failed Vercel hook leaves the already-public
# release intact and does not block package publication, which can still
# point at those valid release assets.
deploy_landing:
name: deploy stable landing
needs:
- plan
- publish
if: >-
always() &&
needs.plan.result == 'success' &&
needs.publish.result != 'cancelled' &&
needs.plan.outputs.channel == 'stable' &&
needs.plan.outputs.any_should_release == 'true' &&
needs.publish.outputs.landing_assets_ready == 'true'
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Trigger production landing deployment
env:
CHANNEL: ${{ needs.plan.outputs.channel }}
VERCEL_PRODUCTION_DEPLOY_HOOK: ${{ secrets.VERCEL_PRODUCTION_DEPLOY_HOOK }}
run: bash tool/release/trigger_vercel_deploy_hook.sh "$CHANNEL"
# The desktop packages are downstream of a release that is already public:
# they only ever point at assets `publish` made public. Their gate uses the
# explicit asset outcome, so a later cleanup failure does not hide a valid
# release. Release candidates are excluded for the same reason the Linux
# stable repositories exclude them.
publish_packages:
name: publish brew and scoop
needs:
- plan
- publish
if: >-
always() &&
!cancelled() &&
needs.plan.result == 'success' &&
needs.publish.result != 'cancelled' &&
needs.plan.outputs.desktop_should_release == 'true' &&
needs.plan.outputs.channel == 'stable' &&
needs.publish.outputs.desktop_assets_public == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout release ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ needs.plan.outputs.target_sha }}
submodules: false
- name: Setup Dart
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: release-*
path: release-assets
merge-multiple: true
- name: Render package manifests
env:
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
TAG: ${{ needs.plan.outputs.desktop_tag }}
run: |
set -euo pipefail
macos_asset="release-assets/alera-${RELEASE_VERSION}-macos.tar.gz"
windows_asset="release-assets/alera-${RELEASE_VERSION}-windows.zip"
for asset in "$macos_asset" "$windows_asset"; do
test -s "$asset" || { echo "::error::Missing $asset"; exit 1; }
done
# The checksums are read from the very files the release carries, so a
# package can never advertise a digest for something else.
dart tool/release/render_package_manifests.dart \
--version "$RELEASE_VERSION" \
--tag "$TAG" \
--macos-sha256 "$(sha256sum "$macos_asset" | cut -d' ' -f1)" \
--windows-sha256 "$(sha256sum "$windows_asset" | cut -d' ' -f1)" \
--out packaging-out
- name: Publish the Homebrew cask and the Scoop manifest
env:
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.ALERA_HOMEBREW_TAP_DEPLOY_KEY }}
SCOOP_BUCKET_DEPLOY_KEY: ${{ secrets.ALERA_SCOOP_BUCKET_DEPLOY_KEY }}
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
set -euo pipefail
# Pin GitHub's SSH host keys from an endpoint TLS already authenticates
# rather than trusting the first connection. A runner that accepted a
# forged host key would push the manifest somewhere else and still
# report success, which is the failure nobody would notice.
mkdir -p ~/.ssh
curl -fsSL https://api.github.com/meta |
python3 -c 'import json,sys; print("\n".join(f"github.com {k}" for k in json.load(sys.stdin)["ssh_keys"]))' \
>>~/.ssh/known_hosts
# Each repository gets its own deploy key rather than one account-wide
# token, so a key that leaks or needs rotating reaches nothing else.
publish_manifest() {
local repository="$1" key_value="$2" rendered="$3" destination="$4"
if [[ -z "$key_value" ]]; then
echo "::warning::No deploy key is configured for $repository; skipping it."
return 0
fi
local key_file checkout
key_file="$(mktemp)"
chmod 600 "$key_file"
printf '%s\n' "$key_value" >"$key_file"
checkout="$(mktemp -d)"
GIT_SSH_COMMAND="ssh -i $key_file -o IdentitiesOnly=yes" \
git clone --depth 1 "git@github.com:${repository}.git" "$checkout"
mkdir -p "$checkout/$(dirname "$destination")"
cp "$rendered" "$checkout/$destination"
git -C "$checkout" config user.name "github-actions[bot]"
git -C "$checkout" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git -C "$checkout" add "$destination"
if git -C "$checkout" diff --cached --quiet; then
echo "$repository already carries Alera $RELEASE_VERSION"
rm -f "$key_file"
return 0
fi
git -C "$checkout" commit -m "alera $RELEASE_VERSION"
GIT_SSH_COMMAND="ssh -i $key_file -o IdentitiesOnly=yes" \
git -C "$checkout" push
rm -f "$key_file"
}
publish_manifest leynier/homebrew-tap "${HOMEBREW_TAP_DEPLOY_KEY:-}" \
packaging-out/homebrew/Casks/alera.rb Casks/alera.rb
publish_manifest leynier/scoop-bucket "${SCOOP_BUCKET_DEPLOY_KEY:-}" \
packaging-out/scoop/bucket/alera.json bucket/alera.json
- name: Upload the Chocolatey package source
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: chocolatey-package
path: packaging-out/chocolatey/
retention-days: 1
if-no-files-found: error
# `choco pack` and `choco push` only run on a Windows runner in a supported
# configuration, so this is deliberately a second job rather than a step.
publish_chocolatey:
name: publish chocolatey
needs:
- plan
- publish_packages
if: >-
!cancelled() &&
needs.publish_packages.result == 'success' &&
needs.plan.outputs.desktop_should_release == 'true' &&
needs.plan.outputs.channel == 'stable'
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Download the Chocolatey package source
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: chocolatey-package
path: chocolatey
- name: Pack and push the Chocolatey package
shell: pwsh
env:
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
RELEASE_VERSION: ${{ needs.plan.outputs.desktop_release_version }}
run: |
if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY)) {
Write-Output "::warning::CHOCOLATEY_API_KEY is not configured; skipping the Chocolatey push."
exit 0
}
$packageId = "alera"
$packagePageUri = "https://community.chocolatey.org/packages/$packageId"
$approvedFeedUri = "https://community.chocolatey.org/api/v2/Packages()?`$filter=Id%20eq%20%27$packageId%27&`$top=1"
try {
$packagePage = Invoke-WebRequest -Uri $packagePageUri -UseBasicParsing
$approvedFeed = Invoke-WebRequest -Uri $approvedFeedUri -UseBasicParsing
$pendingModeration = $packagePage.Content -match "This version is in[\s\S]*moderation[\s\S]*has not yet been approved"
$hasApprovedVersion = $approvedFeed.Content -match "<entry(?:\s|>)"
if ($pendingModeration -and -not $hasApprovedVersion) {
Write-Output "::warning::The first Chocolatey package version is still in moderation; skipping this version until the package has an approved version."
exit 0
}
} catch {
Write-Output "::notice::Could not inspect Chocolatey moderation state; attempting the package push."
}
choco pack chocolatey/alera.nuspec --out chocolatey
$package = "chocolatey/alera.$env:RELEASE_VERSION.nupkg"
if (-not (Test-Path $package)) {
Write-Output "::error::Missing $package"
exit 1
}
choco push $package --source https://push.chocolatey.org/ `
--api-key $env:CHOCOLATEY_API_KEY