mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix release workflow limits and branch selection (#4866)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user