diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md index 66efb7b0b..9da8a83ba 100644 --- a/.github/ISSUE_TEMPLATE/bug.md +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -24,7 +24,7 @@ Suspected cause, workaround, or proposed fix. Skip if unknown. - [ ] **Repro gone** — Bug no longer reproduces on the stated environment (or root cause documented if intentional). - [ ] **Regression safety** — Unit, integration, or E2E coverage added or updated if this should not come back. -- [ ] **Diff coverage ≥ 80%** — the fix PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/pr-ci.yml`](../../.github/workflows/pr-ci.yml)). +- [ ] **Diff coverage ≥ 80%** — the fix PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/ci-lite.yml`](../../.github/workflows/ci-lite.yml)). - [ ] **…** — Other verify-before-close items. ## Related diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md index cad7a59f9..dcdd9c0a5 100644 --- a/.github/ISSUE_TEMPLATE/feature.md +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -25,7 +25,7 @@ How you plan to solve it — scope (core / app / both), approach, tradeoffs. Ski - [ ] **Feature 1** — TODO - [ ] **Feature 2** — TODO - [ ] **Feature 3** — TODO -- [ ] **Diff coverage ≥ 80%** — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/pr-ci.yml`](../../.github/workflows/pr-ci.yml)). +- [ ] **Diff coverage ≥ 80%** — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/ci-lite.yml`](../../.github/workflows/ci-lite.yml)). - … diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md index 91125e7d9..52f8b521d 100644 --- a/.github/ISSUE_TEMPLATE/task.md +++ b/.github/ISSUE_TEMPLATE/task.md @@ -25,7 +25,7 @@ What is in scope, what is not, and any implementation notes or tradeoffs worth c - [ ] **Task 1** — TODO - [ ] **Task 2** — TODO - [ ] **Task 3** — TODO -- [ ] **Diff coverage ≥ 80%** — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/pr-ci.yml`](../../.github/workflows/pr-ci.yml)) when code changes are involved. +- [ ] **Diff coverage ≥ 80%** — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by [`.github/workflows/ci-lite.yml`](../../.github/workflows/ci-lite.yml)) when code changes are involved. - … diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 848fa34cd..a84674dd6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,7 +18,7 @@ > If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items. - [ ] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) -- [ ] **Diff coverage ≥ 80%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/pr-ci.yml`](../.github/workflows/pr-ci.yml). Run `pnpm test:coverage` and `pnpm test:rust` locally; PRs below 80% on changed lines will not merge. +- [ ] **Diff coverage ≥ 80%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/ci-lite.yml`](../.github/workflows/ci-lite.yml). Run `pnpm test:coverage` and `pnpm test:rust` locally; PRs below 80% on changed lines will not merge. - [ ] Coverage matrix updated — added/removed/renamed feature rows in [`docs/TEST-COVERAGE-MATRIX.md`](../docs/TEST-COVERAGE-MATRIX.md) reflect this change (or `N/A: behaviour-only change`) - [ ] All affected feature IDs from the matrix are listed in the PR description under `## Related` - [ ] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy)) diff --git a/.github/workflows/release-ci.yml b/.github/workflows/ci-full.yml similarity index 89% rename from .github/workflows/release-ci.yml rename to .github/workflows/ci-full.yml index 2e84a2113..df7871573 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/ci-full.yml @@ -1,19 +1,25 @@ --- -# Release CI — full-suite gate for PRs targeting the long-lived `release` -# branch (normally the auto-prepared main→release PR). +# CI Full — the slow lane. Runs EVERYTHING against the long-lived `release` +# branch: full frontend + Rust unit suites, Rust mock-backend E2E, Playwright +# web E2E, and the full desktop E2E matrix on Linux/macOS/Windows. # -# Two-branch model: -# - PRs → main run the fast lane (pr-ci.yml): quality checks + unit tests -# scoped to the changed files only. -# - main → release PRs run EVERYTHING here: full frontend + Rust unit -# suites, Rust mock-backend E2E, Playwright web E2E, and the full desktop -# E2E matrix on Linux/macOS/Windows. Maintainers can push fixups directly -# to the PR until it greens up; releases are then cut from `release`. +# Two-lane model: +# - ci-lite.yml (quick): pushes to main + PRs to main/release — quality +# checks + unit tests scoped to the changed files only. +# - ci-full.yml (this file, slow): PRs targeting `release` (fix PRs opened +# while stabilising a cut — the "CI Full Gate" check gates their merge) +# and pushes to `release` (the maintainer-dispatched promotion merge from +# promote-main-to-release.yml, which has no PR, plus each fix-PR merge +# commit). Once the gate is green on release HEAD a release is cut via +# release-staging.yml / release-production.yml. # -# Branch protection on `release` requires the "Release CI Gate" check below. -name: Release CI +# The release workflows' version-bump commits carry [skip ci] so cutting a +# build does not re-trigger this suite on an already-validated tree. +name: CI Full on: + push: + branches: [release] pull_request: branches: [release] workflow_dispatch: {} @@ -25,15 +31,16 @@ permissions: actions: read contents: read packages: read - pull-requests: read env: # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. GH_TOKEN: ${{ github.token }} NPM_CONFIG_STORE_DIR: .pnpm-store +# Keep only the newest run per PR (or per ref for release pushes); the gate +# result is per-commit, so cancelling the superseded run loses nothing. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true defaults: @@ -46,8 +53,8 @@ jobs: # pretest gate uses. unit-tests: name: Full Unit Suites - # No `secrets: inherit` — the called workflow uses no secrets, and this - # workflow is pull_request-triggered; keep its runs secret-free. + # No `secrets: inherit` — the called workflow uses no secrets; keep this + # workflow's runs secret-free. uses: ./.github/workflows/test-reusable.yml with: run_unit: true @@ -95,7 +102,7 @@ jobs: with: workspaces: . -> target cache-on-failure: true - # Shared with pr-ci's former key so warm caches carry over between + # Shared with ci-lite's former key so warm caches carry over between # the fast lane and this full lane on the same dependency tree. shared-key: pr-rust-core @@ -294,8 +301,8 @@ jobs: # shards — see e2e-reusable.yml). e2e-desktop: name: Desktop E2E (full suite, 3 OS) - # No `secrets: inherit` — the called workflow uses no secrets, and this - # workflow is pull_request-triggered; keep its runs secret-free. + # No `secrets: inherit` — the called workflow uses no secrets; keep this + # workflow's runs secret-free. uses: ./.github/workflows/e2e-reusable.yml with: run_linux: true @@ -303,8 +310,8 @@ jobs: run_windows: true full: true - release-ci-gate: - name: Release CI Gate + ci-full-gate: + name: CI Full Gate needs: - unit-tests - rust-e2e @@ -322,7 +329,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Require all Release CI jobs to pass + - name: Require all CI Full jobs to pass run: | set -euo pipefail # NOTE: "Playwright E2E" is deliberately absent — see the needs: diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/ci-lite.yml similarity index 96% rename from .github/workflows/pr-ci.yml rename to .github/workflows/ci-lite.yml index 6fc6518fb..96fd7d9ec 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/ci-lite.yml @@ -1,17 +1,24 @@ --- -# PR fast lane (PRs targeting main + pushes to main). +# CI Lite — the quick lane (pushes to main + PRs targeting main or release). # # Runs quality checks per changed area and unit tests ONLY for the files the # PR changed (vitest related / domain-scoped cargo-llvm-cov), with diff-cover # enforcing >= 80% coverage on changed lines. Integration and E2E suites do -# NOT run here — they run in full on main→release PRs via release-ci.yml. -name: PR CI +# NOT run here — they run in full via ci-full.yml on PRs targeting `release` +# and on every push to `release` (the maintainer-dispatched +# promote-main-to-release.yml merge and fix-PR merges). Fix PRs against +# release get this fast lane too, for quick lint/coverage feedback alongside +# the full suite. +# +# The gate job keeps its historical "PR CI Gate" check name so the main +# ruleset's required-status-check reference stays valid. +name: CI Lite on: push: branches: [main] pull_request: - branches: [main] + branches: [main, release] permissions: # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so @@ -77,7 +84,7 @@ jobs: list-files: shell filters: | frontend: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' # The changed-files runner itself must trigger the lane that # runs it (it is also in frontend-full → full-suite mode). - 'scripts/ci/vitest-changed-coverage.sh' @@ -95,7 +102,7 @@ jobs: - 'app/postcss.config.*' # Changes here invalidate per-file test scoping → full Vitest run. frontend-full: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'scripts/ci/vitest-changed-coverage.sh' - 'package.json' - 'pnpm-lock.yaml' @@ -112,17 +119,17 @@ jobs: - 'app/src/**/*.ts' - 'app/src/**/*.tsx' i18n: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'app/src/**' docs: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'app/src/App.tsx' - 'scripts/generate-architecture-docs.mjs' - 'scripts/__tests__/generate-architecture-docs.test.mjs' - 'gitbooks/developing/architecture/frontend.md' - 'package.json' rust-core: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' # The changed-files runner itself must trigger the lane that # runs it (it is also in rust-core-full → full-suite mode). - 'scripts/ci/rust-coverage-changed.sh' @@ -136,7 +143,7 @@ jobs: - 'scripts/test-rust-with-mock.sh' # Changes here invalidate per-module test scoping → full suite. rust-core-full: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'scripts/ci/rust-coverage-changed.sh' - 'Cargo.toml' - 'Cargo.lock' @@ -146,7 +153,7 @@ jobs: - 'src/**' - 'tests/**' rust-tauri: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'app/src-tauri/**' @@ -156,7 +163,7 @@ jobs: # own socket-auth tests never ran in CI) — arm the scripts # self-test job on any mock/script change. scripts: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'scripts/mock-api/**' - 'scripts/*.mjs' - 'scripts/__tests__/**' @@ -172,7 +179,7 @@ jobs: - 'scripts/generate-test-inventory.mjs' # Windows installer + its Pester unit test. install_ps1: - - '.github/workflows/pr-ci.yml' + - '.github/workflows/ci-lite.yml' - 'scripts/install.ps1' - 'scripts/tests/OpenHumanWindowsInstall.Tests.ps1' diff --git a/.github/workflows/prepare-release-pr.yml b/.github/workflows/prepare-release-pr.yml deleted file mode 100644 index 812086ba5..000000000 --- a/.github/workflows/prepare-release-pr.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -# Keeps the main→release PR standing at all times. -# -# Every push to main ensures there is exactly one open PR from main into the -# long-lived `release` branch. GitHub automatically refreshes an open -# branch-to-branch PR as main advances, so if one already exists this is a -# no-op; otherwise it opens one (skipping when main has nothing release -# doesn't already have). Release CI (release-ci.yml) runs the full test + -# E2E matrix on that PR; once it greens up a maintainer merges it and cuts a -# release from `release` via release-staging.yml / release-production.yml. -# -# Two-job split on purpose: `check` runs on every merge to main with only the -# default read token and no environment — the common case (PR already open) -# stops there. `open-pr`, which claims the Release-PR-Automation environment -# and mints a GitHub App token, only runs when a PR actually needs creating. -name: Prepare Release PR - -on: - push: - branches: [main] - workflow_dispatch: {} - -permissions: - contents: read - pull-requests: read - -concurrency: - group: prepare-release-pr - cancel-in-progress: false - -jobs: - check: - name: Check whether a main→release PR is needed - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - needs-pr: ${{ steps.decide.outputs.needs_pr }} - steps: - - name: Decide (release branch exists, no open PR, main is ahead) - id: decide - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - log() { echo "[ci][prepare-release-pr] $*"; } - decide() { echo "needs_pr=$1" >> "$GITHUB_OUTPUT"; log "needs_pr=$1 ($2)"; } - - if ! gh api "repos/${REPO}/branches/release" --silent 2>/dev/null; then - decide false "release branch does not exist yet" - exit 0 - fi - - existing="$(gh pr list --repo "${REPO}" --base release --head main --state open --json number --jq '.[0].number // empty')" - if [ -n "${existing}" ]; then - decide false "open main→release PR already exists: #${existing} (auto-updates as main advances)" - exit 0 - fi - - # Skip when main has no commits release lacks (e.g. right after a - # release-back-merge) — GitHub rejects empty PRs anyway. - ahead_by="$(gh api "repos/${REPO}/compare/release...main" --jq '.ahead_by')" - log "main is ahead of release by ${ahead_by} commit(s)" - if [ "${ahead_by}" = "0" ]; then - decide false "release already contains main" - exit 0 - fi - - decide true "no open PR and main is ahead by ${ahead_by}" - - open-pr: - name: Open the main→release PR - needs: [check] - if: needs.check.outputs.needs-pr == 'true' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - pull-requests: write - # The XGITHUB_APP_* secrets live in environments, never at repo level. - # This dedicated environment holds ONLY those two secrets and is branch- - # policied to `main`, so this job never sees the Production environment - # (Apple signing, Tauri updater key, …) and no other ref can claim it. - # It is only entered when `check` says a PR must be created. - environment: Release-PR-Automation - steps: - # PRs created with the default GITHUB_TOKEN do not trigger pull_request - # workflows (GitHub's recursion guard), which would leave the standing - # main→release PR without a Release CI run until someone touched it. - # Use the same GitHub App the release workflows push with. - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.XGITHUB_APP_ID }} - private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} - # Scope the minted installation token to the minimum this job - # needs: opening a PR. Unlike the release workflows' token, this - # one can NOT push commits, so a compromise of this job cannot - # write to protected branches. (This workflow only ever runs - # trusted code — push:main / dispatch, never pull_request — this - # is defense-in-depth.) - permission-contents: read - permission-pull-requests: write - - - name: Open the main→release PR - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - log() { echo "[ci][prepare-release-pr] $*"; } - - # Re-check under the concurrency group: a parallel run may have - # opened the PR between `check` and this job. - existing="$(gh pr list --repo "${REPO}" --base release --head main --state open --json number --jq '.[0].number // empty')" - if [ -n "${existing}" ]; then - log "open main→release PR appeared meanwhile: #${existing} — nothing to do" - exit 0 - fi - - url="$(gh pr create \ - --repo "${REPO}" \ - --base release \ - --head main \ - --title "Release: merge main into release" \ - --body "$(printf 'Auto-prepared by prepare-release-pr.yml on push to main.\n\nThis PR runs the full Release CI suite (unit + Rust E2E + Playwright + desktop E2E on 3 OSes). Maintainers can push fixes to it; merge once green to advance the release branch, then cut a release with release-staging.yml / release-production.yml.')")" - log "opened ${url}" diff --git a/.github/workflows/promote-main-to-release.yml b/.github/workflows/promote-main-to-release.yml new file mode 100644 index 000000000..7ca0603e6 --- /dev/null +++ b/.github/workflows/promote-main-to-release.yml @@ -0,0 +1,99 @@ +--- +# Promotes main into the long-lived `release` branch on demand. +# +# A maintainer dispatches this workflow to take a snapshot of main: it pushes +# a merge commit from main into `release` directly (no PR). CI Full +# (ci-full.yml) then runs the complete test + E2E matrix on that push. Fix +# PRs for anything the full suite catches are opened directly against +# `release` (they run ci-lite for quick feedback plus ci-full, whose +# "CI Full Gate" check gates the merge). Releases are cut from `release` via +# release-staging.yml / release-production.yml, both of which merge release +# back into main. +# +# Merge, not reset: re-dispatching refreshes release with main's latest while +# preserving fix commits already on release. When release already contains +# main this is a no-op. +name: Promote main to release + +on: + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: promote-main-to-release + cancel-in-progress: false + +jobs: + promote: + name: Merge main into release + runs-on: ubuntu-latest + timeout-minutes: 10 + # The XGITHUB_APP_* secrets live in environments, never at repo level. + # This dedicated environment holds ONLY those two secrets and is branch- + # policied to `main`, so this job never sees the Production environment + # (Apple signing, Tauri updater key, …) and no other ref can claim it. + environment: Release-PR-Automation + steps: + # Pushes made with the default GITHUB_TOKEN do not trigger `push` + # workflows (GitHub's recursion guard), which would leave the promotion + # commit on `release` without a CI Full run. Use the same GitHub App + # the release workflows push with; its identity is on the ruleset + # bypass list for the protected branches. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.XGITHUB_APP_ID }} + private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} + # Least privilege: this job only pushes the promotion merge commit + # to `release` — contents: write is all it needs. + permission-contents: write + + - name: Checkout main + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Merge main into release and push + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + log() { echo "[ci][promote] $*"; } + + 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 main + log "main is at $(git rev-parse origin/main)" + + if ! git fetch origin release 2>/dev/null; then + log "release branch does not exist — bootstrapping it from main" + git push origin origin/main:refs/heads/release + log "created release at $(git rev-parse origin/main)" + exit 0 + fi + log "release is at $(git rev-parse origin/release)" + + if git merge-base --is-ancestor origin/main origin/release; then + log "release already contains main — nothing to promote" + echo "::notice::release already contains main; no promotion needed." + exit 0 + fi + + git checkout -B release origin/release + # Plain merge (fast-forward allowed): ff keeps history linear when + # release carries no fix commits; when it does, git creates the + # merge commit with this message. + if ! git merge origin/main -m "chore(release): merge main into release"; then + git merge --abort + log "merge conflict between main and release" + echo "::error::main→release merge hit conflicts. Resolve by opening a fix PR against release (or merge main into release manually), then re-dispatch." + exit 1 + fi + git push origin HEAD:release + log "release advanced to $(git rev-parse HEAD)" diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 476e8867b..6f13136e7 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -24,11 +24,21 @@ on: required: false type: boolean default: true + skip_ci_gate: + description: + Skip the green-CI-Full-Gate requirement on the commit being cut. + Operator recovery only — never the default release path. + required: false + type: boolean + default: false permissions: # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so # cancelled builds inside container jobs stop themselves (docker exec # swallows the runner's signals). actions: read + # `checks: read` lets scripts/release/require-ci-full-gate.sh verify the + # "CI Full Gate" check run on the commit being cut. + checks: read contents: write packages: write concurrency: @@ -166,6 +176,15 @@ jobs: git checkout release git pull origin release --ff-only fi + # Direct App-token pushes bypass the PR merge gate, so nothing else + # guarantees the commit being cut passed the full suite. Fail unless + # the latest "CI Full Gate" check run on it (skipping [skip ci] bump + # commits) concluded success. + - name: Require green CI Full Gate + if: ${{ !inputs.skip_ci_gate }} + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/release/require-ci-full-gate.sh "$(git rev-parse HEAD)" - name: Compute next version and sync release files id: bump run: node scripts/release/bump-version.js "${{ inputs.release_type }}" @@ -196,7 +215,9 @@ jobs: 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}" + # [skip ci]: the bump commit lands on an already-validated release + # tree — don't re-trigger the full suite (ci-full.yml, push:release). + git commit -m "chore(release): v${VERSION} [skip ci]" git push origin HEAD:release if [ "$CREATE_RELEASE" = "true" ]; then git tag -a "$TAG" -m "Release $TAG" @@ -237,24 +258,7 @@ jobs: env: VERSION: ${{ steps.bump.outputs.version }} run: | - set -euo pipefail - echo "[release-production] merging release back into main" - RELEASE_SHA="$(git rev-parse HEAD)" - git fetch origin main - git checkout -B main origin/main - # Fast-forward when main hasn't moved since the cut — this leaves - # main == release, so prepare-release-pr sees ahead_by=0 and does - # NOT open a no-op main→release PR for the back-merge commit. - if git merge --ff-only "$RELEASE_SHA" 2>/dev/null; then - git push origin HEAD:main - echo "[release-production] fast-forwarded main to $RELEASE_SHA" - elif git merge --no-ff "$RELEASE_SHA" -m "chore(release): merge release v${VERSION} back into main"; then - git push origin HEAD:main - echo "[release-production] merged $RELEASE_SHA into main (merge commit)" - else - echo "::warning::Automatic release→main back-merge hit conflicts. Merge branch 'release' into 'main' manually." - exit 1 - fi + bash scripts/release/merge-release-into-main.sh "chore(release): merge release v${VERSION} back into main" # ========================================================================= # Phase 2: Create draft GitHub release diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 34b27b67c..9913ff760 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -20,11 +20,21 @@ on: required: false type: boolean default: true + skip_ci_gate: + description: + Skip the green-CI-Full-Gate requirement on the commit being cut. + Operator recovery only — never the default release path. + required: false + type: boolean + default: false permissions: # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so # cancelled builds inside container jobs stop themselves (docker exec # swallows the runner's signals). actions: read + # `checks: read` lets scripts/release/require-ci-full-gate.sh verify the + # "CI Full Gate" check run on the commit being cut. + checks: read # `contents: write` is required for the patch bump commit and the # `v-staging` tag push performed by `prepare-build` below. contents: write @@ -141,6 +151,15 @@ jobs: git checkout release git pull origin release --ff-only fi + # Direct App-token pushes bypass the PR merge gate, so nothing else + # guarantees the commit being cut passed the full suite. Fail unless + # the latest "CI Full Gate" check run on it (skipping [skip ci] bump + # commits) concluded success. + - name: Require green CI Full Gate + if: ${{ !inputs.skip_ci_gate }} + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/release/require-ci-full-gate.sh "$(git rev-parse HEAD)" # 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 `release` (we don't maintain a separate `staging` @@ -188,7 +207,9 @@ jobs: 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}" + # [skip ci]: the bump commit lands on an already-validated release + # tree — don't re-trigger the full suite (ci-full.yml, push:release). + git commit -m "chore(staging): v${VERSION} [skip ci]" git push origin HEAD:release if [ "$CREATE_TAG" = "true" ]; then git tag -a "$TAG" -m "Staging cut $TAG" @@ -221,6 +242,19 @@ jobs: fi echo "base_url=https://staging-api.tinyhumans.ai/" >> "$GITHUB_OUTPUT" + # Keep main in sync: the version-bump commit (and anything else on + # release) flows back into main right after the cut — every release + # build back-merges, not just production. Runs in both create_tag + # variants because the bump commit lands on release in both. + # continue-on-error so a merge conflict never strands a cut that is + # already tagged — resolve the merge manually in that case. + - name: Merge release back into main + continue-on-error: true + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + bash scripts/release/merge-release-into-main.sh "chore(staging): merge release v${VERSION}-staging back into main" + # ========================================================================= # Phase 2: Build desktop artifacts (delegated to reusable workflow) # ========================================================================= diff --git a/AGENTS.md b/AGENTS.md index 04608cff0..c409e8279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml **Build speed**: both `Cargo.toml` files set `[profile.dev.package."*"] debug = false` — dependencies compile without DWARF in `dev`/`test` (faster builds + smaller `target/`); our own crates keep full debuginfo so panics/backtraces still resolve to file:line. `release`/`ci` profiles are unchanged. Keep this stanza in sync across the root and `app/src-tauri/Cargo.toml` if you touch profiles. -**Two-branch CI model**: PRs → `main` run the **fast lane** (`pr-ci.yml`): quality checks per changed area plus unit tests **only for the changed files** — `vitest related` for `app/src` changes and domain-scoped `cargo llvm-cov` (libtest filter derived from `src///…`) for Rust — still gated at ≥ 80% diff coverage. Config-level changes (lockfile, Cargo.toml/lock, vitest config, `src/lib.rs`, …) fall back to the full suite (`scripts/ci/vitest-changed-coverage.sh`, `scripts/ci/rust-coverage-changed.sh`). A standing main→release PR (auto-opened by `prepare-release-pr.yml` on every push to main) runs the **full lane** (`release-ci.yml`): complete unit suites, Rust mock-backend E2E, Playwright, and the full desktop E2E matrix on 3 OSes, gated by the `Release CI Gate` check. Releases are cut from the `release` branch (`release-staging.yml` / `release-production.yml` run there and tag on `release`; production takes a `release_type` increment — patch/minor/major — and merges the release branch back into `main` after the cut; staging patch-bumps without a back-merge). Long build/test commands must run through `scripts/ci-cancel-aware.sh`, whose Actions-API watchdog stops cancelled builds inside container jobs (docker exec swallows runner signals). +**Two-lane CI model**: **CI Lite** (`ci-lite.yml`, quick — pushes to `main` + PRs targeting `main` or `release`): quality checks per changed area plus unit tests **only for the changed files** — `vitest related` for `app/src` changes and domain-scoped `cargo llvm-cov` (libtest filter derived from `src///…`) for Rust — still gated at ≥ 80% diff coverage. Config-level changes (lockfile, Cargo.toml/lock, vitest config, `src/lib.rs`, …) fall back to the full suite (`scripts/ci/vitest-changed-coverage.sh`, `scripts/ci/rust-coverage-changed.sh`). **CI Full** (`ci-full.yml`, slow — PRs targeting the long-lived `release` branch + every push to it): complete unit suites, Rust mock-backend E2E, Playwright, and the full desktop E2E matrix on 3 OSes, aggregated by the `CI Full Gate` check (except the Playwright spec run — non-blocking signal while flaky, #3615). `release` advances when a maintainer dispatches `promote-main-to-release.yml` (pushes a merge commit from `main` into `release` — no standing PR) and when fix PRs opened directly against `release` merge (those run both lanes, with `CI Full Gate` blocking the merge; the post-merge push re-runs CI Full). Releases are cut from `release` (`release-staging.yml` / `release-production.yml` run there and tag on `release`; production takes a `release_type` increment — patch/minor/major); **every** cut back-merges `release` into `main` via `scripts/release/merge-release-into-main.sh`, and version-bump commits carry `[skip ci]`. Long build/test commands must run through `scripts/ci-cancel-aware.sh`, whose Actions-API watchdog stops cancelled builds inside container jobs (docker exec swallows runner signals). **CI build topology**: full-suite E2E is **build-once-then-fanout** on all three OSes — `build-{linux,macos,windows}-full` compile/bundle the app once and upload it as a per-run workflow artifact, and the shard jobs (`e2e-*-full`) `needs:` that job and download it instead of each shard rebuilding on a cold cache (`.github/workflows/e2e-reusable.yml`). Linux desktop packaging (`build-desktop.yml`) does a **single** `cargo tauri build`: libcef.so is resolved from the restored CEF cache (or a targeted `cargo build -p cef-dll-sys` prewarm on a cold cache) rather than a throwaway `--no-bundle` full build. The root core crate and the Tauri shell are still **separate Cargo worlds** (two `Cargo.lock`, two `target/`); converging them into one workspace is tracked as follow-up in #3877. @@ -85,7 +85,7 @@ pnpm debug logs last # print most recent ### Coverage requirement (merge gate) -PRs need **≥ 80% coverage on changed lines** via `diff-cover` over Vitest + `cargo-llvm-cov` lcov. Enforced by the coverage jobs (`frontend-coverage`/`rust-core-coverage`/`rust-tauri-coverage`/`coverage-gate`) in `.github/workflows/pr-ci.yml`. +PRs need **≥ 80% coverage on changed lines** via `diff-cover` over Vitest + `cargo-llvm-cov` lcov. Enforced by the coverage jobs (`frontend-coverage`/`rust-core-coverage`/`rust-tauri-coverage`/`coverage-gate`) in `.github/workflows/ci-lite.yml`. --- diff --git a/docs/RELEASE-MANUAL-SMOKE.md b/docs/RELEASE-MANUAL-SMOKE.md index a099b3c92..308b3cfb2 100644 --- a/docs/RELEASE-MANUAL-SMOKE.md +++ b/docs/RELEASE-MANUAL-SMOKE.md @@ -1,6 +1,6 @@ # Release Manual Smoke Checklist -Run this checklist on every release-cut. Sign-off lives in the release PR description (paste the checklist with checked items + the sign-off block at the bottom). Owns OS-level surfaces that drivers cannot assert — everything else is automated under WDIO, Vitest, or Rust integration tests (see [Testing Strategy](../gitbooks/developing/testing-strategy.md)). +Run this checklist on every release-cut. Sign-off lives as a GitHub commit comment on the `v-staging` tagged commit that QA validated (paste the checklist with checked items + the sign-off block at the bottom). Before approving the production run, the `Release-Approval` reviewer checks the sign-off exists **and** that the run targets that validated commit (staging SHA passed as `commit_sha`, or separated from the target only by `[skip ci]` version-bump commits). Owns OS-level surfaces that drivers cannot assert — everything else is automated under WDIO, Vitest, or Rust integration tests (see [Testing Strategy](../gitbooks/developing/testing-strategy.md)). This is the **only** acceptable substitute for a `🚫` row in [`TEST-COVERAGE-MATRIX.md`](./TEST-COVERAGE-MATRIX.md). If a feature has neither automated coverage nor an entry on this checklist, treat it as untested and open a coverage gap. @@ -11,9 +11,9 @@ This is the **only** acceptable substitute for a `🚫` row in [`TEST-COVERAGE-M 1. Build the release artifact for each platform you ship. 2. On a clean machine (or fresh user account), walk through `## Per-release smoke` then the section for the active release line. 3. Tick each box only after you have verified the expected outcome with your own eyes. -4. Paste the completed checklist + sign-off block into the release PR description. +4. Paste the completed checklist + sign-off block as a commit comment on the `v-staging` tagged commit. 5. Any item that is genuinely not applicable for this release: mark `N/A` with a one-line reason; do not silently skip. -6. If `release-staging.yml` was dispatched with `skip_e2e=true`, record the reason and link the most recent relevant green pretest evidence in the PR notes (unit/rust and E2E as applicable). That override is for operator recovery, not the default release path. +6. If `release-staging.yml` or `release-production.yml` was dispatched with `skip_ci_gate=true`, record the reason and link the most recent relevant green CI Full evidence alongside the sign-off. That override is for operator recovery, not the default release path. --- @@ -85,4 +85,4 @@ Platforms tested: [macOS arm64] [macOS x64] [Windows] [Linux .deb] [Linux .AppIm Notes: ``` -Paste the filled block into the release PR description before tagging. +Paste the filled block as a commit comment on the `v-staging` tagged commit before promoting to production. diff --git a/gitbooks/developing/e2e-testing.md b/gitbooks/developing/e2e-testing.md index 0331e8518..af9814a7b 100644 --- a/gitbooks/developing/e2e-testing.md +++ b/gitbooks/developing/e2e-testing.md @@ -166,9 +166,9 @@ CEF cache preflight before Tauri's deep-link forwarding path is installed. ### Push / PR checks -The default pull-request gate is `.github/workflows/pr-ci.yml`. It builds one Linux E2E-compatible desktop artifact, then runs the Linux Appium/Chromium `mega-flow` lane and the Playwright web lane in parallel with Rust and coverage jobs. +The default pull-request gate is `.github/workflows/ci-lite.yml` (quick lane: quality checks + unit tests scoped to the changed files). E2E suites do not run on PRs to `main` — the full E2E matrix (Rust mock-backend, Playwright web, desktop on Linux/macOS/Windows) runs in `.github/workflows/ci-full.yml` on PRs targeting the `release` branch and on every push to it. -macOS and Windows desktop E2E do not run on every PR. Use the manually dispatched E2E workflow, or release pretest workflows, when cross-platform desktop signal is needed. +macOS and Windows desktop E2E do not run on every PR. Use the manually dispatched E2E workflow (`.github/workflows/e2e.yml`) when cross-platform desktop signal is needed before promotion. ### macOS / Appium Chromium diff --git a/gitbooks/developing/release-policy.md b/gitbooks/developing/release-policy.md index 0b106120f..83ba223ed 100644 --- a/gitbooks/developing/release-policy.md +++ b/gitbooks/developing/release-policy.md @@ -37,16 +37,32 @@ Implementation: `app/src/utils/oauthAppVersionGate.ts`, `app/src/utils/desktopDe 2. When dropping support for older installs, set **`VITE_MINIMUM_SUPPORTED_APP_VERSION`** to the new floor **before** or **with** that release (repo Actions variables + both workflow steps above). 3. Remove, redirect, or retire older stable installers and stale **updater** entries from user-facing surfaces (GitHub Release assets, website, CDN, updater feed). Confirm deprecated artifacts are not reachable from default install/update flows. 4. Smoke-test **Gmail connect** on a fresh install from **releases/latest**. -5. Complete the [manual smoke checklist](../../docs/RELEASE-MANUAL-SMOKE.md), then paste the completed sign-off block (verbatim, with every checked item left checked) into the release PR description before tagging. +5. Complete the [manual smoke checklist](../../docs/RELEASE-MANUAL-SMOKE.md), then paste the completed sign-off block (verbatim, with every checked item left checked) as a **GitHub commit comment on the `v-staging` tagged commit** that QA validated (there is no release PR in the promotion flow). Before approving the production run, the `Release-Approval` required reviewer verifies (a) the sign-off comment exists on the staging-tagged commit and (b) the production run actually targets that validated content — pass the staging-tagged SHA as `commit_sha`, or confirm nothing but `[skip ci]` version-bump commits separate it from the run's target. Anything more than bump commits means new content QA never smoked: re-run staging first. + +## Branch model and CI lanes + +Two long-lived branches, two CI lanes: + +- **`main`** — where all feature/fix PRs land. Every PR (and push to main) runs **CI Lite** ([`ci-lite.yml`](../../.github/workflows/ci-lite.yml)): quality checks per changed area plus unit tests scoped to the changed files, gated at ≥ 80% diff coverage by the `PR CI Gate` check. +- **`release`** — a maintainer-promoted snapshot of `main` that releases are cut from. PRs targeting `release` and every push to `release` run **CI Full** ([`ci-full.yml`](../../.github/workflows/ci-full.yml)): complete unit suites, Rust mock-backend E2E, Playwright web E2E, and the full desktop E2E matrix on Linux/macOS/Windows. The `CI Full Gate` check aggregates every lane **except the Playwright spec run**, which is non-blocking signal for now (`continue-on-error`, flaky under CI contention — #3615): a green gate does not prove Playwright specs passed, so check that lane's result in the run before cutting. Only the Playwright artifact *build* is gated. + +The cycle: + +1. A maintainer dispatches [`promote-main-to-release.yml`](../../.github/workflows/promote-main-to-release.yml), which pushes a **merge commit from `main` into `release`** (no PR). Re-dispatching refreshes `release` with main's latest while preserving fix commits already on `release`; when `release` already contains `main` it's a no-op. +2. CI Full runs on the promotion push. If it finds breakage, anyone with write access opens a **fix PR directly against `release`**; fix PRs run both lanes — CI Lite for quick lint/coverage feedback and CI Full as the merge-blocking `CI Full Gate` check — and the post-merge push re-runs CI Full on the merge result. +3. Once CI Full is green on `release` HEAD, cut a build with `release-staging.yml` or `release-production.yml`. Both workflows **enforce** this: `scripts/release/require-ci-full-gate.sh` fails the run unless the latest `CI Full Gate` check on the commit being cut (walking past `[skip ci]` bump commits) concluded success. The `skip_ci_gate` input overrides it for operator recovery only. +4. **Every cut back-merges `release` into `main`** (`scripts/release/merge-release-into-main.sh`: fast-forward when possible, else a versioned merge commit such as `chore(release): merge release v1.2.4 back into main`), so bump commits and fix commits flow back. Version-bump commits carry `[skip ci]` so cutting a build does not re-run CI Full on the already-validated tree. + +Required GitHub settings for this model (repo **Settings → Rules**): `main` requires the `PR CI Gate` status check on PRs; `release` requires PRs for non-bypass actors with the `CI Full Gate` status check required (it runs on PRs targeting release); the release GitHub App's identity sits on the bypass list of both rulesets so the promote/release workflows can push directly. ## Workflows: staging vs. production -Two first-class GitHub Actions workflows, one per environment. Pick by intent rather than toggling a flag. +Two first-class GitHub Actions workflows, one per environment. Pick by intent rather than toggling a flag. Both run from the `release` branch only. | Workflow | Branch | Bumps | Tags pushed | Concurrency group | Use when | | ------------------------------------------------------- | --------- | ------- | -------------------------- | ----------------------- | --------------------------------------------------------------------- | -| [`release-staging.yml`](../../.github/workflows/release-staging.yml) | `main` | `patch` only | `v-staging` | `release-staging` | Cutting a staging build for QA. Runs frequently; narrow semver moves. | -| [`release-production.yml`](../../.github/workflows/release-production.yml) | `main` | `patch` / `minor` / `major` (only on `main_head`) | `v` | `release-production` | Promoting a validated staging tag, or hotfixing from `main` HEAD. | +| [`release-staging.yml`](../../.github/workflows/release-staging.yml) | `release` | `patch` only | `v-staging` | `release-staging` | Cutting a staging build for QA. Runs frequently; narrow semver moves. | +| [`release-production.yml`](../../.github/workflows/release-production.yml) | `release` | `patch` / `minor` / `major` (`release_type` input) | `v` | `release-production` | Shipping a production release from validated `release` HEAD (or a pinned `commit_sha`). | The matrix build / sign / Sentry-DIF / artifact-upload pipeline used by both flows lives in [`.github/workflows/build-desktop.yml`](../../.github/workflows/build-desktop.yml) as a `workflow_call` reusable workflow. The two top-level workflows above own ref resolution, version bumping, tagging, and publish/cleanup; the build itself is shared. @@ -82,42 +98,35 @@ Google Play requires each upload to use a monotonically increasing Android `vers ### Cutting a staging build -1. Run **Release (Staging)** via `workflow_dispatch` from `main`. -2. The workflow bumps `patch` on `main`, commits `chore(staging): vX.Y.Z`, pushes the branch, and creates an immutable `vX.Y.Z-staging` tag at that commit. -3. Build matrix runs from the **tag** (not main HEAD), so reruns rebuild byte-identical content even if `main` has moved on. -4. On failure the staging tag is auto-deleted; the bump commit on `main` stays so the next cut continues from `vX.Y.(Z+1)`. +1. Run **Release (Staging)** via `workflow_dispatch` from `release` (optionally pinning a release-reachable `commit_sha`; `create_tag = false` bumps + commits without tagging or building). +2. The workflow bumps `patch` on `release`, commits `chore(staging): vX.Y.Z [skip ci]`, pushes, and creates an immutable `vX.Y.Z-staging` tag at that commit. +3. Build matrix runs from the **tag** (not release HEAD), so reruns rebuild byte-identical content even if `release` has moved on. +4. The bump commit (and anything else on `release`) is merged back into `main`. +5. On failure the staging tag is auto-deleted; the bump commit on `release` stays so the next cut continues from `vX.Y.(Z+1)`. -There is no separate `staging` branch, staging cuts and production promotions both live on `main`. The two are distinguished only by tag suffix (`-staging` vs none) and by which workflow created the tag. +There is no separate `staging` branch — staging cuts and production releases both live on `release`. The two are distinguished only by tag suffix (`-staging` vs none) and by which workflow created the tag. -### Promoting to production (default flow) +### Shipping a production release -1. Run **Release Production** via `workflow_dispatch` with `release_source = staging_tag` (the default). -2. Leave `staging_tag` blank to promote the latest `v*-staging`, or pass an explicit tag (e.g. `v1.2.4-staging`) to pin. -3. The workflow strips `-staging`, creates `v` at the same commit, and runs the production build matrix from that tag. **No further version bump**, the artifact reuses what staging already validated. - -### Hotfix from `main` HEAD - -1. Run **Release Production** via `workflow_dispatch` with `release_source = main_head` and the desired `release_type` (`patch` / `minor` / `major`). -2. The run first parks on the **`review-approval`** job (`environment: Release-Approval`); a [required reviewer](#release-app-token-approval-gate-and-rotation) must approve before anything is pushed. Only after approval does `prepare-build` run the bump-and-tag path: bump on `main`, commit `chore(release): vX.Y.Z`, push, tag `vX.Y.Z`, build. -3. Use this only when a production-only fix needs to ship without going through staging. - -> The `staging_tag` promotion path is **not** gated by `review-approval`: it pushes only an immutable `v` tag at an already-validated staging commit, never a commit to `main`. It remains gated by the existing `Production` environment. +1. Run **Release Production** via `workflow_dispatch` with the desired `release_type` (`patch` / `minor` / `major`), from `release` HEAD or a pinned release-reachable `commit_sha`. +2. The run first parks on the **`review-approval`** job (`environment: Release-Approval`); a [required reviewer](#release-app-token-approval-gate-and-rotation) must approve before anything is pushed. Only after approval does `prepare-build` run the bump-and-tag path: bump on `release`, commit `chore(release): vX.Y.Z [skip ci]`, push, tag `vX.Y.Z`, build, publish. +3. `release` is merged back into `main` right after the cut. ### Tag policy and rollback -- **Naming.** Staging tags use the SemVer pre-release suffix `-staging` (`v1.2.4-staging`) so they sort *before* the matching production tag. Promotion to production drops the suffix verbatim; the version embedded in the bundled installer is identical between the two tags. +- **Naming.** Staging tags use the SemVer pre-release suffix `-staging` (`v1.2.4-staging`) so they sort *before* the matching production tag. - **Collisions.** Both workflows fail fast if the target tag already exists locally or on `origin`. Resolve by deleting the stale tag (org maintainers only) or bumping past it. -- **Rollback (production).** A failed build matrix triggers `cleanup-failed-release`, which deletes both the draft GitHub Release and the `v` tag. The staging tag it was promoted from is left untouched and can be re-promoted after fixing. -- **Rollback (staging).** A failed staging build deletes the `v-staging` tag. The bump commit on `main` is left in place; the next staging cut continues from the new patch number rather than re-using it (we accept a small “gap” in patch numbers over racing with concurrent merges). +- **Rollback (production).** A failed build matrix triggers `cleanup-failed-release`, which deletes both the draft GitHub Release and the `v` tag. +- **Rollback (staging).** A failed staging build deletes the `v-staging` tag. The bump commit on `release` is left in place; the next staging cut continues from the new patch number rather than re-using it (we accept a small “gap” in patch numbers over racing with concurrent merges). - **Who can delete tags.** Same write-access as `main`. Workflow-driven cleanup deletes run with the workflow's token via `actions/github-script` (the GitHub App token is only used by `prepare-build` for the bump commit + tag push); manual deletes (`git push --delete origin `) require equivalent maintainer permissions. ## Release App token: approval gate and rotation -The `main_head` path of `release-production.yml` bumps the version, **commits to `main`**, and pushes that commit + tag with a GitHub App token (`secrets.XGITHUB_APP_ID` / `secrets.XGITHUB_APP_PRIVATE_KEY`) that **bypasses branch protection**. A leaked private key (via a log, a compromised action, or a misconfigured runner) would let an attacker push arbitrary commits to the default branch ([CWE-250: Execution with Unnecessary Privileges](https://cwe.mitre.org/data/definitions/250.html)). Two controls bound that blast radius. +`release-production.yml` bumps the version, **commits to `release` and back-merges into `main`**, pushing those commits + tag with a GitHub App token (`secrets.XGITHUB_APP_ID` / `secrets.XGITHUB_APP_PRIVATE_KEY`) that **bypasses branch protection**. The same App pushes staging bumps (`release-staging.yml`) and promotion merge commits (`promote-main-to-release.yml`). A leaked private key (via a log, a compromised action, or a misconfigured runner) would let an attacker push arbitrary commits to protected branches ([CWE-250: Execution with Unnecessary Privileges](https://cwe.mitre.org/data/definitions/250.html)). Two controls bound that blast radius. ### Manual approval gate -The `review-approval` job runs **before** `prepare-build` and parks the run on the **`Release-Approval`** GitHub environment, so a human must approve before any push happens. It is scoped to `release_source == 'main_head'`; the `staging_tag` promotion path skips it (it pushes only a tag, never a commit to `main`). +The `review-approval` job runs **before** `prepare-build` and parks every production run on the **`Release-Approval`** GitHub environment, so a human must approve before any push happens. One-time setup (repo **Settings → Environments**): @@ -125,7 +134,7 @@ One-time setup (repo **Settings → Environments**): 2. Under **Deployment protection rules**, enable **Required reviewers** and add the maintainers allowed to authorize a production push to `main`. A reviewer **cannot** approve their own run unless _Prevent self-review_ is left off; for a release gate, keeping a second approver is preferred. 3. (Optional) Set a short **wait timer** of 0. The gate is a human decision, not a delay. -When a `main_head` run starts it shows **"Waiting"** on the `review-approval` job; an approver opens the run and clicks **Review deployments → Approve**. Rejecting (or cancelling) leaves `prepare-build` skipped, so nothing is pushed. +When a production run starts it shows **"Waiting"** on the `review-approval` job; an approver opens the run and clicks **Review deployments → Approve**. Rejecting (or cancelling) leaves `prepare-build` skipped, so nothing is pushed. ### Quarterly key rotation @@ -134,7 +143,7 @@ Rotate `XGITHUB_APP_PRIVATE_KEY` **every quarter** (and immediately on any suspe 1. In the GitHub **App settings** (Org → Settings → Developer settings → GitHub Apps → the release App), under **Private keys** click **Generate a private key**. Download the new `.pem`. 2. Update the repo secret: **Settings → Secrets and variables → Actions → `XGITHUB_APP_PRIVATE_KEY`** → paste the full new key (including the `-----BEGIN/END-----` lines). `XGITHUB_APP_ID` is unchanged. 3. Trigger a low-risk verification run (e.g. **Release (Staging)**) and confirm the **Generate GitHub App token** step succeeds and the push authenticates. - Do not use `main_head` for this check unless you intentionally want to cut a real bump commit and production tag: even with `create_release = false`, `prepare-build` still bumps the version, commits to `main`, and pushes the release tag. + Do not use **Release Production** for this check unless you intentionally want to cut a real bump commit: even with `create_release = false`, `prepare-build` still bumps the version and commits to `release` (staging with `create_tag = false` does too, but skips the tag/build and is the lower-risk probe). 4. Back in **App settings → Private keys**, **delete the old key** so only the freshly-issued one remains valid. 5. Record the rotation date (PR description, ops log, or `docs/OPERATIONS.md`) so the next quarter's owner can see when it last happened. diff --git a/scripts/release/merge-release-into-main.sh b/scripts/release/merge-release-into-main.sh new file mode 100755 index 000000000..51dc8ea5a --- /dev/null +++ b/scripts/release/merge-release-into-main.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Merge the current HEAD of the release checkout back into main and push. +# +# Usage: merge-release-into-main.sh +# +# Called by release-staging.yml and release-production.yml right after the +# version-bump commit (and tag) land on `release`, so main stays in sync with +# everything on release (bump commit + any fix commits). Expects to run in a +# repo checkout whose HEAD is the release commit to merge and whose `origin` +# remote is authenticated for pushing to main (App token remote URL). +# +# Fast-forward is preferred: when main hasn't moved since the promotion cut, +# ff leaves main == release and the next promote-main-to-release.yml dispatch +# sees nothing to promote. Otherwise a --no-ff merge commit with the given +# message is created. Conflicts exit 1 with a warning — callers keep +# continue-on-error so a conflicted back-merge never strands a release that +# is already tagged; resolve the merge manually in that case. +set -euo pipefail + +if [ $# -ne 1 ] || [ -z "$1" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +MERGE_MESSAGE="$1" + +log() { echo "[release][back-merge] $*"; } + +RELEASE_SHA="$(git rev-parse HEAD)" +log "merging release ($RELEASE_SHA) back into main" +git fetch origin main +git checkout -B main origin/main +if git merge --ff-only "$RELEASE_SHA" 2>/dev/null; then + git push origin HEAD:main + log "fast-forwarded main to $RELEASE_SHA" +elif git merge --no-ff "$RELEASE_SHA" -m "$MERGE_MESSAGE"; then + git push origin HEAD:main + log "merged $RELEASE_SHA into main (merge commit)" +else + echo "::warning::Automatic release→main back-merge hit conflicts. Merge branch 'release' into 'main' manually." + exit 1 +fi diff --git a/scripts/release/require-ci-full-gate.sh b/scripts/release/require-ci-full-gate.sh new file mode 100755 index 000000000..4470966ff --- /dev/null +++ b/scripts/release/require-ci-full-gate.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Require a successful "CI Full Gate" check run before cutting a release. +# +# Usage: require-ci-full-gate.sh +# +# Called by release-staging.yml / release-production.yml on the commit they +# are about to bump/tag/build. Direct App-token pushes bypass the PR merge +# gate, so without this check a maintainer could cut a release while CI Full +# is still pending or after it failed on release HEAD. +# +# Version-bump commits carry [skip ci] and therefore never get a CI Full run, +# so first-parent ancestry is walked past them (bounded) to the most recent +# commit that should have one. Needs GH_TOKEN with checks:read and +# GITHUB_REPOSITORY set (both standard in Actions). +set -euo pipefail + +SHA="${1:?usage: require-ci-full-gate.sh }" +CHECK_NAME="CI Full Gate" +MAX_SKIP_DEPTH=10 + +log() { echo "[release][ci-full-gate] $*"; } + +target="$SHA" +depth=0 +while [ "$depth" -le "$MAX_SKIP_DEPTH" ]; do + subject="$(git log -1 --format=%s "$target")" + if [[ "$subject" != *"[skip ci]"* ]]; then + break + fi + log "$target is a [skip ci] commit ('$subject') — checking its first parent instead" + target="$(git rev-parse "${target}^")" + depth=$((depth + 1)) +done +if [ "$depth" -gt "$MAX_SKIP_DEPTH" ]; then + echo "::error::Walked ${MAX_SKIP_DEPTH} [skip ci] commits from ${SHA} without finding a CI-validated commit — something is off with the release history." + exit 1 +fi + +log "requiring a successful '${CHECK_NAME}' check run on ${target}" +result="$(gh api -X GET \ + "repos/${GITHUB_REPOSITORY}/commits/${target}/check-runs" \ + -f check_name="${CHECK_NAME}" -f filter=latest \ + --jq '[(.check_runs[0].status // "missing"), (.check_runs[0].conclusion // "none")] | join(" ")')" +status="${result%% *}" +conclusion="${result##* }" +log "status=${status} conclusion=${conclusion}" + +if [ "${status}" != "completed" ] || [ "${conclusion}" != "success" ]; then + echo "::error::'${CHECK_NAME}' on ${target} is ${status}/${conclusion} — cut releases only from a commit with a green CI Full run (re-run ci-full.yml if needed). The skip_ci_gate input overrides this for operator recovery only." + exit 1 +fi +log "'${CHECK_NAME}' is green on ${target}"