Files
MervinPraisonandClaude Fable 5 0ca67c6b47 ci(release): harden the pipeline and add first-class single-package releases
Multi-agent gap review of the release pipeline surfaced 17 findings across
the gate, the workflow, and the bump script. This fixes the confirmed ones.

Gate (release-gate.js + nightly-release-gate.yml):
- A pypi-release run stuck in `waiting` (environment approval) blocked all
  auto-releases with no age limit -- GitHub only auto-fails unapproved
  deployments after 30 days, and exactly this stalled releases for 16h this
  week. Waiting runs older than 6h are now ignored (with a warning linking
  the run); in_progress/queued runs still block unconditionally, and the
  stuck run is never cancelled -- if later approved, the concurrency group
  serializes it and pypi_exists no-ops anything already published.
- PACKAGE_PATHS covered 3 of 9 published packages, so a change landing only
  in e.g. praisonai-bot was never auto-released. Now lists all nine.
- The 3-day dedupe counted any successful run -- including dry_run=true,
  which publishes nothing -- blocking real releases for 3 days after a dry
  run. Dedupe now keys on v* GitHub releases, which only real releases create.
- The nightly path required last-green-Core-Tests SHA == HEAD exactly; a
  docs-only or [skip ci] commit at the tip (the release safety-net commit
  itself!) stalled the cron path indefinitely. A green ancestor with no
  package-path changes since is now accepted.
- Replaced the dead 'Bump praisonai' workflow_run filter (those commits no
  longer exist) with 'chore(release):', and wired the previously never-run
  release-gate selftest into the gate as a step.

Workflow (pypi-release.yml):
- New `only` input: a dedicated single-package release is one dropdown pick
  instead of eight skip flags. A "Resolve effective skips" step is the single
  source of truth; the 19 publish/wait conditions are untouched (they gate
  via the seeded pypi_exists outputs).
- PyPI existence probes now distinguish 404 (publish) from outages (fail the
  run before anything is published) instead of treating any error as "not
  published" and then dying on duplicate upload midway.
- The token-refresh step is continue-on-error and its consumer only swaps
  tokens on success -- a failed re-mint previously overwrote a possibly-valid
  token with an empty string, guaranteeing the safety net could not push.
- The safety net now (a) reverts uncommitted wrapper-side rewrites when the
  wrapper release didn't complete, so a wrapper version that never reached
  PyPI is not committed and then skipped past forever, and (b) retries
  rebase+push 3x preferring the version bumps on conflict -- losing that
  commit deadlocks future auto-releases ("already published" forever).
- "Verify PyPI versions" actually probes PyPI now; it previously echoed
  checkmarks derived from input flags for publishes that may never have run.
- Dedicated releases get per-package tags (praisonai-mcp-v0.0.12) since the
  wrapper's "Release v..." tag doesn't exist on that path; only packages
  confirmed on PyPI are tagged.
- uv publish --check-url makes re-uploads idempotent (partial-upload
  recovery); dry-run summary now includes sandbox/deploy and the effective
  skip plan.

Bump script (bump_and_release.py):
- A conflicted `git pull --rebase` previously printed "trying to continue"
  and proceeded from a mid-rebase detached HEAD -- tagging the wrong commit
  and cascading conflict markers into the safety net. It now aborts, retries
  preferring the release commit, and exits before pushing if that fails.
- Commit failures are no longer swallowed (only "nothing to commit" is
  tolerated), so the tag can't silently point at the previous HEAD.
- `gh release create` is idempotent (view-then-create), unblocking reruns
  after a transient failure between push and wrapper publish.
- Tag push is scoped to the release's own tag; `git push --tags -f` could
  force-move historical tags.

Verified: node selftest (10 checks incl. new stale-waiting/dry-run-dedupe
cases), py_compile, YAML parse, actionlint (same 3 pre-existing SC2129
style warnings as main, nothing new), and the rebase-conflict retry logic
exercised against a scratch remote (bump wins, upstream commit preserved).

Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
2026-08-05 07:52:22 +01:00

316 lines
10 KiB
JavaScript

/**
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks.
*/
const https = require('https');
/** Every directory whose changes should make a release eligible — all nine
* published packages (mirrors the skip_* inputs in pypi-release.yml). */
const PACKAGE_PATHS = [
'src/praisonai',
'src/praisonai-agents',
'src/praisonai-code',
'src/praisonai-bot',
'src/praisonai-train',
'src/praisonai-browser',
'src/praisonai-mcp',
'src/praisonai-sandbox',
'src/praisonai-deploy',
];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
/** A run stuck in `waiting` (environment approval) longer than this no longer
* blocks the gate. GitHub only auto-fails unapproved deployments after 30
* days, so without a cutoff one forgotten manual dispatch stalls all
* auto-releases for up to a month. The run is warned about, never cancelled —
* if later approved, the pypi-release concurrency group serializes it and its
* pypi_exists checks no-op anything already published. */
const STALE_WAITING_HOURS = 6;
function bumpPatch(version) {
const parts = version.split('.');
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
}
function readVersionsFromTree(root = '.') {
const fs = require('fs');
const path = require('path');
const agentsToml = fs.readFileSync(
path.join(root, 'src/praisonai-agents/pyproject.toml'), 'utf8'
);
const agentsMatch = agentsToml.match(/^version\s*=\s*"([^"]+)"/m);
if (!agentsMatch) throw new Error('Could not read agents version');
const codeToml = fs.readFileSync(
path.join(root, 'src/praisonai-code/pyproject.toml'), 'utf8'
);
const codeMatch = codeToml.match(/^version\s*=\s*"([^"]+)"/m);
if (!codeMatch) throw new Error('Could not read praisonai-code version');
const wrapperPy = fs.readFileSync(
path.join(root, 'src/praisonai/praisonai/version.py'), 'utf8'
);
const wrapperMatch = wrapperPy.match(/__version__ = "([^"]+)"/);
if (!wrapperMatch) throw new Error('Could not read wrapper version');
return {
currentAgents: agentsMatch[1],
currentCode: codeMatch[1],
currentWrapper: wrapperMatch[1],
targetAgents: bumpPatch(agentsMatch[1]),
targetCode: bumpPatch(codeMatch[1]),
targetWrapper: bumpPatch(wrapperMatch[1]),
};
}
function pypiVersionExists(packageName, version) {
return new Promise((resolve) => {
const req = https.get(
`https://pypi.org/pypi/${packageName}/${version}/json`,
{ timeout: 15000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
async function hasActiveReleaseRun(github, owner, repo, now = new Date(), core = null) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
const staleCutoff = now.getTime() - STALE_WAITING_HOURS * 60 * 60 * 1000;
return runs.data.workflow_runs.some((r) => {
if (!ACTIVE_RELEASE_STATUSES.has(r.status) || r.conclusion) return false;
if (r.status === 'waiting' && new Date(r.created_at).getTime() < staleCutoff) {
if (core) {
core.warning(
`Ignoring release run waiting >${STALE_WAITING_HOURS}h for environment approval: `
+ `${r.html_url} — approve or cancel it. It no longer blocks auto-releases.`
);
}
return false;
}
return true;
});
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
// Dedupe on GitHub releases (v* tags) rather than run conclusions: a
// dry_run=true dispatch concludes 'success' without publishing anything and
// must not consume the release window. Only a real full release creates a
// v* GitHub release (bump_and_release.py's `gh release create`).
const releases = await github.rest.repos.listReleases({
owner,
repo,
per_page: 10,
});
return releases.data.some(
(r) => r.tag_name && r.tag_name.startsWith('v')
&& new Date(r.published_at || r.created_at) >= windowStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'test-core.yml',
branch: 'main',
status: 'completed',
per_page: 30,
});
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
return hit ? hit.head_sha : '';
}
async function evaluateReleasePreflight(github, owner, repo, options, core) {
const {
headSha,
isCiTrigger = false,
bump = 'patch',
} = options;
const reasons = [];
const out = {
ready: false,
reasons,
headSha: headSha || '',
lastTag: '',
targetAgents: '',
targetCode: '',
targetWrapper: '',
};
if (bump !== 'patch') {
reasons.push('only patch auto-release supported');
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasActiveReleaseRun(github, owner, repo, referenceTime, core)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
return out;
}
let versions;
try {
versions = readVersionsFromTree();
} catch (err) {
reasons.push(err.message);
return out;
}
out.targetAgents = versions.targetAgents;
out.targetCode = versions.targetCode;
out.targetWrapper = versions.targetWrapper;
const agentsOnPypi = await pypiVersionExists('praisonaiagents', versions.targetAgents);
const codeOnPypi = await pypiVersionExists('praisonai-code', versions.targetCode);
const wrapperOnPypi = await pypiVersionExists('praisonai', versions.targetWrapper);
if (agentsOnPypi && codeOnPypi && wrapperOnPypi) {
reasons.push(
`already published: praisonaiagents==${versions.targetAgents}, `
+ `praisonai-code==${versions.targetCode}, praisonai==${versions.targetWrapper}`
);
return out;
}
const { execSync } = require('child_process');
let lastTag = '';
try {
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
reasons.push('no v* tag found');
return out;
}
out.lastTag = lastTag;
let changed = '';
try {
changed = execSync(
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
} catch {
reasons.push('git diff failed');
return out;
}
if (!changed) {
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
return out;
}
if (isCiTrigger) {
let mainSha = '';
try {
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
} catch {
reasons.push('could not resolve origin/main');
return out;
}
if (headSha !== mainSha) {
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
return out;
}
out.headSha = headSha;
} else {
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
// Core Tests has path filters and honors [skip ci] (the release
// safety-net commit uses it), so the tip of main may legitimately have
// no Core Tests run of its own — e.g. a docs-only commit, or the
// "chore(release): … [skip ci]" commit right after a release. Accept a
// green ancestor when nothing under the released package paths changed
// after it; otherwise the cron path stalls until the next src/** push.
let greenAncestorOk = false;
if (greenSha && /^[0-9a-f]{40}$/.test(greenSha)) {
try {
execSync(`git merge-base --is-ancestor ${greenSha} ${evalSha}`);
const delta = execSync(
`git diff --name-only ${greenSha} ${evalSha} -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
greenAncestorOk = delta === '';
} catch {
greenAncestorOk = false;
}
}
if (!greenAncestorOk) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
if (core) {
core.info(
`HEAD ${evalSha.slice(0, 7)} has no Core Tests run; accepting green ancestor `
+ `${greenSha.slice(0, 7)} (no package-path changes since).`
);
}
}
}
out.ready = true;
out.reasons = ['ready'];
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
return out;
}
module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
STALE_WAITING_HOURS,
releaseIntervalStart,
hasActiveReleaseRun,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
ACTIVE_RELEASE_STATUSES,
PACKAGE_PATHS,
};