diff --git a/.github/scripts/collect-macos-desktop-diagnostics.sh b/.github/scripts/collect-macos-desktop-diagnostics.sh new file mode 100644 index 0000000000..5be1edd486 --- /dev/null +++ b/.github/scripts/collect-macos-desktop-diagnostics.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +label="${1:-failure}" +platform="${DESKTOP_DIAGNOSTICS_PLATFORM:?DESKTOP_DIAGNOSTICS_PLATFORM is required}" +rust_target="${DESKTOP_DIAGNOSTICS_RUST_TARGET:?DESKTOP_DIAGNOSTICS_RUST_TARGET is required}" +diag_dir="$GITHUB_WORKSPACE/dist/desktop-diagnostics" +bundle_root="$GITHUB_WORKSPACE/apps/desktop/src-tauri/target/${rust_target}/release/bundle" + +mkdir -p "$diag_dir" +if [ ! -d "$bundle_root" ]; then + bundle_root="$GITHUB_WORKSPACE/apps/desktop/src-tauri/target/release/bundle" +fi + +{ + echo "label=$label" + echo "platform=$platform" + echo "rust_target=$rust_target" + echo "date=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + echo + echo "## system" + uname -a || true + sw_vers || true + echo + echo "## disk" + df -h || true + echo + echo "## hdiutil info" + hdiutil info || true + echo + echo "## mounts" + mount || true + echo + echo "## related processes" + ps aux | grep -E 'hdiutil|diskimages|bundle_dmg|pnpm|cargo' | grep -v grep || true + echo + echo "## bundle files" + if [ -d "$bundle_root" ]; then + find "$bundle_root" -maxdepth 5 -print || true + else + echo "Missing bundle root: $bundle_root" + fi +} > "$diag_dir/macos-${platform}-${label}.txt" + +if [ -f "$bundle_root/dmg/bundle_dmg.sh" ]; then + cp "$bundle_root/dmg/bundle_dmg.sh" "$diag_dir/bundle_dmg-${platform}-${label}.sh" || true +fi diff --git a/.github/scripts/release-workflow-contract_test.py b/.github/scripts/release-workflow-contract_test.py new file mode 100644 index 0000000000..1b86919930 --- /dev/null +++ b/.github/scripts/release-workflow-contract_test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Contract tests for the maintainer release workflow.""" + +import re +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml" +DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh" +WORKFLOW = WORKFLOW_PATH.read_text() +DIAGNOSTICS = DIAGNOSTICS_PATH.read_text() + + +def step_block(name: str) -> str: + marker = f" - name: {name}" + start = WORKFLOW.find(marker) + if start == -1: + raise AssertionError(f"step not found: {name}") + next_step = re.search(r"\n - (?:name|uses): ", WORKFLOW[start + 1 :]) + end = len(WORKFLOW) if next_step is None else start + 1 + next_step.start() + return WORKFLOW[start:end] + + +def job_block(name: str) -> str: + marker = f" {name}:" + start = WORKFLOW.find(marker) + if start == -1: + raise AssertionError(f"job not found: {name}") + next_job = re.search(r"\n [a-zA-Z0-9_-]+:\n", WORKFLOW[start + 1 :]) + end = len(WORKFLOW) if next_job is None else start + 1 + next_job.start() + return WORKFLOW[start:end] + + +class ReleaseWorkflowContractTest(unittest.TestCase): + def test_backfill_tag_input_uses_existing_tag_without_recreating_it(self) -> None: + self.assertIn("backfill_tag:", WORKFLOW) + self.assertIn("BACKFILL_TAG: ${{ inputs.backfill_tag }}", WORKFLOW) + self.assertIn("Backfill existing release tag:", step_block("Compute next version")) + self.assertIn("backfill_tag is set; the 'bump' input", WORKFLOW) + self.assertIn("backfill_tag cannot be used: no existing release tags found.", WORKFLOW) + self.assertIn("must be the latest release tag", WORKFLOW) + self.assertIn("(expected ${next})", WORKFLOW) + self.assertIn('BACKFILL_REF="refs/tags/$BACKFILL_TAG"', WORKFLOW) + self.assertIn('echo "ref=$BACKFILL_REF" >> "$GITHUB_OUTPUT"', WORKFLOW) + self.assertIn('echo "ref=refs/tags/$TAG" >> "$GITHUB_OUTPUT"', WORKFLOW) + self.assertNotIn("ref: ${{ needs.prepare.outputs.tag }}", WORKFLOW) + + for path in ( + "apps/cli/package.json", + "apps/desktop/package.json", + "apps/desktop/src-tauri/tauri.conf.json", + "apps/desktop/src-tauri/Cargo.toml", + "apps/desktop/src-tauri/Cargo.lock", + ): + self.assertIn(path, WORKFLOW) + + for name in ( + "Bump version + generate CHANGELOG (in working tree)", + "Create release PR + squash-merge + tag", + ): + self.assertIn("inputs.backfill_tag == ''", step_block(name)) + + def test_backfill_tag_still_runs_build_and_publish_jobs(self) -> None: + for name in ( + "build-web", + "build-bundles", + "build-desktop", + "docker-amd64", + "docker-arm64", + "docker-manifest", + "docker-universal-amd64", + "docker-universal-arm64", + "docker-universal-manifest", + "publish-release", + "publish-npm", + "update-homebrew-tap", + ): + block = job_block(name) + self.assertIn("if: ${{ !inputs.dry_run", block) + self.assertNotIn("inputs.backfill_tag == ''", block) + + def test_macos_dmg_build_has_retry_timeout_and_diagnostics(self) -> None: + build = step_block("Build Tauri desktop app") + self.assertIn("timeout-minutes: 70", build) + self.assertIn("TAURI_BUNDLE_ATTEMPTS:", build) + self.assertIn("run_with_timeout", build) + self.assertIn("collect_macos_desktop_diagnostics", build) + self.assertIn("DESKTOP_DIAGNOSTICS_SCRIPT", build) + self.assertIn("terminate_process_tree_with_signal", build) + self.assertIn("else\n status=$?", build) + self.assertIn("for attempt in", build) + + helper = step_block("Prepare macOS desktop diagnostics helper") + self.assertIn("continue-on-error: true", helper) + self.assertIn("github.workflow_sha", helper) + self.assertIn("DESKTOP_DIAGNOSTICS_SCRIPT=$helper", helper) + + collect = step_block("Collect macOS desktop diagnostics") + self.assertIn("if: failure() && startsWith(matrix.platform, 'macos-')", collect) + self.assertIn("DESKTOP_DIAGNOSTICS_SCRIPT", collect) + + upload = step_block("Upload macOS desktop diagnostics") + self.assertIn("if: failure() && startsWith(matrix.platform, 'macos-')", upload) + self.assertIn("dist/desktop-diagnostics/**", upload) + self.assertIn("/release/bundle/dmg/**", upload) + + self.assertIn("hdiutil info", DIAGNOSTICS) + self.assertIn("df -h", DIAGNOSTICS) + self.assertIn("bundle_dmg.sh", DIAGNOSTICS) + self.assertIn('cp "$bundle_root/dmg/bundle_dmg.sh"', DIAGNOSTICS) + self.assertIn("|| true", DIAGNOSTICS) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index c90768d030..c7f99a2d45 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -7,12 +7,16 @@ on: - ".github/workflows/**" - ".github/scripts/lint-action-pinning.py" - ".github/scripts/lint-action-pinning_test.py" + - ".github/scripts/collect-macos-desktop-diagnostics.sh" + - ".github/scripts/release-workflow-contract_test.py" pull_request: branches: [main] paths: - ".github/workflows/**" - ".github/scripts/lint-action-pinning.py" - ".github/scripts/lint-action-pinning_test.py" + - ".github/scripts/collect-macos-desktop-diagnostics.sh" + - ".github/scripts/release-workflow-contract_test.py" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -36,5 +40,8 @@ jobs: - name: Test action-pinning lint run: python3 .github/scripts/lint-action-pinning_test.py + - name: Test release workflow contract + run: python3 .github/scripts/release-workflow-contract_test.py + - name: Verify action refs run: python3 .github/scripts/lint-action-pinning.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2431501e9..f472f369d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,11 @@ on: description: "Build desktop artifacts only (no PR, tag, publish, npm, or tap update)" type: boolean default: false + backfill_tag: + description: "Existing release tag to rebuild/publish (for recovery; leave empty for normal release)" + required: false + default: "" + type: string # Default to read-only at the workflow level. Each job opts into the writes # it actually needs. @@ -109,11 +114,104 @@ jobs: - name: Compute next version id: compute env: + BACKFILL_TAG: ${{ inputs.backfill_tag }} BUMP: ${{ inputs.bump }} + DRY_RUN: ${{ inputs.dry_run }} DESKTOP_VALIDATION_ONLY: ${{ inputs.desktop_validation_only }} CURRENT_REF: ${{ github.sha }} run: | set -euo pipefail + if [ -n "$BACKFILL_TAG" ]; then + echo "::notice::backfill_tag is set; the 'bump' input ($BUMP) is ignored." + if [ "$DRY_RUN" = "true" ]; then + echo "backfill_tag cannot be combined with dry_run." >&2 + exit 1 + fi + if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then + echo "backfill_tag cannot be combined with desktop_validation_only." >&2 + exit 1 + fi + + case "$BACKFILL_TAG" in + refs/tags/*) BACKFILL_TAG="${BACKFILL_TAG#refs/tags/}" ;; + esac + + if [[ ! "$BACKFILL_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "backfill_tag must be a SemVer release tag like v1.2.3." >&2 + exit 1 + fi + BACKFILL_REF="refs/tags/$BACKFILL_TAG" + if ! git rev-parse --verify --quiet "${BACKFILL_REF}^{commit}" >/dev/null; then + echo "backfill_tag does not exist: $BACKFILL_TAG" >&2 + exit 1 + fi + + NEXT="${BACKFILL_TAG#v}" + LATEST_RELEASE_TAG="$(git tag --list 'v*' | python3 -c " + import sys, re + best = None + best_tag = '' + for t in (l.strip() for l in sys.stdin if l.strip()): + s = t.lstrip('v') + m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) + m2 = re.match(r'^(\d+)\.(\d+)$', s) + if m3: + v = tuple(int(x) for x in m3.groups()) + tag = f'v{v[0]}.{v[1]}.{v[2]}' + elif m2: + v = (int(m2.group(1)), int(m2.group(2)), 0) + tag = f'v{v[0]}.{v[1]}.{v[2]}' + else: + continue + if best is None or v > best: + best = v + best_tag = tag + print(best_tag) + ")" + if [ -z "$LATEST_RELEASE_TAG" ]; then + echo "backfill_tag cannot be used: no existing release tags found." >&2 + exit 1 + fi + if [ "$LATEST_RELEASE_TAG" != "$BACKFILL_TAG" ]; then + echo "backfill_tag must be the latest release tag ($LATEST_RELEASE_TAG) because backfill publishes mutable channels." >&2 + exit 1 + fi + + BACKFILL_REF="$BACKFILL_REF" BACKFILL_TAG="$BACKFILL_TAG" NEXT="$NEXT" node <<'NODE' + const { execFileSync } = require("node:child_process"); + + const tag = process.env.BACKFILL_TAG; + const ref = process.env.BACKFILL_REF; + const next = process.env.NEXT; + const show = (path) => execFileSync("git", ["show", `${ref}:${path}`], { encoding: "utf8" }); + const versions = new Map([ + ["apps/cli/package.json", JSON.parse(show("apps/cli/package.json")).version], + ["apps/desktop/package.json", JSON.parse(show("apps/desktop/package.json")).version], + ["apps/desktop/src-tauri/tauri.conf.json", JSON.parse(show("apps/desktop/src-tauri/tauri.conf.json")).version], + ["apps/desktop/src-tauri/Cargo.toml", show("apps/desktop/src-tauri/Cargo.toml").match(/^version = "([^"]+)"/m)?.[1]], + [ + "apps/desktop/src-tauri/Cargo.lock", + show("apps/desktop/src-tauri/Cargo.lock").match(/\[\[package\]\]\nname = "kandev-desktop"\nversion = "([^"]+)"/)?.[1], + ], + ]); + + for (const [path, version] of versions) { + if (version !== next) { + console.error(`backfill_tag version mismatch: ${tag} points to ${path} version ${version ?? ""} (expected ${next}).`); + process.exit(1); + } + } + NODE + + TAG="$BACKFILL_TAG" + echo "Backfill existing release tag: $TAG" + echo "Next version: $NEXT" + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "ref=$BACKFILL_REF" >> "$GITHUB_OUTPUT" + exit 0 + fi + PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) @@ -164,7 +262,7 @@ jobs: if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then echo "ref=$CURRENT_REF" >> "$GITHUB_OUTPUT" else - echo "ref=$TAG" >> "$GITHUB_OUTPUT" + echo "ref=refs/tags/$TAG" >> "$GITHUB_OUTPUT" fi # Sanity: tag must not already exist @@ -178,7 +276,7 @@ jobs: fi - name: Bump version + generate CHANGELOG (in working tree) - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} @@ -216,7 +314,7 @@ jobs: git-cliff --tag "$TAG" -o CHANGELOG.md - name: Create release PR + squash-merge + tag - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEXT: ${{ steps.compute.outputs.version }} @@ -482,6 +580,34 @@ jobs: ref: ${{ needs.prepare.outputs.ref }} persist-credentials: false + - name: Prepare macOS desktop diagnostics helper + if: startsWith(matrix.platform, 'macos-') + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + helper="$RUNNER_TEMP/collect-macos-desktop-diagnostics.sh" + mkdir -p "$(dirname "$helper")" + if [ -f "$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh" ]; then + cp "$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh" "$helper" + else + encoded_helper="$RUNNER_TEMP/collect-macos-desktop-diagnostics.sh.b64" + gh api \ + "repos/${{ github.repository }}/contents/.github/scripts/collect-macos-desktop-diagnostics.sh?ref=${{ github.workflow_sha }}" \ + --jq .content > "$encoded_helper" + HELPER="$helper" ENCODED_HELPER="$encoded_helper" python3 - <<'PY' + import base64 + import os + from pathlib import Path + + encoded = Path(os.environ["ENCODED_HELPER"]).read_text() + Path(os.environ["HELPER"]).write_bytes(base64.b64decode(encoded)) + PY + fi + chmod +x "$helper" + echo "DESKTOP_DIAGNOSTICS_SCRIPT=$helper" >> "$GITHUB_ENV" + - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: version: "9.15.9" @@ -640,8 +766,13 @@ jobs: } - name: Build Tauri desktop app + timeout-minutes: 70 env: CI: "true" + DESKTOP_DIAGNOSTICS_PLATFORM: ${{ matrix.platform }} + DESKTOP_DIAGNOSTICS_RUST_TARGET: ${{ matrix.rust_target }} + TAURI_BUNDLE_ATTEMPTS: ${{ startsWith(matrix.platform, 'macos-') && '2' || '1' }} + TAURI_BUILD_TIMEOUT_SECONDS: ${{ startsWith(matrix.platform, 'macos-') && '1800' || '0' }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} @@ -676,10 +807,110 @@ jobs: fi pnpm -C apps --filter @kandev/desktop build:vite cd apps/desktop - pnpm tauri build \ - --features desktop-runtime \ - --target "${{ matrix.rust_target }}" \ - --bundles "${{ matrix.tauri_bundles }}" + + collect_macos_desktop_diagnostics() { + local label="${1:-failure}" + if [[ "${{ matrix.platform }}" != macos-* ]]; then + return 0 + fi + + bash "${DESKTOP_DIAGNOSTICS_SCRIPT:-$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh}" "$label" || true + } + + terminate_process_tree() { + local pid="$1" + terminate_process_tree_with_signal "$pid" TERM + sleep 5 + terminate_process_tree_with_signal "$pid" KILL + } + + terminate_process_tree_with_signal() { + local pid="$1" + local signal="$2" + local child + while IFS= read -r child; do + [ -n "$child" ] || continue + terminate_process_tree_with_signal "$child" "$signal" + done < <(pgrep -P "$pid" 2>/dev/null || true) + kill "-$signal" "$pid" 2>/dev/null || true + } + + run_with_timeout() { + local timeout_seconds="$1" + shift + if [ "$timeout_seconds" -le 0 ]; then + "$@" + return $? + fi + + "$@" & + local command_pid=$! + local start_seconds + start_seconds="$(date +%s)" + while kill -0 "$command_pid" 2>/dev/null; do + local now_seconds + now_seconds="$(date +%s)" + if [ $((now_seconds - start_seconds)) -ge "$timeout_seconds" ]; then + echo "::warning::Command timed out after ${timeout_seconds}s: $*" + collect_macos_desktop_diagnostics "timeout" + terminate_process_tree "$command_pid" + wait "$command_pid" 2>/dev/null || true + return 124 + fi + sleep 10 + done + wait "$command_pid" + } + + run_tauri_build() { + run_with_timeout "$TAURI_BUILD_TIMEOUT_SECONDS" \ + pnpm tauri build \ + --features desktop-runtime \ + --target "${{ matrix.rust_target }}" \ + --bundles "${{ matrix.tauri_bundles }}" + } + + status=0 + for attempt in $(seq 1 "$TAURI_BUNDLE_ATTEMPTS"); do + echo "Tauri desktop build attempt $attempt/$TAURI_BUNDLE_ATTEMPTS" + if run_tauri_build; then + status=0 + break + else + status=$? + fi + + echo "::warning::Tauri desktop build attempt $attempt/$TAURI_BUNDLE_ATTEMPTS failed with exit code $status." + collect_macos_desktop_diagnostics "attempt-${attempt}" + if [ "$attempt" -lt "$TAURI_BUNDLE_ATTEMPTS" ]; then + sleep 20 + fi + done + + exit "$status" + + - name: Collect macOS desktop diagnostics + if: failure() && startsWith(matrix.platform, 'macos-') + env: + DESKTOP_DIAGNOSTICS_PLATFORM: ${{ matrix.platform }} + DESKTOP_DIAGNOSTICS_RUST_TARGET: ${{ matrix.rust_target }} + run: | + set -euo pipefail + bash "${DESKTOP_DIAGNOSTICS_SCRIPT:-$GITHUB_WORKSPACE/.github/scripts/collect-macos-desktop-diagnostics.sh}" failure || true + + - name: Upload macOS desktop diagnostics + if: failure() && startsWith(matrix.platform, 'macos-') + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: desktop-diagnostics-${{ matrix.platform }} + path: | + dist/desktop-diagnostics/** + apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg/** + apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/** + apps/desktop/src-tauri/target/release/bundle/dmg/** + apps/desktop/src-tauri/target/release/bundle/macos/** + if-no-files-found: ignore + include-hidden-files: true - name: Sign Windows desktop artifacts if: matrix.platform == 'windows-x64' && env.WINDOWS_SIGNING_ENABLED == 'true' @@ -813,7 +1044,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -884,7 +1115,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -997,7 +1228,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -1049,7 +1280,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -1152,7 +1383,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} fetch-depth: 0 - name: Generate release notes @@ -1258,7 +1489,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -1290,7 +1521,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - name: Update Homebrew tap formula env: diff --git a/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md b/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md new file mode 100644 index 0000000000..35e6079c7a --- /dev/null +++ b/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md @@ -0,0 +1,33 @@ +# 0029: Release Backfill and Desktop Diagnostics + +**Status:** accepted +**Date:** 2026-07-01 +**Area:** infra, workflow + +## Context + +The release workflow can create and push a version tag before all platform artifacts have been built and published. If a later artifact job fails, a normal rerun from `workflow_dispatch` cannot safely resume the release because the tag already exists and the prepare job is intentionally non-idempotent for version bumping and tag creation. + +The first desktop macOS failure mode we observed happened after Rust compilation completed, while Tauri was bundling the DMG. The GitHub Actions log ended at `bundle_dmg.sh` with little nested stderr. A Linux CI container image cannot remove this class of failure because DMG packaging must run on native macOS runners. + +## Decision + +Add a maintainer-only release backfill mode to `.github/workflows/release.yml`. The `backfill_tag` input accepts the current highest existing `vX.Y.Z` release tag, verifies that the tag exists, and verifies that all release-critical manifests at that tag match the version. It then uses the existing tag as the build and publish ref. In this mode the workflow skips version bumping, changelog generation, release PR creation, and tag creation, while still running the build, GitHub release, npm publish, and Homebrew update jobs. + +Add macOS desktop packaging guardrails to the Tauri build job. macOS DMG builds now run with a bounded timeout and a retry, and failed macOS desktop builds upload diagnostics that include disk state, `hdiutil info`, related packaging processes, bundle directory listings, and Tauri's generated `bundle_dmg.sh` when present. + +Keep desktop installer builds on native OS runners. Linux container images remain useful for Linux CI and e2e dependency stability, but they are not the mechanism for macOS DMG recovery. + +## Consequences + +Release recovery becomes explicit and repeatable for tag-only or partially published current releases. Publishing scripts can keep their existing idempotent behavior, so rerunning a backfill is safe when some assets or packages already exist. Older tags are rejected because this workflow still updates mutable channels such as Docker tags, npm dist-tags, and the Homebrew formula. + +The release workflow has more branching logic, and maintainers must choose between normal release, desktop validation, dry run, and tag backfill modes intentionally. The workflow contract is covered by a small CI test so later YAML changes do not silently remove the recovery path or macOS diagnostics. + +macOS package failures should now leave actionable artifacts instead of only the final Tauri command line. The retry may add time to a failing macOS job, but the step-level timeout caps total time. + +## Alternatives Considered + +1. **Rerun the normal release workflow.** Rejected because the existing tag makes prepare fail before artifact rebuild and publish jobs can run. +2. **Create a separate duplicate backfill workflow.** Rejected because it would duplicate the large release build/publish graph and increase drift risk. +3. **Use a custom Linux Docker image for release packaging.** Rejected for macOS DMG recovery because native DMG packaging requires macOS runners. This can still help Linux jobs that fail while downloading build dependencies, but it does not address the observed release failure. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 917e445134..7021f187d3 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -34,3 +34,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0026 | [Tauri desktop shell over native runtime](0026-tauri-desktop-shell.md) | accepted | frontend, backend, cli, infra | 2026-06-23 | | 0027 | [Replayable schema migrations across SQLite and Postgres](0027-replayable-schema-migrations.md) | accepted | backend | 2026-06-24 | | 0028 | [Backend-owned task-create last-used preferences](0028-task-create-last-used-source-of-truth.md) | accepted | backend, frontend | 2026-06-29 | +| 0029 | [Release backfill and desktop diagnostics](0029-release-backfill-and-desktop-diagnostics.md) | accepted | infra, workflow | 2026-07-01 |