Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 77 additions & 36 deletions .github/scripts/release-gate-selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,71 +11,112 @@ assert.ok(versions.currentCode);
assert.ok(versions.currentWrapper);
assert.ok(versions.targetCode);
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
// Every released package must make the gate's path-change check eligible.
for (const p of [
'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',
]) {
assert.ok(rg.PACKAGE_PATHS.includes(p), `PACKAGE_PATHS missing ${p}`);
}

const noonUtc = new Date('2026-07-08T12:00:00Z');
const dayStart = rg.utcDayStart(noonUtc);
assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');

(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);

const mockGithub = {
function mockGithub({ runs = [], releases = [] } = {}) {
return {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
},
repos: {
listReleases: async () => ({ data: releases }),
},
},
};
}

(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);

assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);

// Dedupe is release-based: a v* release inside the window blocks…
const recentRelease = mockGithub({
releases: [{ tag_name: 'v4.6.159', published_at: '2026-07-08T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseWithinDays(recentRelease, 'o', 'r', noonUtc),
true
);

const result = await rg.evaluateReleasePreflight(
mockGithub, 'o', 'r',
recentRelease, 'o', 'r',
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));

const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
// …an old release does not…
const oldRelease = mockGithub({
releases: [{ tag_name: 'v4.6.150', published_at: '2026-07-04T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseWithinDays(oldRelease, 'o', 'r', noonUtc),
false
);

// …and a dry-run "success" (workflow run, no release) does not consume the
// window: only real releases count.
const dryRunOnly = mockGithub({
runs: [{ conclusion: 'success', created_at: '2026-07-08T09:00:00Z', status: 'completed' }],
releases: [],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(dryRunOnly, 'o', 'r', noonUtc),
false
);

// Active-run blocking: fresh waiting run blocks…
const freshWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T11:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(freshWaiting, 'o', 'r', noonUtc, null),
true
);

// …a waiting run stuck longer than STALE_WAITING_HOURS no longer blocks…
const staleWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T01:00:00Z', html_url: 'x' }],
});
const warnings = [];
const fakeCore = { warning: (m) => warnings.push(m), info: () => {} };
assert.strictEqual(rg.STALE_WAITING_HOURS, 6);
assert.strictEqual(
await rg.hasActiveReleaseRun(staleWaiting, 'o', 'r', noonUtc, fakeCore),
false
);
assert.ok(warnings.length === 1 && warnings[0].includes('approve or cancel'));

// …but an in_progress run always blocks regardless of age.
const oldInProgress = mockGithub({
runs: [{ status: 'in_progress', conclusion: null, created_at: '2026-07-07T01:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(oldInProgress, 'o', 'r', noonUtc, null),
true
);

console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: PACKAGE_PATHS covers all 9 released packages');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: hasSuccessfulReleaseWithinDays');
console.log('ok: release-based dedupe (recent blocks, old does not)');
console.log('ok: dry-run success does not consume the release window');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: fresh waiting run blocks; stale waiting run ignored with warning');
console.log('ok: in_progress always blocks');
})();
93 changes: 78 additions & 15 deletions .github/scripts/release-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@

const https = require('https');

const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
/** 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('.');
Expand Down Expand Up @@ -65,16 +84,27 @@ function pypiVersionExists(packageName, version) {
});
}

async function hasActiveReleaseRun(github, owner, repo) {
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,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
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;
Comment on lines +97 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching release-gate.js:"
fd -a 'release-gate\.js$' . || true

if [ -f .github/scripts/release-gate.js ]; then
  echo
  echo "Outline:"
  ast-grep outline .github/scripts/release-gate.js || true
  echo
  echo "Relevant lines:"
  sed -n '1,180p' .github/scripts/release-gate.js | cat -n
fi

echo
echo "Search for stale/waiting logic elsewhere:"
rg -n "STALE_WAITING_HOURS|waiting|created_at|updated_at|waitingCutoff|staleCutoff|env approval" .github scripts . 2>/dev/null | head -200

Repository: MervinPraison/PraisonAI

Length of output: 29936


🌐 Web query:

GitHub Actions API list workflow runs created_at updated_at running workflow run status waiting

💡 Result:

To list GitHub Actions workflow runs via the GitHub REST API, you can use the endpoints for repositories or specific workflows [1][2]. The primary endpoint to list workflow runs for a repository is: GET /repos/{owner}/{repo}/actions/runs The API supports filtering by status and creation time [1][2]. Status filtering: You can use the status parameter to filter workflow runs [1]. While the documentation groups potential values, valid status strings include queued, in_progress, completed, waiting, pending, and requested [1][3]. Specifically, the waiting status is used by GitHub Actions in certain scenarios [1]. Time-based filtering: You can filter by creation time using the created parameter [1][2]. This parameter accepts a date-time range syntax (e.g., YYYY-MM-DD or ISO 8601 format) to narrow down results based on when the workflow run was created [1]. Workflow run object data: The response object for a workflow run explicitly includes the created_at and updated_at fields, which are returned as strings in ISO 8601 date-time format [4]. For more information on the syntax for the created parameter, consult the official GitHub documentation on search syntax [1][4].

Citations:


🌐 Web query:

GitHub Actions workflow run object created_at updated_at documentation updates on waiting status

💡 Result:

In the GitHub Actions REST API, the Workflow Run object explicitly includes created_at and updated_at fields [1][2]. Both are returned as strings in ISO 8601 date-time format [1][3]. The created_at field denotes when the workflow run was initialized, while updated_at indicates the last time the workflow run's status or properties were modified [1][4]. Regarding the waiting status, this state specifically indicates that a workflow run or a job is paused and awaiting external intervention or conditions before it can proceed [5]. It is commonly triggered by: - Deployment protection rules (e.g., environment-level approvals) [5]. - Required manual reviews [5]. It is distinct from the pending status, which is often associated with concurrency limits or queueing [5]. Official GitHub documentation categorizes these statuses within the workflow run lifecycle to help distinguish between transient queueing states and states requiring user action [5]. Developers can query these status values via the REST API or monitor them through webhook events for workflow jobs [2][6].

Citations:


🌐 Web query:

"create_date" "last_modified" "running_workflow_run" updated_at GitHub Actions API

💡 Result:

In the GitHub Actions REST API, the workflow run object contains created_at and updated_at fields to track timestamps, rather than create_date or last_modified [1][2][3]. The relevant timestamp fields for a workflow run are: - created_at: The date and time when the workflow run was created [1][3]. - updated_at: The date and time when the workflow run was last updated [1][3]. - run_started_at: The date and time when the workflow run execution actually started [4][3]. These fields are provided as strings in ISO 8601 format (date-time) [1][5][3]. If you are using the GitHub GraphQL API, the corresponding fields are named createdAt and updatedAt [6].

Citations:


Measure the waiting duration, not the workflow creation time.

created_at is the workflow run start time. A job can enter waiting after already running more than six hours, so this condition can treat a fresh approval requirement as stale and allow another release dispatch. Use a timestamp for the transition into waiting, or use updated_at only if GitHub’s API documents that the workflow-run status update sets it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/release-gate.js around lines 97 - 104, Update the stale
waiting check in the release-run filtering logic to measure elapsed time since
the run entered the waiting state rather than using r.created_at. Use the API’s
documented waiting-transition timestamp, or use r.updated_at only when
documented to reflect the workflow-run status change, while preserving the
existing warning and blocking behavior for genuinely stale approvals.

}
return true;
});
}

function utcDayStart(now = new Date()) {
Expand All @@ -93,15 +123,18 @@ async function hasSuccessfulReleaseWithinDays(
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
const runs = await github.rest.actions.listWorkflowRuns({
// 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,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
per_page: 10,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
return releases.data.some(
(r) => r.tag_name && r.tag_name.startsWith('v')
&& new Date(r.published_at || r.created_at) >= windowStart
);
}

Expand Down Expand Up @@ -146,12 +179,13 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
return out;
}

if (await hasActiveReleaseRun(github, owner, repo)) {
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;
}

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; `
Expand Down Expand Up @@ -226,8 +260,35 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
// 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).`
);
}
}
}

Expand All @@ -242,7 +303,9 @@ module.exports = {
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
STALE_WAITING_HOURS,
releaseIntervalStart,
hasActiveReleaseRun,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/nightly-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
!startsWith(github.event.workflow_run.head_commit.message, 'Bump praisonai')
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
)
runs-on: ubuntu-latest
env:
Expand Down Expand Up @@ -69,6 +69,10 @@ jobs:
- name: Fetch main and tags
run: git fetch --tags origin main

# Guards the gate logic itself — no workflow ran these selftests before.
- name: Selftest release-gate script
run: node .github/scripts/release-gate-selftest.js

- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
Expand Down
Loading
Loading