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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,15 @@ gx cleanup # prune merged/stale branches and worktrees
gx cleanup --watch --interval 60
gx cleanup --idle-minutes 10
gx cleanup --watch --once --interval 60
gx release # create/update the current GitHub release from README notes
```

### Release publishing

`gx release` is the maintainer path for package releases. It reads the versioned sections under `README.md -> Release notes`, finds the last published GitHub release, and writes one grouped GitHub release body covering everything newer than that release and up to the current package version.

That GitHub release then triggers `.github/workflows/release.yml`, which performs the actual `npm publish --provenance --access public` step.

### Prompts for your agents

```sh
Expand Down
254 changes: 243 additions & 11 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const REQUIRED_SYSTEM_TOOLS = [
},
];
const MAINTAINER_RELEASE_REPO = path.resolve(
process.env.GUARDEX_RELEASE_REPO || '/tmp/multiagent-safety',
process.env.GUARDEX_RELEASE_REPO || path.resolve(__dirname, '..'),
);
const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
Expand Down Expand Up @@ -252,6 +252,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
['sync', 'Sync agent branches with origin/<base>'],
['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
['cleanup', 'Prune merged/stale agent branches and worktrees'],
['release', 'Create or update the current GitHub release with README-generated notes'],
['agents', 'Start/stop repo-scoped review + cleanup bots'],
['prompt', 'Print AI setup checklist (--exec, --snippet)'],
['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
Expand Down Expand Up @@ -2587,6 +2588,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
return `github.com/${slug}`;
}

function inferGithubRepoSlug(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return '';
const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
if (!match) return '';
const slug = String(match[1] || '')
.replace(/^\/+/, '')
.replace(/^github\.com\//i, '')
.trim();
if (!slug || !slug.includes('/')) return '';
return slug;
}

function resolveScorecardRepo(repoRoot, explicitRepo) {
if (explicitRepo) {
return explicitRepo.trim();
Expand Down Expand Up @@ -3941,6 +3955,17 @@ function parseVersionString(version) {
];
}

function compareParsedVersions(left, right) {
if (!left || !right) return 0;
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const leftValue = left[index] || 0;
const rightValue = right[index] || 0;
if (leftValue > rightValue) return 1;
if (leftValue < rightValue) return -1;
}
return 0;
}

function isNewerVersion(latest, current) {
const latestParts = parseVersionString(latest);
const currentParts = parseVersionString(current);
Expand All @@ -3949,11 +3974,7 @@ function isNewerVersion(latest, current) {
return String(latest || '').trim() !== String(current || '').trim();
}

for (let index = 0; index < latestParts.length; index += 1) {
if (latestParts[index] > currentParts[index]) return true;
if (latestParts[index] < currentParts[index]) return false;
}
return false;
return compareParsedVersions(latestParts, currentParts) > 0;
}

function parseNpmVersionOutput(stdout) {
Expand Down Expand Up @@ -5835,6 +5856,156 @@ function ensureCleanWorkingTree(repoRoot) {
}
}

function readReleaseRepoPackageJson(repoRoot) {
const manifestPath = path.join(repoRoot, 'package.json');
if (!fs.existsSync(manifestPath)) {
throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
}

try {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
}
}

function resolveReleaseGithubRepo(repoRoot) {
const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
const fromManifest = inferGithubRepoSlug(
releasePackageJson.repository &&
(releasePackageJson.repository.url || releasePackageJson.repository),
);
if (fromManifest) {
return fromManifest;
}

const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
if (fromOrigin) {
return fromOrigin;
}

throw new Error(
'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
);
}

function readRepoReadme(repoRoot) {
const readmePath = path.join(repoRoot, 'README.md');
if (!fs.existsSync(readmePath)) {
throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
}
return fs.readFileSync(readmePath, 'utf8');
}

function parseReadmeReleaseEntries(readmeContent) {
const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
if (releaseNotesIndex < 0) {
throw new Error('Release blocked: README.md is missing the "## Release notes" section');
}

const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
const entries = [];
const lines = releaseNotesContent.split(/\r?\n/);
let currentTag = '';
let currentLines = [];

function flushEntry() {
if (!currentTag) {
return;
}
const body = currentLines.join('\n').trim();
if (body) {
entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
}
currentTag = '';
currentLines = [];
}

for (const line of lines) {
const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
if (headingMatch) {
flushEntry();
currentTag = headingMatch[1];
continue;
}

if (!currentTag) {
continue;
}

if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
flushEntry();
continue;
}

currentLines.push(line);
}

flushEntry();

if (entries.length === 0) {
throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
}

return entries;
}

function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
timeout: 20_000,
});
if (result.error) {
throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
}
if (result.status !== 0) {
const details = (result.stderr || result.stdout || '').trim();
throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
}

const tags = String(result.stdout || '')
.split('\n')
.map((line) => line.split('\t')[0].trim())
.filter(Boolean);

return tags.find((tag) => tag !== currentTag) || '';
}

function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
const currentVersion = parseVersionString(currentTag);
if (!currentVersion) {
throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
}
const previousVersion = previousTag ? parseVersionString(previousTag) : null;

const selected = entries.filter((entry) => {
if (!entry.version) return false;
if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
if (!previousVersion) return entry.tag === currentTag;
return compareParsedVersions(entry.version, previousVersion) > 0;
});

if (!selected.some((entry) => entry.tag === currentTag)) {
throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
}

return selected;
}

function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
const sections = entries
.map((entry) => `### ${entry.tag}\n${entry.body}`)
.join('\n\n');
return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
}

function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
const readme = readRepoReadme(repoRoot);
const entries = parseReadmeReleaseEntries(readme);
const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
}

function release(rawArgs) {
if (rawArgs.length > 0) {
throw new Error(`Unknown option: ${rawArgs[0]}`);
Expand All @@ -5850,13 +6021,74 @@ function release(rawArgs) {
ensureMainBranch(repoRoot);
ensureCleanWorkingTree(repoRoot);

console.log(`[${TOOL_NAME}] Releasing ${packageJson.name}@${packageJson.version} from ${repoRoot}`);
const publishResult = run(NPM_BIN, ['publish'], { cwd: repoRoot, stdio: 'inherit' });
if (publishResult.status !== 0) {
throw new Error('npm publish failed');
if (!isCommandAvailable(GH_BIN)) {
throw new Error(`Release blocked: '${GH_BIN}' is not available`);
}

const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
if (ghAuthStatus.error) {
throw new Error(`Release blocked: unable to run '${GH_BIN} auth status': ${ghAuthStatus.error.message}`);
}
if (ghAuthStatus.status !== 0) {
const details = (ghAuthStatus.stderr || ghAuthStatus.stdout || '').trim();
throw new Error(`Release blocked: '${GH_BIN}' auth is unavailable.${details ? `\n${details}` : ''}`);
}

const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
const repoSlug = resolveReleaseGithubRepo(repoRoot);
const currentTag = `v${releasePackageJson.version}`;
const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();

const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
timeout: 20_000,
});
if (existingRelease.error) {
throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
}

const releaseArgs =
existingRelease.status === 0
? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
: [
'release',
'create',
currentTag,
'--repo',
repoSlug,
'--target',
headCommit,
'--title',
currentTag,
'--notes',
notes,
];

console.log(
`[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
);
if (previousTag) {
console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
} else {
console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
}

const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
if (releaseResult.error) {
throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
}
if (releaseResult.status !== 0) {
const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
}

const releaseUrl = String(releaseResult.stdout || '').trim();
if (releaseUrl) {
console.log(releaseUrl);
}

console.log(`[${TOOL_NAME}] ✅ Publish complete.`);
console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
process.exitCode = 0;
}

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/auto-release-writer/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-21
17 changes: 17 additions & 0 deletions openspec/changes/auto-release-writer/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- The current `gx release` command still runs `npm publish` directly, so maintainers have to open GitHub Releases manually and hand-write the release body.
- The latest public release (`v7.0.15`) only shows the patch-bump note even though the shipped changes since `v7.0.12` also include nested protected-repo doctor fixes, protected-main sandbox setup, and the Claude companion naming cleanup.
- This repo already keeps the authoritative release history in `README.md`, so the GitHub release flow should reuse that text instead of drifting into manual summaries.

## What Changes

- Change `gx release` so it generates release notes from `README.md`, aggregating every README release section newer than the last published GitHub release and up to the current package version.
- Have `gx release` resolve the public GitHub repo from the package manifest, then create or update the GitHub release for the current version with the generated notes instead of running `npm publish` directly.
- Document the new release flow and use the generated text to rewrite the existing `v7.0.15` GitHub release body so it summarizes the changes since `v7.0.12`.

## Impact

- Affects the maintainer-only `gx release` path, CLI documentation, and release-related test coverage.
- Keeps npm publishing on the existing `release.yml` workflow, which still runs from `release.published` / manual dispatch.
- Main risk: GitHub release creation must target the public repo even when `origin` points at a mirror or worktree-management remote, so repo resolution must not rely on `origin` alone.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## ADDED Requirements

### Requirement: `gx release` writes GitHub releases from README history
The maintainer-only `gx release` workflow SHALL generate the GitHub release body from the README release-notes history and SHALL create or update the GitHub release for the current package version instead of publishing to npm directly.

#### Scenario: aggregate the README changes since the last published release
- **GIVEN** `README.md` contains release sections for `v7.0.13`, `v7.0.14`, and `v7.0.15`
- **AND** GitHub already has `v7.0.12` as the latest published release before `v7.0.15`
- **WHEN** `gx release` runs for package version `7.0.15`
- **THEN** the generated GitHub release body SHALL include grouped sections for `v7.0.15`, `v7.0.14`, and `v7.0.13`
- **AND** it SHALL not collapse that range into only the patch-bump bullet from `v7.0.15`

#### Scenario: target the public GitHub repo even when `origin` drifts
- **GIVEN** the package manifest repository URL points at `git+https://github.com/recodeee/gitguardex.git`
- **AND** the local `origin` remote may point at a mirror or worktree-management repo
- **WHEN** `gx release` resolves the GitHub target repo
- **THEN** it SHALL use the public repo from the package manifest for release creation or updates

#### Scenario: update an existing release instead of failing
- **GIVEN** a GitHub release already exists for the current package tag
- **WHEN** `gx release` runs again for that same version
- **THEN** it SHALL update the release title/body with regenerated notes
- **AND** it SHALL not fail just because the release already exists
24 changes: 24 additions & 0 deletions openspec/changes/auto-release-writer/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `auto-release-writer`.
- [x] 1.2 Define normative requirements in `specs/release-workflow/spec.md`.

## 2. Implementation

- [x] 2.1 Replace the maintainer `gx release` path so it generates GitHub release notes from README history and creates or updates the public GitHub release instead of running `npm publish` directly.
- [x] 2.2 Add focused regression coverage for README aggregation, package-manifest repo targeting, and create-vs-edit GitHub release behavior.
- [x] 2.3 Update operator-facing docs for the new release flow and rewrite the live `v7.0.15` GitHub release body with the generated notes.

## 3. Verification

- [ ] 3.1 Run targeted project verification commands (`node --test test/install.test.js test/metadata.test.js`, `node --check bin/multiagent-safety.js`).
- [x] 3.2 Run `openspec validate auto-release-writer --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

Verification note: `node --check bin/multiagent-safety.js` passed. The exact `node --test test/install.test.js test/metadata.test.js` command still timed out after 120s because unrelated `setup`/`doctor` areas in `test/install.test.js` are red in this repo baseline, while the release-focused slice and the inherited `codex-agent` regressions touched here now pass.

## 4. Completion

- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch <agent-branch> --base <base-branch> --via-pr --wait-for-merge --cleanup`).
- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff.
- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending.
Loading