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
32 changes: 31 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,42 @@ Workflow:

The existing workflow supports manual `workflow_dispatch` **and** a strictly gated owner-command trigger (`issue_comment`). Merging to `main` only starts automatic read-only preflight; it never publishes by itself.

## Single-maintainer exception — v1.15.4 only

A project with one authorized repository maintainer may release **v1.15.4 only**
without inventing a second GitHub reviewer, but this is **not** an implicit
override of CI or governance approvals:

1. The canonical private governance `/release` Decision must be genuinely
`approved`, `resolved_by=interactive-user`, `workflow_gate=user`,
`type=approval`, selected option **`approve-single-maintainer`**, and
`options` must explicitly include that option. Its `rationale` must mention
`single-maintainer` and `v1.15.4`.
2. A matching `released` Waitpoint must be released by `/release` with
`workflow_gate=owner`. Both records must bind to the **actual current main**
using `release:cortex-agent@<mainSHA>`; prior 1.15.0 release records are invalid
for this candidate.
3. The release-preparation PR must really have merged, modify only reviewed
metadata/verification paths, and its merge SHA must be an ancestor of current
main. It need not equal current main when later safe fixes landed. Any
`CHANGES_REQUESTED` review blocks the waiver.
4. The release-workflow preflight, exact-head tests, packaging, npm/tag
collision checks and effect-time authority checks remain mandatory.
This exception does not change GitHub permissions, npm Trusted Publishing
or the Auto Release opt-in variable.
5. Future versions require independent reviewer approval unless a separately
reviewed policy explicitly extends this exception. A chat message or PR
comment does not create authoritative Decision/Waitpoint records.

The existing independent-review path remains the default when selected option
is `approve`. **Do not falsify an APPROVED GitHub Review to activate this mode.**

## Approval-driven release automation (opt-in)

- **Automatic preflight:** `.github/workflows/npm-release-preflight.yml` runs after version-file changes land on `main`. This workflow has read-only permissions, runs architecture/contract tests and `npm pack --dry-run`, and never publishes.
- **Publish trigger:** After release-preparation PR merges, the project owner posts the exact command below as a new comment **on the merged release-preparation PR**. The existing trusted workflow `npm-release.yml` then runs without clicking Actions / Run workflow.
- **Activation prerequisite:** configure repository variable `CORTEX_AUTO_RELEASE_ENABLED=true` **only after** configuring a read-only credential in secret `CORTEX_GOVERNANCE_READ_TOKEN` capable of reading the private `Kucell/cortex-agent-agent` Decision/Waitpoint files. Prefer a short-lived GitHub App installation token; do not use npm tokens or put credentials in versioned files.
- The release gate must verify: repository owner as commenter, merged release-prep PR, PR merged commit equals current `main`, strictly allowed metadata/test files, distinct independently approving reviewer **with repository write/maintain/admin access** on the exact source PR SHA and no outstanding change request, matching **interactive-user-approved release Decision** and **Owner-released Waitpoint** in the canonical private governance main bound to `release:cortex-agent@<actual-main-commit>`, non-expired gate, and the package version matching the requested version. Recheck Git main, release tag and private governance approval immediately before the npm publish side effect.
- The release gate must verify the exact main commit, actual merged release-preparation PR (matching main or an ancestor in the explicitly approved v1.15.4 single-maintainer mode), accepted file scope, private interactive-user-approved Decision and owner-released Waitpoint for the exact main, and current package version. An ordinary approval requires a distinct write-authorized reviewer; the explicit v1.15.4 `approve-single-maintainer` mode waives only this reviewer identity requirement. Active `CHANGES_REQUESTED` is never waived.
- **Command format:** `/cortex-release publish v1.15.4 decision=D-release-... waitpoint=WP-release-...`. Substitute the actual IDs created by the authoritative `/release` owner after the exact-commit dry-run / approval process.
- Failure to verify anything (including unavailable private governance credential) blocks publication; no fallback to simulated approvals or `--force`.
- Publisher remains `npm-release.yml` with npm Trusted Publishing/OIDC. Automatic approvals use `release_type=current`, `dist_tag=latest` and the existing validations/retry logic. **Manual dispatch remains available for validation and governed publication; `publish=true` is no longer an authorization bypass.** Both manual publish and owner-comment publish require the same live private Decision + released Waitpoint, independent write-authorized review, and exact-head verification.
Expand Down
32 changes: 28 additions & 4 deletions scripts/release/verify-approved-release.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,16 @@ function hasIndependentApproval(pr, reviews, eligibleReviewers = null) {
}
function validateGovernance(decision, waitpoint, sha, now=new Date()) {
const target = "release:cortex-agent@" + sha;
const waiver = decision.selected_option === "approve-single-maintainer";
must(decision.type === "approval" && decision.status === "approved" &&
decision.selected_option === "approve" &&
(decision.selected_option === "approve" || waiver) &&
(!waiver || (
Array.isArray(decision.options) &&
decision.options.includes("approve-single-maintainer") &&
typeof decision.rationale === "string" &&
/single-maintainer/i.test(decision.rationale) &&
/1\.15\.4/.test(decision.rationale)
)) &&
decision.resolved_by === "interactive-user" && decision.workflow_gate === "user" &&
decision.resolved_at && Number.isFinite(Date.parse(decision.resolved_at)) &&
Date.parse(decision.resolved_at) <= +now, "interactive-user-approved release Decision required");
Expand Down Expand Up @@ -93,7 +101,8 @@ async function verifyApproval({event,eventName="issue_comment",actor="Kucell",re
const pr = await getJson(api+"/pulls/"+command.prNumber, repoToken, http);
must(pr.merged === true && pr.base && pr.base.ref === "main" &&
pr.number === command.prNumber, "release PR must be merged to main");
must(pr.merge_commit_sha === mainSha, "release PR merge digest differs from current HEAD");
must(typeof pr.merge_commit_sha === "string" &&
/^[a-f0-9]{40}$/.test(pr.merge_commit_sha), "release preparation merge SHA missing");
const branch = await getJson(api+"/git/ref/heads/main",repoToken,http);
must(branch.object && branch.object.sha === mainSha,"main moved; new approval needed");
const files = await getJson(api+"/pulls/"+command.prNumber+"/files?per_page=100",repoToken,http);
Expand All @@ -112,7 +121,6 @@ async function verifyApproval({event,eventName="issue_comment",actor="Kucell",re
const permission = await getJson(api+"/collaborators/"+encodeURIComponent(login)+"/permission",repoToken,http);
if (["write","maintain","admin"].includes(permission.permission)) eligible.add(login);
}
must(hasIndependentApproval(pr,reviews,eligible),"independent write-authorized APPROVED review bound to exact PR head required");
must(governanceToken, "private governance read-only token absent");
const base = "https://api.github.com/repos/Kucell/cortex-agent-agent/contents";
function decode(data) {
Expand All @@ -123,7 +131,23 @@ async function verifyApproval({event,eventName="issue_comment",actor="Kucell",re
const waitpoint = decode(await getJson(base+"/waitpoints/"+command.waitpoint+".json?ref=main",governanceToken,http));
must(decision.decision_id === command.decision && waitpoint.waitpoint_id === command.waitpoint,"governance ID mismatch");
validateGovernance(decision,waitpoint,mainSha,now);
return {version:command.version,sha:mainSha,pr:pr.number};
const waiver = decision.selected_option === "approve-single-maintainer";
if (waiver) {
must(command.version === "1.15.4", "single-maintainer exception limited to v1.15.4");
// A reviewed metadata PR can precede later main commits. The actual release
// approval still binds to the newest exact main SHA (not the stale PR SHA).
const comparison = await getJson(api+"/compare/"+
encodeURIComponent(pr.merge_commit_sha)+"..."+encodeURIComponent(mainSha),repoToken,http);
must(["ahead","identical"].includes(comparison.status) && comparison.behind_by === 0,
"approved release PR is not an ancestor of current main");
must(!reviews.some(r=>r.state === "CHANGES_REQUESTED"),
"unresolved PR change request forbids review waiver");
} else {
must(pr.merge_commit_sha === mainSha, "release PR merge digest differs from current HEAD");
must(hasIndependentApproval(pr,reviews,eligible),
"independent write-authorized APPROVED review bound to exact PR head required");
}
return {version:command.version,sha:mainSha,pr:pr.number,review_mode:waiver?"single-maintainer":"independent"};
}
async function main() {
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));
Expand Down
44 changes: 42 additions & 2 deletions tests/release/verify-approved-release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const b64 = data => ({encoding:"base64",content:Buffer.from(JSON.stringify(data)
function http(uri) {
if (uri.includes("/pulls/51/files")) return Promise.resolve(ok([{filename:"package.json",status:"modified"},{filename:"CHANGELOG.md",status:"modified"}]));
if (uri.includes("/pulls/51/reviews")) return Promise.resolve(ok(reviews));
if (uri.includes("/compare/")) return Promise.resolve(ok({status:"ahead",ahead_by:1,behind_by:0}));
if (uri.includes("/collaborators/separate-reviewer/permission")) return Promise.resolve(ok({permission:"write"}));
if (uri.includes("/collaborators/security-reviewer/permission")) return Promise.resolve(ok({permission:"write"}));
if (uri.endsWith("/pulls/51")) return Promise.resolve(ok(pr));
Expand Down Expand Up @@ -53,7 +54,7 @@ test("governance actor, workflow and expiry proofs fail closed",()=>{
assert.throws(()=>validateGovernance(decision,{...waitpoint,expires_at:"not-a-time"},sha));
});
test("exact version, SHA, independent reviewer and governance gate pass",async()=>{
assert.deepEqual(await verifyApproval(opts()),{version:"1.15.4",sha,pr:51});
assert.deepEqual(await verifyApproval(opts()),{version:"1.15.4",sha,pr:51,review_mode:"independent"});
});
test("nonowner, missing governance token, drift and unmerged PR fail closed",async()=>{
await assert.rejects(()=>verifyApproval({...opts(),event:{...event,sender:{login:"attacker"}}}));
Expand Down Expand Up @@ -95,7 +96,7 @@ const manualEvent = {
};
test("manual publishing uses exactly the same independent review and release gates",async()=>{
const result = await verifyApproval({...opts(),event:manualEvent,eventName:"workflow_dispatch"});
assert.deepEqual(result,{version:"1.15.4",sha,pr:51});
assert.deepEqual(result,{version:"1.15.4",sha,pr:51,review_mode:"independent"});
});
test("manual publish cannot bypass authorization via version, SHA, PR or actor drift",async()=>{
for (const inputs of [
Expand All @@ -116,3 +117,42 @@ test("manual publish cannot bypass authorization via version, SHA, PR or actor d
test("unknown release event never becomes approval",async()=>{
await assert.rejects(()=>verifyApproval({...opts(),event:manualEvent,eventName:"push"}));
});

test("single maintainer release exception is explicit, version-bound and ancestry-checked", async () => {
const waiver = {...decision,selected_option:"approve-single-maintainer",
options:["approve","reject","approve-single-maintainer"],
rationale:"Single-maintainer review waiver for cortex-agent v1.15.4; user approved this exception after CI source audit"};
assert.equal(validateGovernance(waiver,waitpoint,sha),"release:cortex-agent@"+sha);
const run=await verifyApproval({...opts(),http:uri=>{
if (uri.includes("/decisions/")) return Promise.resolve(ok(b64(waiver)));
if (uri.includes("/pulls/51/reviews")) return Promise.resolve(ok([]));
return http(uri);
}});
assert.deepEqual(run,{version:"1.15.4",sha,pr:51,review_mode:"single-maintainer"});
await assert.rejects(()=>verifyApproval({...opts(),http:uri=>{
if (uri.includes("/decisions/")) return Promise.resolve(ok(b64(waiver)));
if (uri.includes("/pulls/51/reviews")) return Promise.resolve(ok([]));
if (uri.includes("/compare/")) return Promise.resolve(ok({status:"diverged",ahead_by:2,behind_by:1}));
return http(uri);
}}),/not an ancestor/);
});
test("waiver cannot be implicitly inferred from 'approve' or rationale alone",()=>{
assert.throws(()=>validateGovernance({...decision,selected_option:"approve-single-maintainer",options:["approve","reject"],
rationale:"single-maintainer v1.15.4"},waitpoint,sha));
assert.throws(()=>validateGovernance({...decision,selected_option:"approve-single-maintainer",options:["approve","reject","approve-single-maintainer"],
rationale:"v1.15.4"},waitpoint,sha));
assert.throws(()=>validateGovernance({...decision,selected_option:"approve-single-maintainer",options:["approve","reject","approve-single-maintainer"],
rationale:"single-maintainer but for v1.15.5"},waitpoint,sha));
});
test("waiver does not allow unanswered change-requested reviews",async()=>{
const waiver = {...decision,selected_option:"approve-single-maintainer",
options:["approve","reject","approve-single-maintainer"],
rationale:"single-maintainer v1.15.4 explicit review exception"};
await assert.rejects(()=>verifyApproval({...opts(),http:uri=>{
if (uri.includes("/decisions/")) return Promise.resolve(ok(b64(waiver)));
if (uri.includes("/pulls/51/reviews")) return Promise.resolve(ok([{
state:"CHANGES_REQUESTED",user:{login:"different-reviewer"},commit_id:pr.head.sha
}]));
return http(uri);
}}),/change request/);
});
Loading