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
@@ -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}"