diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index e4804852e..e845aa5c4 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -84,14 +84,6 @@ on: the Tauri shell since #1061). type: boolean default: false - skip_pretests: - description: Metadata-only flag propagated by caller workflows when they - intentionally bypass the upstream pretest phase. This reusable build - workflow does not execute pretests itself, but it logs the policy so - the build run captures whether the normal release gate was relaxed for - this invocation. - type: boolean - default: false with_updater: description: When true, set `WITH_UPDATER=true` so the Tauri bundler emits the @@ -146,7 +138,7 @@ jobs: - name: Log build policy shell: bash run: | - echo "[build-desktop] tag=${{ inputs.tag }} build_ref=${{ inputs.build_ref }} skip_pretests=${{ inputs.skip_pretests }}" + echo "[build-desktop] tag=${{ inputs.tag }} build_ref=${{ inputs.build_ref }}" - name: Set Xcode version if: matrix.settings.platform == 'macos-latest' uses: maxim-lobanov/setup-xcode@v1 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 195c021ca..337f1d88c 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -28,15 +28,15 @@ on: default: patch type: choice options: [patch, minor, major] - skip_pretests: + commit_sha: description: - Skip the unit/rust pretest phase and continue directly to the build - matrix. Release workflows no longer run E2E. Use only when the - required pretest signal is already known and you need to unblock a - production cut. + Build from a specific commit SHA instead of main HEAD. Only + consulted when release_source = main_head; staging_tag promotions + resolve the commit from the tag. The commit must be reachable + from main. Leave empty to use main HEAD. required: false - type: boolean - default: false + type: string + default: "" create_release: description: Create and publish the GitHub Release and attach release assets. When @@ -58,8 +58,6 @@ concurrency: # # prepare-build # │ -# ├─── pretest-tests (reusable test-reusable.yml — unit + rust) -# │ # ├─── create-release (optional no-op when `create_release=false`) # │ │ # │ ┌────┴───────────────┬────────────────┐ @@ -174,13 +172,28 @@ jobs: - name: Configure Git env: APP_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_SOURCE: ${{ inputs.release_source }} + COMMIT_SHA: ${{ inputs.commit_sha }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin https://${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git git fetch origin --tags --prune --prune-tags - git checkout main - git pull origin main --ff-only + if [ "$RELEASE_SOURCE" = "main_head" ] && [ -n "$COMMIT_SHA" ]; then + [[ "$COMMIT_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]] || { + echo "Invalid commit_sha: must be a hex SHA (7-40 chars)" + exit 1 + } + git rev-parse --verify "${COMMIT_SHA}^{commit}" >/dev/null + git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { + echo "commit_sha $COMMIT_SHA is not reachable from main" + exit 1 + } + git checkout --detach "$COMMIT_SHA" + else + git checkout main + git pull origin main --ff-only + fi # ── Path A: main_head ──────────────────────────────────────────────── # Bump version on main, commit, push, tag. @@ -201,7 +214,7 @@ jobs: cargo update --workspace --manifest-path Cargo.toml cargo update --workspace --manifest-path app/src-tauri/Cargo.toml - name: Ensure tag does not already exist (main_head) - if: inputs.release_source == 'main_head' + if: inputs.release_source == 'main_head' && inputs.create_release env: TAG: ${{ steps.bump.outputs.tag }} run: | @@ -213,18 +226,23 @@ jobs: echo "Tag already exists on origin: $TAG" exit 1 fi - - name: Commit, push and tag (main_head) + - name: Commit and push version bump (main_head) if: inputs.release_source == 'main_head' id: push env: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.bump.outputs.tag }} + CREATE_RELEASE: ${{ inputs.create_release }} run: | git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml app/src-tauri/Cargo.lock Cargo.lock git commit -m "chore(release): v${VERSION}" - git push origin main - git tag -a "$TAG" -m "Release $TAG" - git push origin "$TAG" + git push origin HEAD:main + if [ "$CREATE_RELEASE" = "true" ]; then + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + else + echo "Skipping tag creation (create_release=false)" + fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # ── Path B: staging_tag ────────────────────────────────────────────── @@ -295,7 +313,7 @@ jobs: echo "prod_version=$PROD_VERSION" >> "$GITHUB_OUTPUT" echo "prod_tag=$PROD_TAG" >> "$GITHUB_OUTPUT" - name: Ensure production tag does not already exist (staging_tag) - if: inputs.release_source == 'staging_tag' + if: inputs.release_source == 'staging_tag' && inputs.create_release env: TAG: ${{ steps.stagingtag.outputs.prod_tag }} run: | @@ -308,7 +326,7 @@ jobs: exit 1 fi - name: Create production tag at staging commit (staging_tag) - if: inputs.release_source == 'staging_tag' + if: inputs.release_source == 'staging_tag' && inputs.create_release id: promote env: STAGING_TAG: ${{ steps.stagingtag.outputs.staging_tag }} @@ -324,12 +342,14 @@ jobs: shell: bash env: RELEASE_SOURCE: ${{ inputs.release_source }} + CREATE_RELEASE: ${{ inputs.create_release }} BUMP_VERSION: ${{ steps.bump.outputs.version }} BUMP_TAG: ${{ steps.bump.outputs.tag }} PUSH_SHA: ${{ steps.push.outputs.sha }} PROMOTE_VERSION: ${{ steps.stagingtag.outputs.prod_version }} PROMOTE_TAG: ${{ steps.stagingtag.outputs.prod_tag }} PROMOTE_SHA: ${{ steps.promote.outputs.sha }} + STAGING_SHA: ${{ steps.stagingtag.outputs.staging_sha }} run: | if [ "$RELEASE_SOURCE" = "main_head" ]; then VERSION="$BUMP_VERSION" @@ -338,14 +358,16 @@ jobs: else VERSION="$PROMOTE_VERSION" TAG="$PROMOTE_TAG" - SHA="$PROMOTE_SHA" + # When create_release=false on staging_tag path, promote step + # is skipped so PROMOTE_SHA is empty — fall back to staging SHA. + SHA="${PROMOTE_SHA:-$STAGING_SHA}" + fi + if [ "$CREATE_RELEASE" = "true" ]; then + BUILD_REF="$TAG" + else + BUILD_REF="$SHA" fi - BUILD_REF="$TAG" BASE_URL="https://api.tinyhumans.ai/" - # Match the 12-char truncation runtime code applies to - # VITE_BUILD_SHA / OPENHUMAN_BUILD_SHA when constructing the release - # tag at startup, so SENTRY_RELEASE assembled in CI agrees with - # the tag events emit. SHORT_SHA="${SHA:0:12}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" @@ -355,31 +377,13 @@ jobs: echo "base_url=$BASE_URL" >> "$GITHUB_OUTPUT" # ========================================================================= - # Phase 1b: Pretest gate — run unit + rust on the build ref before we spin up the release - # draft or any signed-build matrix. Pretest failures abort the workflow - # before `create-release` runs, so a busted commit never produces a - # half-finished GH Release that has to be cleaned up. - # ========================================================================= - pretest-tests: - name: Pretest — unit + rust - needs: [prepare-build] - if: ${{ !inputs.skip_pretests }} - uses: ./.github/workflows/test-reusable.yml - with: - ref: ${{ needs.prepare-build.outputs.build_ref }} - # ========================================================================= # Phase 2: Create draft GitHub release # ========================================================================= create-release: name: Prepare GitHub release runs-on: ubuntu-latest environment: Production - needs: [prepare-build, pretest-tests] - if: >- - always() - && needs.prepare-build.result == 'success' - && (needs.pretest-tests.result == 'success' - || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) + needs: [prepare-build] outputs: release_id: ${{ steps.create.outputs.release_id || steps.noop.outputs.release_id }} upload_url: ${{ steps.create.outputs.upload_url || steps.noop.outputs.upload_url }} @@ -428,13 +432,6 @@ jobs: build-desktop: name: Build desktop matrix needs: [prepare-build, create-release] - # `always()` is load-bearing: when `skip_pretests=true` the pretest job is - # `skipped`, and GitHub propagates that skipped status transitively to any - # downstream job lacking an explicit status function — even though we only - # `needs` create-release here, the build would otherwise be skipped along - # with the pretests. Same pattern below for build-cli-linux / build-docker / - # publish-updater-manifest / publish-release / tag-docker-latest / - # record-sentry-deploy. See release-staging.yml for the working precedent. if: always() && needs.create-release.result == 'success' uses: ./.github/workflows/build-desktop.yml secrets: inherit @@ -453,7 +450,6 @@ jobs: with_release_upload: ${{ inputs.create_release }} release_id: ${{ needs.create-release.outputs.release_id }} build_sidecar: false - skip_pretests: ${{ inputs.skip_pretests }} # ========================================================================= # Phase 3b: Build & push Docker image (runs parallel with build-desktop). @@ -835,37 +831,22 @@ jobs: environment: Production needs: - prepare-build - - pretest-tests - create-release - build-desktop - build-cli-linux - build-docker - publish-updater-manifest - # Two cleanup paths: - # (a) pretest failed → no release yet, but `prepare-build` already - # pushed a tag and (for main_head) a chore(release) commit; we - # still need to delete the tag so a rerun doesn't trip the - # "tag already exists" guard. - # (b) any build phase failed after create-release succeeded — the - # original behavior — delete the draft release, the tag, and any - # pushed Docker image versions. if: >- always() + && inputs.create_release && needs.prepare-build.result == 'success' - && ( - (needs.create-release.result != 'success' - && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled' - )) - || (needs.create-release.result == 'success' - && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' - || needs.build-cli-linux.result == 'failure' || needs.build-cli-linux.result == 'cancelled' - || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled' - || needs.publish-updater-manifest.result == 'failure' || needs.publish-updater-manifest.result == 'cancelled')) - ) + && needs.create-release.result == 'success' + && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' + || needs.build-cli-linux.result == 'failure' || needs.build-cli-linux.result == 'cancelled' + || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled' + || needs.publish-updater-manifest.result == 'failure' || needs.publish-updater-manifest.result == 'cancelled') steps: - name: Delete GitHub release - # Skip on the pretest-failure cleanup path: create-release didn't run, - # so there's no draft release to delete (only an orphaned tag). if: ${{ inputs.create_release && needs.create-release.result == 'success' }} uses: actions/github-script@v8 with: @@ -906,8 +887,7 @@ jobs: # `:v` and `:` tags we just pushed and remove the # underlying package version. `:latest` is left alone — the previous # release is still pointed at it, and clobbering the moving tag here - # would orphan downstream pulls. Skipped on the pretest-failure - # cleanup path where build-docker never ran. + # would orphan downstream pulls. if: needs.build-docker.result == 'success' continue-on-error: true env: diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 3404661e5..a0175a72c 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -3,15 +3,23 @@ name: Release Staging on: workflow_dispatch: inputs: - skip_pretests: + commit_sha: description: - Skip the unit/rust pretest phase and continue directly to the - desktop/docker staging build. Release workflows no longer run E2E. - Use only when the required pretest signal is already known and you - need to unblock a staging cut. + Build from a specific commit SHA instead of main HEAD. Leave empty + to use main HEAD (the default). The commit must be reachable from + main. + required: false + type: string + default: "" + create_tag: + description: + Create the immutable `v-staging` tag and run the full + build matrix. When false, bump versions and commit to main but + skip tag creation, desktop builds, and Docker — useful for + version-bump-only runs. required: false type: boolean - default: false + default: true permissions: # `contents: write` is required for the patch bump commit and the # `v-staging` tag push performed by `prepare-build` below. @@ -25,20 +33,15 @@ concurrency: # # prepare-build # │ -# ├── pretest-tests (reusable test-reusable.yml — unit + rust; -# │ optional when `skip_pretests` is true) -# ├── pretest-tests (reusable test-reusable.yml — unit + rust) -# │ -# ├── build-desktop (delegated to .github/workflows/build-desktop.yml) -# ├── build-docker (build only — no GHCR push on staging) +# ├── build-desktop (delegated to .github/workflows/build-desktop.yml; +# │ skipped when `create_tag=false`) +# ├── build-docker (build only — no GHCR push on staging; +# │ skipped when `create_tag=false`) # │ # record-sentry-deploy # │ # cleanup-failed-staging (on failure) # -# The pretest job is a hard gate — `build-desktop` and `build-docker` -# only start once unit/rust have passed, unless explicitly skipped. -# # The actual desktop build / Sentry / artifact-upload pipeline lives in # `.github/workflows/build-desktop.yml` and is shared with # release-production.yml. @@ -105,13 +108,27 @@ jobs: - name: Configure Git env: APP_TOKEN: ${{ steps.app-token.outputs.token }} + COMMIT_SHA: ${{ inputs.commit_sha }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin https://${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git git fetch origin --tags --prune --prune-tags - git checkout main - git pull origin main --ff-only + if [ -n "$COMMIT_SHA" ]; then + [[ "$COMMIT_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]] || { + echo "Invalid commit_sha: must be a hex SHA (7-40 chars)" + exit 1 + } + git rev-parse --verify "${COMMIT_SHA}^{commit}" >/dev/null + git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { + echo "commit_sha $COMMIT_SHA is not reachable from main" + exit 1 + } + git checkout --detach "$COMMIT_SHA" + else + git checkout main + git pull origin main --ff-only + fi # Patch-only bump for staging cuts. Minor/major promotions are owned # by `release-production.yml` and only happen on the production path. # Bump commit lands on `main` (we don't maintain a separate `staging` @@ -139,6 +156,7 @@ jobs: STAGING_TAG="v${VERSION}-staging" echo "tag=${STAGING_TAG}" >> "$GITHUB_OUTPUT" - name: Ensure staging tag does not already exist + if: inputs.create_tag env: TAG: ${{ steps.tagname.outputs.tag }} run: | @@ -150,17 +168,22 @@ jobs: echo "Tag already exists on origin: $TAG" exit 1 fi - - name: Commit, push and tag staging cut + - name: Commit and push version bump id: push env: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.tagname.outputs.tag }} + CREATE_TAG: ${{ inputs.create_tag }} run: | git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml app/src-tauri/Cargo.lock Cargo.lock git commit -m "chore(staging): v${VERSION}" - git push origin main - git tag -a "$TAG" -m "Staging cut $TAG" - git push origin "$TAG" + git push origin HEAD:main + if [ "$CREATE_TAG" = "true" ]; then + git tag -a "$TAG" -m "Staging cut $TAG" + git push origin "$TAG" + else + echo "Skipping tag creation (create_tag=false)" + fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Resolve build outputs id: resolve @@ -169,44 +192,30 @@ jobs: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.tagname.outputs.tag }} SHA: ${{ steps.push.outputs.sha }} + CREATE_TAG: ${{ inputs.create_tag }} run: | - # Match the 12-char truncation runtime code applies to - # VITE_BUILD_SHA / OPENHUMAN_BUILD_SHA when constructing the - # release tag at startup, so SENTRY_RELEASE assembled in CI - # agrees with the tag events emit. SHORT_SHA="${SHA:0:12}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - # Build from the immutable staging tag rather than main HEAD so - # reruns of this workflow rebuild the same content even if main - # has moved on (e.g. another patch cut, or a hotfix landed). - echo "build_ref=$TAG" >> "$GITHUB_OUTPUT" + if [ "$CREATE_TAG" = "true" ]; then + # Build from the immutable staging tag rather than main HEAD so + # reruns of this workflow rebuild the same content even if main + # has moved on (e.g. another patch cut, or a hotfix landed). + echo "build_ref=$TAG" >> "$GITHUB_OUTPUT" + else + echo "build_ref=$SHA" >> "$GITHUB_OUTPUT" + fi echo "base_url=https://staging-api.tinyhumans.ai/" >> "$GITHUB_OUTPUT" # ========================================================================= - # Phase 1b: Pretest gate — run unit + rust once on the staging commit before any build job - # spins up. A failure here aborts the matrix (and `cleanup-failed-staging` - # deletes the tag) without burning four signed Tauri builds first. - # ========================================================================= - pretest-tests: - name: Pretest — unit + rust - needs: [prepare-build] - if: ${{ !inputs.skip_pretests }} - uses: ./.github/workflows/test-reusable.yml - with: - ref: ${{ needs.prepare-build.outputs.build_ref }} - # ========================================================================= # Phase 2: Build desktop artifacts (delegated to reusable workflow) # ========================================================================= build-desktop: name: Build desktop matrix - needs: [prepare-build, pretest-tests] - if: >- - always() - && (needs.pretest-tests.result == 'success' - || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) + needs: [prepare-build] + if: inputs.create_tag uses: ./.github/workflows/build-desktop.yml secrets: inherit with: @@ -236,7 +245,6 @@ jobs: # real consumer. Set `build_sidecar: true` to re-enable a per-platform # CLI Actions artifact + its Sentry DIF upload for QA spot-checks. build_sidecar: false - skip_pretests: ${{ inputs.skip_pretests }} # ========================================================================= # Phase 2b: Build the openhuman-core Docker image without pushing. @@ -249,11 +257,8 @@ jobs: # ========================================================================= build-docker: name: "Docker: build (no push)" - needs: [prepare-build, pretest-tests] - if: >- - always() - && (needs.pretest-tests.result == 'success' - || (inputs.skip_pretests && needs.pretest-tests.result == 'skipped')) + needs: [prepare-build] + if: inputs.create_tag runs-on: ubuntu-latest environment: Production steps: @@ -294,6 +299,7 @@ jobs: name: Record Sentry deploy marker runs-on: ubuntu-latest environment: Production + if: inputs.create_tag needs: [prepare-build, build-desktop, build-docker] env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -334,12 +340,12 @@ jobs: name: Remove staging tag if build failed runs-on: ubuntu-latest environment: Production - needs: [prepare-build, pretest-tests, build-desktop, build-docker] + needs: [prepare-build, build-desktop, build-docker] if: >- always() + && inputs.create_tag && needs.prepare-build.result == 'success' - && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled' - || needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' + && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled') steps: - name: Delete remote staging tag diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 29cc81c87..7c33b1a4b 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -1,7 +1,6 @@ --- # Reusable test workflow — frontend unit tests, Rust core tests, Rust Tauri -# shell tests. Same job graph used by both PR/push (`test.yml`) and the -# release pretest gate (`release-staging.yml`, `release-production.yml`). +# shell tests. Used by PR/push (`test.yml`). # # Caching: pnpm store, Swatinem rust-cache, CEF runtime, and sccache backed # by the GitHub Actions cache. Cache keys mirror what the release build