diff --git a/.github/workflows/promote-main-to-release.yml b/.github/workflows/promote-main-to-release.yml index 983fa9b7e..54952af11 100644 --- a/.github/workflows/promote-main-to-release.yml +++ b/.github/workflows/promote-main-to-release.yml @@ -6,9 +6,9 @@ # (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. +# "CI Full Gate" check gates the merge). Production releases are cut from +# `release`; staging may be cut from `main` or `release`. Cuts sourced from +# `release` merge it 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 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index c15c7a901..b9f4ee4a8 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -24,21 +24,11 @@ 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: @@ -50,7 +40,8 @@ concurrency: # Branch model: releases are cut from the long-lived `release` branch, not # `main`. `release` only advances via green main→release PRs that run the # full test suite; this workflow bumps/commits/tags on `release` and the -# release is built from the tag this workflow creates on `release`. +# release is built from the tag this workflow creates on `release`. The +# workflow-dispatch ref does not select the production source branch. # # Job dependency graph # @@ -122,11 +113,6 @@ jobs: build_ref: ${{ steps.resolve.outputs.build_ref }} base_url: ${{ steps.resolve.outputs.base_url }} steps: - - name: Enforce release branch - if: github.ref != 'refs/heads/release' - run: | - echo "This workflow can only run from release. Current ref: $GITHUB_REF" - exit 1 - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v3 @@ -178,15 +164,6 @@ 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 }}" diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 053c97b5b..c1723ea73 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -5,36 +5,25 @@ on: inputs: commit_sha: description: - Build from a specific commit SHA instead of release HEAD. Leave empty - to use release HEAD (the default). The commit must be reachable from - release. + Build from a specific commit SHA instead of the selected branch HEAD. + The commit must be reachable from the selected main or release branch. 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 release but - skip tag creation, desktop builds, and Docker — useful for + build matrix. When false, bump versions on the selected branch but + skip tag creation, desktop builds, and Docker; useful for version-bump-only runs. 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 @@ -43,10 +32,9 @@ concurrency: group: release-staging cancel-in-progress: false # --------------------------------------------------------------------------- -# Branch model: releases are cut from the long-lived `release` branch, not -# `main`. `release` only advances via green main→release PRs that run the -# full test suite; this workflow bumps/commits/tags on `release` and the -# build matrix is built from the tag this workflow creates on `release`. +# Branch model: staging builds may be cut from `main` or `release`, selected +# by the workflow-dispatch ref. The version bump is committed back to the +# selected branch, and the build matrix uses the immutable staging tag. # # Job dependency graph # @@ -84,8 +72,7 @@ jobs: outputs: version: ${{ steps.resolve.outputs.version }} # Immutable staging tag created by this run, e.g. `v1.2.4-staging`. - # Downstream consumers (release-production.yml `staging_tag` promotion, - # Sentry, installer asset names) reference this rather than the bare SHA. + # Sentry and installer asset names reference this rather than the bare SHA. tag: ${{ steps.resolve.outputs.tag }} sha: ${{ steps.resolve.outputs.sha }} # First 12 chars of `sha`. Matches the truncation runtime code in @@ -97,10 +84,10 @@ jobs: build_ref: ${{ steps.resolve.outputs.build_ref }} base_url: ${{ steps.resolve.outputs.base_url }} steps: - - name: Enforce release branch - if: github.ref != 'refs/heads/release' + - name: Enforce staging source branch + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/release' run: | - echo "This workflow can only run from release. Current ref: $GITHUB_REF" + echo "This workflow can only run from main or release. Current ref: $GITHUB_REF" exit 1 - name: Generate GitHub App token id: app-token @@ -113,10 +100,10 @@ jobs: # is all it needs. Branch-protection bypass comes from the App's # identity in the ruleset bypass list, not from token scopes. permission-contents: write - - name: Checkout release + - name: Checkout staging source uses: actions/checkout@v7 with: - ref: release + ref: ${{ github.ref_name }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} submodules: recursive @@ -133,6 +120,7 @@ jobs: env: APP_TOKEN: ${{ steps.app-token.outputs.token }} COMMIT_SHA: ${{ inputs.commit_sha }} + SOURCE_BRANCH: ${{ github.ref_name }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -144,30 +132,19 @@ jobs: exit 1 } git rev-parse --verify "${COMMIT_SHA}^{commit}" >/dev/null - git merge-base --is-ancestor "$COMMIT_SHA" origin/release || { - echo "commit_sha $COMMIT_SHA is not reachable from release" + git merge-base --is-ancestor "$COMMIT_SHA" "origin/$SOURCE_BRANCH" || { + echo "commit_sha $COMMIT_SHA is not reachable from $SOURCE_BRANCH" exit 1 } git checkout --detach "$COMMIT_SHA" else - git checkout release - git pull origin release --ff-only + git checkout "$SOURCE_BRANCH" + git pull origin "$SOURCE_BRANCH" --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` - # branch) and the immutable `v-staging` tag pinpoints the - # exact release commit QA validated, so production promotion can later - # find the tagged commit reachable from release. + # The bump commit lands on the selected source branch and the immutable + # `v-staging` tag pinpoints the exact commit QA validated. - name: Bump patch version id: bump run: node scripts/release/bump-version.js patch @@ -207,12 +184,12 @@ jobs: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.tagname.outputs.tag }} CREATE_TAG: ${{ inputs.create_tag }} + SOURCE_BRANCH: ${{ github.ref_name }} 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 - # [skip ci]: the bump commit lands on an already-validated release - # tree — don't re-trigger the full suite (ci-full.yml, push:release). + # Avoid rebuilding the source branch solely for version metadata. git commit -m "chore(staging): v${VERSION} [skip ci]" - git push origin HEAD:release + git push origin "HEAD:$SOURCE_BRANCH" if [ "$CREATE_TAG" = "true" ]; then git tag -a "$TAG" -m "Staging cut $TAG" git push origin "$TAG" @@ -251,6 +228,7 @@ jobs: # 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 + if: github.ref == 'refs/heads/release' continue-on-error: true env: VERSION: ${{ steps.bump.outputs.version }} diff --git a/AGENTS.md b/AGENTS.md index e4d86eb78..5c05931e2 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-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). +**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). Production releases are always cut from `release`; staging builds may be cut from `main` or `release` by selecting that workflow-dispatch ref. Release-source cuts back-merge `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. diff --git a/docs/RELEASE-MANUAL-SMOKE.md b/docs/RELEASE-MANUAL-SMOKE.md index 308b3cfb2..13521f6a6 100644 --- a/docs/RELEASE-MANUAL-SMOKE.md +++ b/docs/RELEASE-MANUAL-SMOKE.md @@ -13,7 +13,6 @@ This is the **only** acceptable substitute for a `🚫` row in [`TEST-COVERAGE-M 3. Tick each box only after you have verified the expected outcome with your own eyes. 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` 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. --- diff --git a/gitbooks/developing/release-policy.md b/gitbooks/developing/release-policy.md index 83ba223ed..6534e4344 100644 --- a/gitbooks/developing/release-policy.md +++ b/gitbooks/developing/release-policy.md @@ -50,18 +50,18 @@ 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. +3. Once CI Full is green on `release` HEAD, cut production with `release-production.yml`. Staging may instead be dispatched from `main` when QA needs to validate main before promotion. The release workflows do not query or enforce the `CI Full Gate`; operators verify the relevant CI evidence before cutting. +4. A cut sourced from `release` 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. A staging cut sourced from `main` needs no back-merge. Version-bump commits carry `[skip ci]`. 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. Both run from the `release` branch only. +Two first-class GitHub Actions workflows, one per environment. Pick by intent rather than toggling a flag. Staging follows the selected `main` or `release` dispatch ref; production always checks out `release`, regardless of the dispatch ref shown by GitHub's workflow UI. | Workflow | Branch | Bumps | Tags pushed | Concurrency group | Use when | | ------------------------------------------------------- | --------- | ------- | -------------------------- | ----------------------- | --------------------------------------------------------------------- | -| [`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-staging.yml`](../../.github/workflows/release-staging.yml) | `main` or `release` | `patch` only | `v-staging` | `release-staging` | Cutting a staging build for QA from the selected branch. | | [`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. @@ -117,12 +117,12 @@ There is no separate `staging` branch — staging cuts and production releases b - **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. -- **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). +- **Rollback (staging).** A failed staging build deletes the `v-staging` tag. The bump commit on the selected source branch 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 -`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. +`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 to the selected `main` or `release` source (`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 diff --git a/scripts/__tests__/generate-release-notes.test.mjs b/scripts/__tests__/generate-release-notes.test.mjs index 9f7af29ba..6b6c560aa 100644 --- a/scripts/__tests__/generate-release-notes.test.mjs +++ b/scripts/__tests__/generate-release-notes.test.mjs @@ -15,11 +15,11 @@ import { test('release notes args default to the latest GitHub release as the start ref', () => { assert.equal(parseArgs([]).from, 'latest-release'); - const parsed = parseArgs(['--from', 'v1.0.0', '--to', 'main', '--no-ai', '--max-prs', '5']); + const parsed = parseArgs(['--from', 'v1.0.0', '--to', 'main', '--no-ai']); assert.equal(parsed.from, 'v1.0.0'); assert.equal(parsed.to, 'main'); assert.equal(parsed.noAi, true); - assert.equal(parsed.maxPrs, 5); + assert.equal(parsed.maxPrs, undefined); }); test('GitHub repo is inferred from ssh and https remotes', () => { @@ -60,6 +60,31 @@ test('OpenAI request contains required release sections and compare payload', () assert.match(request.input[1].content, /https:\/\/github\.com\/tinyhumansai\/openhuman\/compare\/v1\.0\.0\.\.\.main/); }); +test('OpenAI request compacts release ranges larger than the prompt budget', () => { + const pullRequests = Array.from({ length: 300 }, (_, index) => ({ + number: index + 1, + title: `Release improvement ${index + 1}`, + url: `https://github.com/tinyhumansai/openhuman/pull/${index + 1}`, + author: `contributor-${index + 1}`, + labels: ['enhancement'], + body: 'x'.repeat(700), + commits: [{ sha: `${index + 1}`, subject: `Release improvement ${index + 1}` }], + })); + const payload = buildReleasePayload({ + from: 'v1.0.0', + to: 'main', + resolvedTo: 'main', + repo: 'tinyhumansai/openhuman', + commits: [], + contributors: [], + pullRequests, + }); + + const request = buildOpenAiRequest({ model: 'gpt-5.2', title: 'Large release', payload }); + assert.match(request.input[1].content, /Release improvement 300/); + assert.doesNotMatch(request.input[1].content, /x{700}/); +}); + test('release payload omits contributor emails before AI summarization', () => { const payload = buildReleasePayload({ from: 'v1.0.0', diff --git a/scripts/release/generate-release-notes.mjs b/scripts/release/generate-release-notes.mjs index 1bec54a52..fa2ed47a0 100644 --- a/scripts/release/generate-release-notes.mjs +++ b/scripts/release/generate-release-notes.mjs @@ -24,7 +24,6 @@ Options: --output Write generated Markdown to a file instead of stdout. --no-ai Build deterministic Markdown without calling OpenAI. --dry-run Print the OpenAI prompt/input JSON without calling OpenAI. - --max-prs Refuse ranges with more than n PRs. Defaults to 250. --help Show this help. Environment: @@ -52,7 +51,6 @@ export function parseArgs(argv) { output: null, noAi: false, dryRun: false, - maxPrs: 250, }; for (let index = 0; index < argv.length; index += 1) { @@ -79,12 +77,6 @@ export function parseArgs(argv) { options.model = readValue(arg); } else if (arg === '--output' || arg === '-o') { options.output = readValue(arg); - } else if (arg === '--max-prs') { - const parsed = Number.parseInt(readValue(arg), 10); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error('--max-prs must be a positive integer'); - } - options.maxPrs = parsed; } else if (arg === '--no-ai') { options.noAi = true; } else if (arg === '--dry-run') { @@ -295,12 +287,9 @@ function fetchPullRequest(repo, number) { } } -function collectPullRequests(repo, commits, maxPrs) { +function collectPullRequests(repo, commits) { const prCommits = collectPrCommits(commits); const numbers = [...prCommits.keys()].sort((a, b) => a - b); - if (numbers.length > maxPrs) { - throw new Error(`Range contains ${numbers.length} PRs, above --max-prs ${maxPrs}`); - } return numbers.map((number) => { const detail = fetchPullRequest(repo, number); @@ -373,12 +362,7 @@ export function buildReleasePayload({ from, to, resolvedTo, repo, commits, pullR } export function buildOpenAiRequest({ model, title, payload }) { - const compactPayload = JSON.stringify(payload); - if (compactPayload.length > MAX_PROMPT_CHARS) { - throw new Error( - `Release payload is ${compactPayload.length} characters, above ${MAX_PROMPT_CHARS}. Use a narrower range.`, - ); - } + const compactPayload = serializeOpenAiPayload(payload); return { model, @@ -413,6 +397,49 @@ ${compactPayload}`, }; } +function serializeOpenAiPayload(payload) { + const fullPayload = JSON.stringify(payload); + if (fullPayload.length <= MAX_PROMPT_CHARS) { + return fullPayload; + } + + const compact = { + ...payload, + contributors: payload.contributors.map(({ name, isNew }) => ({ name, isNew })), + pullRequests: payload.pullRequests.map(({ number, title, url, author, labels }) => ({ + number, + title, + url, + author, + labels, + })), + uncategorizedCommits: payload.uncategorizedCommits.map(({ subject, authorName }) => ({ + subject, + authorName, + })), + }; + const compactPayload = JSON.stringify(compact); + if (compactPayload.length <= MAX_PROMPT_CHARS) { + return compactPayload; + } + + const bounded = { + ...compact, + pullRequests: [], + omittedPullRequests: compact.pullRequests.length, + }; + for (const pullRequest of compact.pullRequests) { + bounded.pullRequests.push(pullRequest); + bounded.omittedPullRequests -= 1; + if (JSON.stringify(bounded).length > MAX_PROMPT_CHARS) { + bounded.pullRequests.pop(); + bounded.omittedPullRequests += 1; + break; + } + } + return JSON.stringify(bounded); +} + function getOpenAiKey() { if (process.env.OPENAI_API_KEY) { return process.env.OPENAI_API_KEY; @@ -646,7 +673,7 @@ async function main() { console.error(`[release-notes] Collecting ${repo} changes from ${from} to ${resolvedTo}`); const commits = collectCommits(from, resolvedTo); const contributors = collectContributorStats(commits, priorAuthorKeys(from)); - const pullRequests = collectPullRequests(repo, commits, options.maxPrs); + const pullRequests = collectPullRequests(repo, commits); const payload = buildReleasePayload({ from, to: options.to, diff --git a/scripts/release/require-ci-full-gate.sh b/scripts/release/require-ci-full-gate.sh deleted file mode 100755 index 4470966ff..000000000 --- a/scripts/release/require-ci-full-gate.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/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}"