Build Desktop Release Candidate #2486
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |