Skip to content

Build Desktop Release Candidate #2486

Build Desktop Release Candidate

Build Desktop Release Candidate #2486

name: Build Desktop Release Candidate
on:
# The hourly release train: at most one candidate per hour, coalescing every
# merge since the last tag. Schedule and manual dispatch use the same
# throttle; a retry must never create a second candidate inside the hour.
schedule:
- cron: "7 * * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
plan-and-tag:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: desktop-release-planner-main
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Generate Omi Bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.OMI_BOT_APP_ID }}
private-key: ${{ secrets.OMI_BOT_PRIVATE_KEY }}
- name: Plan desktop release
id: plan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
python3 .github/scripts/plan-desktop-release.py \
--repository "${{ github.repository }}" \
--min-tag-interval-seconds 3600 \
--codemagic-source-gate
- name: Show release plan
run: echo "${{ steps.plan.outputs.reason }}"
# The native tag must be pushed by the Omi Bot installation, rather than
# the workflow's GITHUB_TOKEN. GitHub suppresses recursively generated
# events for GITHUB_TOKEN, which leaves Codemagic no tag webhook to build.
- name: Checkout with Omi Bot token
if: steps.plan.outputs.should_release == 'true'
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Checkout exact releasable desktop source
if: steps.plan.outputs.should_release == 'true'
uses: actions/checkout@v7
with:
ref: ${{ steps.plan.outputs.source_sha }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Compute next version and consolidate changelog
if: steps.plan.outputs.should_release == 'true'
id: version
run: |
# Get latest desktop release tag
LATEST=$(git tag -l 'v*-macos' | sort -V | tail -1)
if [ -z "$LATEST" ]; then
VERSION="0.0.1"
else
# Strip v prefix and +build-macos suffix e.g. v0.11.11+11011-macos -> 0.11.11
VER=$(echo "$LATEST" | sed -E 's/^v([0-9.]+)\+[0-9]+-macos$/\1/')
MAJOR=$(echo "$VER" | cut -d. -f1)
MINOR=$(echo "$VER" | cut -d. -f2)
PATCH=$(echo "$VER" | cut -d. -f3)
PATCH=$((${PATCH:-0} + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
fi
# Build number: 0.11.12 -> 11012 (each component * 1000, summed)
BUILD_NUMBER=$(echo "$VERSION" | tr '.' '\n' | awk '{s=s*1000+$1}END{print s}')
RELEASE_TAG="v${VERSION}+${BUILD_NUMBER}-macos"
echo "Latest tag : ${LATEST:-none}"
echo "New version: $VERSION"
echo "New tag : $RELEASE_TAG"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
# Consolidate unreleased changelog fragments into a versioned release
TODAY=$(date -u +%Y-%m-%d)
python3 .github/scripts/desktop-changelog.py consolidate \
--version "$VERSION" \
--date "$TODAY" \
--write
- name: Commit changelog and prepare sync branch
if: steps.plan.outputs.should_release == 'true'
id: changelog
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
PLANNED_SOURCE_SHA="${{ steps.plan.outputs.source_sha }}"
# A version-only changelog branch can belong to an earlier planner
# source after a retry. Binding the branch to the immutable source
# avoids rewriting that stale branch or confusing its PR with this
# candidate; published tags are never touched by this recovery path.
BRANCH="changelog/v${VERSION}-${PLANNED_SOURCE_SHA:0:12}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Partial candidate tagging recovery: changelog may already be on main while
# the planned-source branch tip is an orphan rewrite. Prefer a
# main-reachable consolidate commit. Always drop consolidate dirt
# before any branch checkout so reuse cannot fail on a dirty tree.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git reset --hard HEAD
git clean -fd -- desktop/macos/CHANGELOG.json desktop/macos/changelog || true
BRANCH_TIP=""
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null; then
git fetch --no-tags origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
BRANCH_TIP="$(git rev-parse "origin/$BRANCH^{commit}")"
fi
RESOLVE_ARGS=(
python3 .github/scripts/resolve-desktop-changelog-sync.py
--repository-root .
--planned-source-sha "$PLANNED_SOURCE_SHA"
--version "$VERSION"
--main-ref origin/main
--repository "${{ github.repository }}"
--github-output
)
if [ -n "$BRANCH_TIP" ]; then
RESOLVE_ARGS+=(--branch-tip "$BRANCH_TIP")
fi
# Capture resolve into step outputs via GITHUB_OUTPUT.
RESOLVE_OUT="$RUNNER_TEMP/desktop-changelog-resolve-outputs.txt"
: > "$RESOLVE_OUT"
GITHUB_OUTPUT="$RESOLVE_OUT" "${RESOLVE_ARGS[@]}"
RESOLVE_MODE="$(grep '^mode=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_COMMIT="$(grep '^commit=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_PR_URL="$(grep '^pr_url=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_MERGED_MAIN="$(grep '^merged_main_sha=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_ON_MAIN="$(grep '^already_on_main=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
if [ "$RESOLVE_MODE" = "stale-orphan" ]; then
# A manually squash-merged or otherwise orphaned sync branch must
# not poison every retry (incident #11936). Keep the stale ref for
# audit and publish a run-qualified recovery branch instead.
BRANCH="${BRANCH}-recovery-${GITHUB_RUN_ID}"
fi
if [ -n "$RESOLVE_COMMIT" ]; then
# Reuse only the exact-parent consolidate selected by the resolver.
test "$(git rev-parse "${RESOLVE_COMMIT}^1")" = "$PLANNED_SOURCE_SHA"
git checkout -B "$BRANCH" "$RESOLVE_COMMIT"
{
echo "changed=true"
echo "commit=$RESOLVE_COMMIT"
echo "reused=true"
echo "already_on_main=$RESOLVE_ON_MAIN"
echo "pr_url=$RESOLVE_PR_URL"
echo "merged_main_sha=$RESOLVE_MERGED_MAIN"
echo "resolve_mode=$RESOLVE_MODE"
echo "branch=$BRANCH"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# Fresh consolidate path: rebuild from the planned source tree.
git checkout --detach "$PLANNED_SOURCE_SHA"
TODAY=$(date -u +%Y-%m-%d)
python3 .github/scripts/desktop-changelog.py consolidate \
--version "$VERSION" \
--date "$TODAY" \
--write
git add desktop/macos/CHANGELOG.json desktop/macos/changelog
if git diff --cached --quiet; then
# Nothing to PR. Direct tag path admits later non-desktop main commits.
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "already_on_main=false" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
exit 0
fi
git commit -m "chore: consolidate changelog for v${VERSION}"
CHANGELOG_COMMIT="$(git rev-parse HEAD)"
git checkout -B "$BRANCH" "$CHANGELOG_COMMIT"
git push origin "$BRANCH"
{
echo "changed=true"
echo "commit=$CHANGELOG_COMMIT"
echo "reused=false"
echo "already_on_main=false"
echo "pr_url="
echo "merged_main_sha="
echo "resolve_mode=created"
echo "branch=$BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Create and regular-merge PR to sync changelog back to main
if: steps.plan.outputs.should_release == 'true' && steps.changelog.outputs.changed == 'true'
id: changelog-pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "Omi Bot token not available; cannot merge changelog PR."
exit 1
fi
VERSION="${{ steps.version.outputs.version }}"
BRANCH="${{ steps.changelog.outputs.branch }}"
CHANGELOG_COMMIT="${{ steps.changelog.outputs.commit }}"
# Changelog already on main from a partial prior candidate tag: do not
# open a second PR. Reuse the merge commit + PR URL from resolve.
if [ "${{ steps.changelog.outputs.already_on_main }}" = "true" ]; then
PR_URL="${{ steps.changelog.outputs.pr_url }}"
MERGED_MAIN_SHA="${{ steps.changelog.outputs.merged_main_sha }}"
if [ -z "$PR_URL" ] || [ -z "$MERGED_MAIN_SHA" ]; then
echo "Changelog commit is on main but merge PR evidence is missing."
exit 1
fi
test "$(git merge-base --is-ancestor "$CHANGELOG_COMMIT" origin/main && echo ok)" = "ok"
PR_NUMBER=$(echo "$PR_URL" | awk -F/ '{print $NF}')
{
echo "number=$PR_NUMBER"
echo "url=$PR_URL"
echo "merged_main_sha=$MERGED_MAIN_SHA"
} >> "$GITHUB_OUTPUT"
exit 0
fi
PR_NUMBER=$(gh pr list \
--head "$BRANCH" \
--base main \
--state open \
--json number \
--jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr list \
--head "$BRANCH" \
--base main \
--state merged \
--json number \
--jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
PR_URL=$(gh pr create \
--title "Update desktop changelog for v${VERSION} [skip ci]" \
--body "Auto-generated: consolidates unreleased changelog fragments into v${VERSION} and regenerates CHANGELOG.json." \
--base main \
--head "$BRANCH")
PR_NUMBER=$(echo "$PR_URL" | awk -F/ '{print $NF}')
fi
fi
PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
if [ "$PR_STATE" != "MERGED" ]; then
# main branch policy blocks ordinary merges. Use admin override so
# the Omi Bot can sync the changelog without waiting on checks.
# Do not fall back to a non-admin merge: a transient --admin failure
# (e.g. HTTP 502) would otherwise retry without override and fail
# on branch policy (see run 30581710658 / PR #10910).
merge_ok=0
for attempt in 1 2 3; do
if gh pr merge "$PR_NUMBER" --admin --merge; then
merge_ok=1
break
fi
if [ "$attempt" -lt 3 ]; then
echo "Admin merge attempt ${attempt}/3 failed; retrying in 5s..."
sleep 5
fi
done
test "$merge_ok" = "1"
fi
PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
test "$PR_STATE" = "MERGED"
MERGED_MAIN_SHA=$(gh pr view "$PR_NUMBER" --json mergeCommit --jq '.mergeCommit.oid')
PR_URL=$(gh pr view "$PR_NUMBER" --json url --jq '.url')
test -n "$MERGED_MAIN_SHA"
{
echo "number=$PR_NUMBER"
echo "url=$PR_URL"
echo "merged_main_sha=$MERGED_MAIN_SHA"
} >> "$GITHUB_OUTPUT"
- name: Bind immutable planner evidence to merged release source
if: steps.plan.outputs.should_release == 'true'
id: identity
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
PLANNED_SOURCE_SHA="${{ steps.plan.outputs.source_sha }}"
CHANGELOG_CHANGED="${{ steps.changelog.outputs.changed }}"
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
MAIN_SHA="$(git rev-parse 'origin/main^{commit}')"
if [ "$CHANGELOG_CHANGED" = true ]; then
CHANGELOG_COMMIT="${{ steps.changelog.outputs.commit }}"
CHANGELOG_PR="${{ steps.changelog-pr.outputs.url }}"
CHANGELOG_PARENT_SHA="$(git rev-parse "${CHANGELOG_COMMIT}^1")"
CANDIDATE_SHA="$CHANGELOG_COMMIT"
python3 .github/scripts/desktop-release-source-identity.py \
--release-tag "$RELEASE_TAG" \
--planned-source-sha "$PLANNED_SOURCE_SHA" \
--candidate-source-sha "$CANDIDATE_SHA" \
--origin-main-sha "$MAIN_SHA" \
--changelog-parent-sha "$CHANGELOG_PARENT_SHA" \
--changelog-commit "$CHANGELOG_COMMIT" \
--changelog-pr "$CHANGELOG_PR" \
--output "$RUNNER_TEMP/desktop-release-source-identity.json"
else
CANDIDATE_SHA="$PLANNED_SOURCE_SHA"
python3 .github/scripts/desktop-release-source-identity.py \
--release-tag "$RELEASE_TAG" \
--planned-source-sha "$PLANNED_SOURCE_SHA" \
--candidate-source-sha "$CANDIDATE_SHA" \
--origin-main-sha "$MAIN_SHA" \
--output "$RUNNER_TEMP/desktop-release-source-identity.json"
fi
{
echo "candidate_sha=$CANDIDATE_SHA"
echo "evidence_path=$RUNNER_TEMP/desktop-release-source-identity.json"
} >> "$GITHUB_OUTPUT"
- name: Retain immutable planner source evidence
if: steps.plan.outputs.should_release == 'true'
uses: actions/upload-artifact@v7
with:
name: desktop-release-planner-source-identity-${{ steps.version.outputs.release_tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.identity.outputs.evidence_path }}
if-no-files-found: error
overwrite: false
- name: Publish immutable tag from merged release source
if: steps.plan.outputs.should_release == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
CANDIDATE_SHA="${{ steps.identity.outputs.candidate_sha }}"
EVIDENCE_PATH="${{ steps.identity.outputs.evidence_path }}"
if [ -z "${GH_TOKEN:-}" ]; then
echo "Omi Bot token not available; cannot publish a candidate tag." >&2
exit 1
fi
# The publisher verifies the exact candidate is still reachable
# from authoritative GitHub main. Later merges belong to the next
# train; an unmerged candidate or existing tag still fails closed.
python3 .github/scripts/publish-desktop-candidate-tag.py \
--repository "${{ github.repository }}" \
--release-tag "$RELEASE_TAG" \
--candidate-sha "$CANDIDATE_SHA" \
--evidence "$EVIDENCE_PATH"
git fetch --no-tags origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$CANDIDATE_SHA"
- name: Dispatch or reuse the exact-tag Codemagic build
if: steps.plan.outputs.should_release == 'true'
env:
CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }}
run: |
# Codemagic's automatic tag trigger is disabled for this workflow.
# One all-state exact-tag query fences retries, then the helper posts
# at most one API build and verifies its immutable source identity.
python3 .github/scripts/check-codemagic-tag-intake.py \
--app-id "66c95e6ec76853c447b8bcbb" \
--workflow-id "omi-desktop-swift-release" \
--release-tag "${{ steps.version.outputs.release_tag }}" \
--source-sha "${{ steps.identity.outputs.candidate_sha }}" \
--timeout-seconds 0 \
--poll-seconds 5 \
--dispatch-fallback-on-absence \
--evidence "$RUNNER_TEMP/codemagic-native-tag-intake.json"
- name: Retain native Codemagic tag intake evidence
if: always() && steps.plan.outputs.should_release == 'true'
uses: actions/upload-artifact@v7
with:
name: codemagic-native-tag-intake-${{ steps.version.outputs.release_tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/codemagic-native-tag-intake.json
if-no-files-found: error
overwrite: false