From 4707c77dd77ade4423c25d651ea1c97e5867dab5 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:45:23 +0000 Subject: [PATCH] ci(fork): split release dispatch out of the validation gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork CI's conclusion is what the smart-host poller promotes on, and it also carried the mobile release dispatch. So a failed EAS dispatch marked a perfectly valid SHA unapprovable: ae4719b1c passed all four required checks, failed only at Dispatch Mobile Releases, and stranded server, Discord, desktop and VS Code deployment of the entire upstream import. Move the release actions into their own workflow chained on workflow_run. Fork CI now answers exactly one question — is this SHA valid — and Fork Release runs only after that answer is success, for a push on fork/dev. That satisfies the constraints without reintroducing fork/integration: - nothing releases from a SHA that is not green: workflow_run gates on conclusion == success, and the manual dispatch path re-checks it explicitly rather than trusting the operator; - release breakage cannot turn the SHA red: a failure is recorded against the release run, and the poller never looks at it; - releases follow the green verdict rather than racing it. Release scope is compared against the last SHA this workflow released, not the last that merely passed CI, so a commit whose release was skipped is still represented in the next release's scope. Concurrency is keyed per SHA and never cancels: two releases of different commits may overlap safely, and cancelling a queued one would leave that commit silently unreleased. Server, Discord, desktop and VS Code are unaffected — the poller still promotes them from the green Fork CI run, so no ops change is required. Note this workflow cannot fire until fork/dev is the repository default branch: workflow_run only triggers for workflow files present on the default branch. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .github/workflows/fork-ci.yml | 128 ----------------------- .github/workflows/fork-release.yml | 158 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/fork-release.yml diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index c27c9214aed..4dadd360d98 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -191,131 +191,3 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts - - deployment_scope: - name: Classify Deployment Scope - # Two release tips during the cutover: the composed fork/integration tip - # (dispatched by the stack workflow) and fork/dev merge commits. Keep both - # until fork/integration is retired. - if: >- - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration') || - (github.event_name == 'push' && github.ref == 'refs/heads/fork/dev') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - actions: read - contents: read - outputs: - deploy: ${{ steps.classify.outputs.deploy }} - server: ${{ steps.classify.outputs.server }} - discord: ${{ steps.classify.outputs.discord }} - vscode: ${{ steps.classify.outputs.vscode }} - mobile: ${{ steps.classify.outputs.mobile }} - desktop: ${{ steps.classify.outputs.desktop }} - steps: - - name: Checkout integration source - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Find previous successful release-tip CI - id: previous - env: - GH_TOKEN: ${{ github.token }} - # Compare against this branch's own history. Hardcoding - # fork/integration + workflow_dispatch here would make every fork/dev - # push diff against an unrelated tip and classify every component as - # changed. - SCOPE_BRANCH: ${{ github.ref_name }} - SCOPE_EVENT: ${{ github.event_name }} - run: | - previous_sha="$( - gh api --method GET \ - "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-ci.yml/runs" \ - -f "branch=${SCOPE_BRANCH}" \ - -f "event=${SCOPE_EVENT}" \ - -f status=success \ - -f per_page=20 \ - --jq ".workflow_runs | map(select(.head_sha != \"${GITHUB_SHA}\")) | first | .head_sha // \"\"" - )" - echo "sha=${previous_sha}" >>"${GITHUB_OUTPUT}" - - - name: Classify changes since previous successful integration CI - id: classify - env: - PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} - run: | - deploy=true - server=true - discord=true - vscode=true - mobile=true - desktop=true - if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]]; then - if ! git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then - git fetch --quiet origin "${PREVIOUS_SHA}" || true - fi - if git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then - classification="$( - scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" - )" - printf '%s\n' "${classification}" >&2 - deploy="$(sed -n 's/^deploy=//p' <<<"${classification}")" - server="$(sed -n 's/^server=//p' <<<"${classification}")" - discord="$(sed -n 's/^discord=//p' <<<"${classification}")" - vscode="$(sed -n 's/^vscode=//p' <<<"${classification}")" - mobile="$(sed -n 's/^mobile=//p' <<<"${classification}")" - desktop="$(sed -n 's/^desktop=//p' <<<"${classification}")" - else - echo "Previous successful integration SHA is unavailable; deployment remains enabled." - fi - else - echo "No previous successful integration SHA; deployment remains enabled." - fi - echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" - echo "server=${server}" >>"${GITHUB_OUTPUT}" - echo "discord=${discord}" >>"${GITHUB_OUTPUT}" - echo "vscode=${vscode}" >>"${GITHUB_OUTPUT}" - echo "mobile=${mobile}" >>"${GITHUB_OUTPUT}" - echo "desktop=${desktop}" >>"${GITHUB_OUTPUT}" - - dispatch_mobile_releases: - name: Dispatch Mobile Releases - needs: [check, test, mobile_native_static_analysis, release_smoke, deployment_scope] - if: >- - ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration') || - (github.event_name == 'push' && github.ref == 'refs/heads/fork/dev')) && - needs.deployment_scope.outputs.mobile == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - actions: write - contents: read - steps: - - name: Dispatch exact release SHA - env: - GH_TOKEN: ${{ github.token }} - # Dispatch against the branch this run is validating so the mobile - # workflows come from the same tip, not a stale fork/integration copy. - RELEASE_REF: ${{ github.ref_name }} - run: | - # mode=auto, not update: an OTA alone never reaches a phone when the - # native runtime changed, so the fingerprint decides between an update - # and a TestFlight build. iOS only, because Android has no keystore. - # release_branch must match: the mobile workflows check out that branch - # and verify the SHA is contained by it. Defaulting it to - # fork/integration there would reject every fork/dev SHA. - gh workflow run mobile-eas-production.yml \ - --repo "$GITHUB_REPOSITORY" \ - --ref "$RELEASE_REF" \ - -f mode=auto \ - -f platform=ios \ - -f sha="$GITHUB_SHA" \ - -f release_branch="$RELEASE_REF" \ - -f message="${RELEASE_REF} ${GITHUB_SHA}" - gh workflow run mobile-eas-development.yml \ - --repo "$GITHUB_REPOSITORY" \ - --ref "$RELEASE_REF" \ - -f platform=ios \ - -f sha="$GITHUB_SHA" \ - -f release_branch="$RELEASE_REF" diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 00000000000..4bcd48a8d91 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,158 @@ +name: Fork Release + +# Release actions, split out of Fork CI on purpose. +# +# Fork CI answers exactly one question: "is this SHA valid?" Its conclusion is +# what the deploy poller on the smart host promotes on, so a release action must +# never be able to turn it red — a failed EAS dispatch would otherwise strand +# server, Discord, desktop and VS Code deployment of a perfectly good commit. +# +# workflow_run gives the ordering for free: this only starts after Fork CI has +# already concluded success for a push to fork/dev, so nothing is released from a +# SHA that is not green, and a failure here is recorded against this run alone. +# +# NOTE: workflow_run only fires for workflow files present on the repository +# default branch. Until fork/dev is the default branch this workflow will not +# trigger, and mobile releases must be dispatched manually below. +on: + workflow_run: + workflows: ["Fork CI"] + types: [completed] + workflow_dispatch: + inputs: + sha: + description: "Exact green fork/dev SHA to release" + required: true + type: string + release_branch: + description: "Branch that must contain the SHA" + required: false + type: string + default: fork/dev + +concurrency: + # Per SHA: two releases of different commits may safely overlap, and a queued + # release must not be cancelled by a newer one — each SHA gets its own outcome. + group: fork-release-${{ github.event.workflow_run.head_sha || inputs.sha }} + cancel-in-progress: false + +jobs: + release: + name: Release + # Only a green push run on fork/dev. Pull-request and workflow_dispatch runs + # of Fork CI validate a tip that was never merged and must not release. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'fork/dev') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: write + contents: read + env: + RELEASE_SHA: ${{ github.event.workflow_run.head_sha || inputs.sha }} + RELEASE_REF: ${{ github.event.workflow_run.head_branch || inputs.release_branch }} + steps: + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha || inputs.sha }} + fetch-depth: 0 + + - name: Confirm the SHA is green + # workflow_dispatch bypasses the workflow_run gate, so re-check it here. + # Releasing an unvalidated SHA by hand is the one thing this split must + # not make easier. + env: + GH_TOKEN: ${{ github.token }} + run: | + green="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-ci.yml/runs" \ + -f branch="${RELEASE_REF}" -f event=push -f status=success -f per_page=30 \ + --jq "[.workflow_runs[] | select(.head_sha == \"${RELEASE_SHA}\")] | length" + )" + if [[ "${green}" -lt 1 ]]; then + echo "error: no successful Fork CI push run for ${RELEASE_SHA} on ${RELEASE_REF}" >&2 + exit 1 + fi + echo "Fork CI is green for ${RELEASE_SHA}." + + - id: previous + name: Find previous released SHA + env: + GH_TOKEN: ${{ github.token }} + run: | + # Compare against the last SHA this workflow released, not the last one + # that merely passed CI: a commit whose release was skipped must still + # be represented in the next release's scope. + previous_sha="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-release.yml/runs" \ + -f branch="${RELEASE_REF}" -f status=success -f per_page=30 \ + --jq "[.workflow_runs[] | select(.head_sha != \"${RELEASE_SHA}\")] | first | .head_sha // \"\"" + )" + echo "sha=${previous_sha}" >>"${GITHUB_OUTPUT}" + + - id: classify + name: Classify release scope + env: + PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + run: | + mobile=true + if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + if ! git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + git fetch --quiet origin "${PREVIOUS_SHA}" || true + fi + if git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + classification="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${RELEASE_SHA}" + )" + printf '%s\n' "${classification}" >&2 + mobile="$(sed -n 's/^mobile=//p' <<<"${classification}")" + [[ "${mobile}" =~ ^(true|false)$ ]] || mobile=true + else + echo "Previous released SHA unavailable; releasing every target." + fi + else + echo "No previous released SHA; releasing every target." + fi + echo "mobile=${mobile}" >>"${GITHUB_OUTPUT}" + + - name: Dispatch mobile releases + if: steps.classify.outputs.mobile == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + # mode=auto, not update: an OTA alone never reaches a phone when the + # native runtime changed, so the fingerprint decides between an update + # and a TestFlight build. iOS only, because Android has no keystore. + gh workflow run mobile-eas-production.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$RELEASE_REF" \ + -f mode=auto \ + -f platform=ios \ + -f sha="$RELEASE_SHA" \ + -f release_branch="$RELEASE_REF" \ + -f message="${RELEASE_REF} ${RELEASE_SHA}" + gh workflow run mobile-eas-development.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$RELEASE_REF" \ + -f platform=ios \ + -f sha="$RELEASE_SHA" \ + -f release_branch="$RELEASE_REF" + + - name: Report + if: always() + run: | + { + printf '### Fork Release\n\n' + printf -- '- SHA: `%s`\n' "${RELEASE_SHA}" + printf -- '- Branch: `%s`\n' "${RELEASE_REF}" + printf -- '- Previous released: `%s`\n' "${{ steps.previous.outputs.sha || 'none' }}" + printf -- '- Mobile dispatched: `%s`\n' "${{ steps.classify.outputs.mobile }}" + printf -- '\nServer, Discord, desktop and VS Code are promoted by the smart-host poller\n' + printf -- 'from the green Fork CI run, independently of this workflow.\n' + } >>"${GITHUB_STEP_SUMMARY}"