fix release workflow limits and branch selection (#4866)

This commit is contained in:
Steven Enamakel
2026-07-14 20:54:58 +03:00
committed by GitHub
parent 6c219fb8a5
commit caf44cfe24
9 changed files with 109 additions and 155 deletions
@@ -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
+2 -25
View File
@@ -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 }}"
+24 -46
View File
@@ -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<version>-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<version>-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<version>-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<version>-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 }}
+1 -1
View File
@@ -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/<a>/<b>/…`) 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/<a>/<b>/…`) 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.
-1
View File
@@ -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<version>-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.
---
+6 -6
View File
@@ -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<version>-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<version>-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<version>` | `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<version>` tag.
- **Rollback (staging).** A failed staging build deletes the `v<version>-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<version>-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 <tag>`) 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
@@ -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',
+46 -19
View File
@@ -24,7 +24,6 @@ Options:
--output <file> 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 <n> 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,
-52
View File
@@ -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 <sha>
#
# 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 <sha>}"
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}"