mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-12 18:47:46 -10:00
Extend release-gate dedupe from daily to every 3 days and configure uv publish with UV_PUBLISH_USERNAME=__token__ per PyPI API token guidance.
228 lines
6.2 KiB
JavaScript
228 lines
6.2 KiB
JavaScript
/**
|
|
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
|
|
*/
|
|
|
|
const https = require('https');
|
|
const config = require('./gate-config.js');
|
|
|
|
const PACKAGE_PATHS = config.packagePaths;
|
|
/** 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',
|
|
]);
|
|
|
|
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 toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
|
|
const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
|
|
if (!match) throw new Error('Could not read aiui version from pyproject.toml');
|
|
const current = match[1];
|
|
const target = bumpPatch(current);
|
|
return { current, target, packageName: config.pypiPackageName };
|
|
}
|
|
|
|
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) {
|
|
const runs = await github.rest.actions.listWorkflowRuns({
|
|
owner,
|
|
repo,
|
|
workflow_id: 'pypi-release.yml',
|
|
per_page: 20,
|
|
});
|
|
return runs.data.workflow_runs.some(
|
|
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
|
|
);
|
|
}
|
|
|
|
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);
|
|
const runs = await github.rest.actions.listWorkflowRuns({
|
|
owner,
|
|
repo,
|
|
workflow_id: 'pypi-release.yml',
|
|
status: 'completed',
|
|
per_page: 30,
|
|
});
|
|
return runs.data.workflow_runs.some(
|
|
(r) => r.conclusion === 'success' && new Date(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 lastGreenCiSha(github, owner, repo) {
|
|
const runs = await github.rest.actions.listWorkflowRuns({
|
|
owner,
|
|
repo,
|
|
workflow_id: config.ciWorkflowFile,
|
|
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: '',
|
|
targetVersion: '',
|
|
packageName: config.pypiPackageName,
|
|
};
|
|
|
|
if (bump !== 'patch') {
|
|
reasons.push('only patch auto-release supported');
|
|
return out;
|
|
}
|
|
|
|
if (await hasActiveReleaseRun(github, owner, repo)) {
|
|
reasons.push('PyPI Release already in progress or awaiting approval');
|
|
return out;
|
|
}
|
|
|
|
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
|
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.targetVersion = versions.target;
|
|
|
|
const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
|
|
if (onPypi) {
|
|
reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
|
|
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 lastGreenCiSha(github, owner, repo);
|
|
if (greenSha !== evalSha) {
|
|
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
|
|
return out;
|
|
}
|
|
}
|
|
|
|
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,
|
|
releaseIntervalStart,
|
|
hasSuccessfulReleaseWithinDays,
|
|
hasSuccessfulReleaseToday,
|
|
utcDayStart,
|
|
evaluateReleasePreflight,
|
|
lastGreenCiSha,
|
|
ACTIVE_RELEASE_STATUSES,
|
|
PACKAGE_PATHS,
|
|
};
|