diff --git a/README.md b/README.md
index 0e48d67..6c8a5ae 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js
index acfbee6..68d4933 100755
--- a/bin/multiagent-safety.js
+++ b/bin/multiagent-safety.js
@@ -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';
@@ -252,6 +252,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
['sync', 'Sync agent branches with origin/'],
['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)'],
@@ -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();
@@ -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);
@@ -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) {
@@ -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]}`);
@@ -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;
}
diff --git a/openspec/changes/auto-release-writer/.openspec.yaml b/openspec/changes/auto-release-writer/.openspec.yaml
new file mode 100644
index 0000000..4b8c565
--- /dev/null
+++ b/openspec/changes/auto-release-writer/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-21
diff --git a/openspec/changes/auto-release-writer/proposal.md b/openspec/changes/auto-release-writer/proposal.md
new file mode 100644
index 0000000..ad5fab4
--- /dev/null
+++ b/openspec/changes/auto-release-writer/proposal.md
@@ -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.
diff --git a/openspec/changes/auto-release-writer/specs/release-workflow/spec.md b/openspec/changes/auto-release-writer/specs/release-workflow/spec.md
new file mode 100644
index 0000000..e0380db
--- /dev/null
+++ b/openspec/changes/auto-release-writer/specs/release-workflow/spec.md
@@ -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
diff --git a/openspec/changes/auto-release-writer/tasks.md b/openspec/changes/auto-release-writer/tasks.md
new file mode 100644
index 0000000..c95fa2b
--- /dev/null
+++ b/openspec/changes/auto-release-writer/tasks.md
@@ -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 --base --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.
diff --git a/test/install.test.js b/test/install.test.js
index 3f13bd2..3c52ae5 100644
--- a/test/install.test.js
+++ b/test/install.test.js
@@ -181,6 +181,22 @@ function seedCommit(repoDir) {
assert.equal(result.status, 0, result.stderr);
}
+function seedReleasePackageManifest(repoDir, overrides = {}) {
+ const packageJsonPath = path.join(repoDir, 'package.json');
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+ const mergedPackageJson = {
+ ...packageJson,
+ name: packageJson.name || '@imdeadpool/guardex',
+ version: cliVersion,
+ repository: {
+ type: 'git',
+ url: 'git+https://github.com/recodeee/gitguardex.git',
+ },
+ ...overrides,
+ };
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(mergedPackageJson, null, 2)}\n`, 'utf8');
+}
+
function attachOriginRemote(repoDir) {
return attachOriginRemoteForBranch(repoDir, 'dev');
}
@@ -2998,13 +3014,8 @@ test('codex-agent restores local branch and falls back to safe worktree start wh
assert.equal(launch.status, 0, launch.stderr || launch.stdout);
const combinedOutput = `${launch.stdout}\n${launch.stderr}`;
assert.match(combinedOutput, /Unsafe starter output/);
-<<<<<<< HEAD
- assert.match(combinedOutput, /\[agent-branch-start\] Created branch: agent\/[^/]+\//);
- assert.match(combinedOutput, /Origin remote does not provide a mergeable PR surface; skipping auto-finish merge\/PR pipeline/);
- const launchedBranch = extractCreatedBranch(combinedOutput);
-=======
assert.match(combinedOutput, /\[agent-branch-start\] Created branch: agent\/planner\//);
->>>>>>> d6a57dd (Align Guardex finish-path regressions with the current workflow contract)
+ assert.match(combinedOutput, /Origin remote does not provide a mergeable PR surface; skipping auto-finish merge\/PR pipeline/);
const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
assert.match(
@@ -3138,7 +3149,7 @@ test('codex-agent keeps dirty sandbox worktrees after session exit', () => {
assert.equal(fs.existsSync(path.join(launchedCwd, 'codex-dirty.txt')), true);
});
-test('codex-agent waits for PR merge completion and cleans merged sandbox branch/worktree by default', () => {
+test('codex-agent keeps the sandbox when origin cannot provide a mergeable PR surface', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);
@@ -3214,24 +3225,24 @@ exit 1
},
);
assert.equal(launch.status, 0, launch.stderr || launch.stdout);
- assert.match(launch.stdout, /\[codex-agent\] Auto-finish enabled: commit -> push\/PR -> wait for merge -> cleanup\./);
- assert.match(launch.stdout, /\[codex-agent\] Auto-finish completed for/);
- assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/);
- assert.equal(fs.readFileSync(ghMergeState, 'utf8').trim(), '2', 'finish flow should retry merge until checks are ready');
+ const combinedOutput = `${launch.stdout}\n${launch.stderr}`;
+ assert.match(combinedOutput, /\[codex-agent\] Auto-finish enabled: commit -> push\/PR -> wait for merge -> cleanup\./);
+ assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped for 'agent\/codex\/autofinish-task-/);
+ assert.equal(fs.existsSync(ghMergeState), false, 'merge should not be attempted without a mergeable remote context');
const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
- assert.equal(fs.existsSync(launchedCwd), false, 'auto-finished sandbox should be cleaned by default');
+ assert.equal(fs.existsSync(launchedCwd), true, 'sandbox should stay available for manual finish');
const launchedBranch = extractCreatedBranch(launch.stdout);
result = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${launchedBranch}`], repoDir);
- assert.notEqual(result.status, 0, 'auto-finished branch should be removed locally by default');
- result = runCmd('git', ['ls-remote', '--heads', 'origin', launchedBranch], repoDir);
- assert.equal(result.stdout.trim(), '', 'auto-finished branch should be removed on origin by default');
+ assert.equal(result.status, 0, 'branch should remain available locally for manual finish');
+ assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/);
+ assert.match(launch.stdout, /\[codex-agent\] If finished, merge with:/);
const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim();
assert.match(launchedArgs, /--model gpt-5\.4-mini/);
});
-test('codex-agent still auto-finishes when base branch advances during task run', () => {
+test('codex-agent keeps the sandbox when base branch advances without a mergeable remote context', () => {
const repoDir = initRepo();
seedCommit(repoDir);
const originPath = attachOriginRemote(repoDir);
@@ -3317,18 +3328,15 @@ exit 1
},
);
assert.equal(launch.status, 0, launch.stderr || launch.stdout);
- const sawCommitRetry = /Auto-commit retry: .*behind origin\/dev/.test(launch.stdout);
- const sawFinishSync = /\[agent-sync-guard\] Auto-syncing .* onto origin\/dev before finish/.test(launch.stdout);
- assert.equal(
- sawCommitRetry || sawFinishSync,
- true,
- `expected sync retry evidence in output, got:\n${launch.stdout}`,
- );
- assert.match(launch.stdout, /\[codex-agent\] Auto-finish completed for/);
- assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/);
+ const combinedOutput = `${launch.stdout}\n${launch.stderr}`;
+ assert.match(combinedOutput, /\[codex-agent\] Auto-committed sandbox changes on 'agent\/planner\/autocommit-retry-task-/);
+ assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped for 'agent\/planner\/autocommit-retry-task-/);
+ assert.equal(fs.existsSync(path.join(originAdvanceClone, 'base-advance.txt')), true, 'test should still advance the base branch during codex execution');
const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
- assert.equal(fs.existsSync(launchedCwd), false, 'auto-finished sandbox should be cleaned by default');
+ assert.equal(fs.existsSync(launchedCwd), true, 'sandbox should stay available for manual finish');
+ assert.equal(fs.existsSync(path.join(launchedCwd, 'codex-autocommit-retry.txt')), true);
+ assert.match(launch.stdout, /\[codex-agent\] If finished, merge with:/);
});
test('codex-agent surfaces commit-hook failures so unfinished sandboxes are actionable', () => {
@@ -4737,60 +4745,214 @@ test('release fails when git status is dirty', () => {
assert.match(result.stderr, /working tree is not clean/);
});
-test('release runs npm publish when guardrails pass', () => {
+test('release creates a GitHub release with README-generated notes', () => {
const repoDir = initRepoOnBranch('main');
+ seedReleasePackageManifest(repoDir);
+ fs.writeFileSync(
+ path.join(repoDir, 'README.md'),
+ `## Release notes
+
+### v${cliVersion}
+- Current release fix.
+
+### v7.0.14
+- Previous release metadata bump.
+
+### v7.0.13
+- Claude companion naming cleanup.
+`,
+ 'utf8',
+ );
seedCommit(repoDir);
- const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-fake-bin-'));
- const markerPath = path.join(repoDir, '.npm-publish-called');
- const fakeNpmPath = path.join(fakeBin, 'npm');
+ const markerPath = path.join(repoDir, '.gh-release-create-called');
+ const fakeGh = createFakeGhScript(`
+if [[ "$1" == "auth" && "$2" == "status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "list" ]]; then
+ printf 'v7.0.12\\tLatest\\tv7.0.12\\t2026-04-21T01:42:36Z\\n'
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "view" ]]; then
+ exit 1
+fi
+if [[ "$1" == "release" && "$2" == "create" ]]; then
+ printf '%s\\n' "$@" > "${markerPath}"
+ printf '%s\\n' "https://example.test/releases/tag/v${cliVersion}"
+ exit 0
+fi
+echo "unexpected gh args: $*" >&2
+exit 1
+`);
+
+ const result = runNodeWithEnv(['release'], repoDir, {
+ GUARDEX_RELEASE_REPO: repoDir,
+ GUARDEX_GH_BIN: fakeGh.fakePath,
+ });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ const args = fs.readFileSync(markerPath, 'utf8');
+ assert.match(args, new RegExp(`^create$`, 'm'));
+ assert.match(args, new RegExp(`^v${escapeRegexLiteral(cliVersion)}$`, 'm'));
+ assert.match(args, /^--repo$\nrecodeee\/gitguardex$/m);
+ assert.match(args, /^--title$\nv7\.0\.15$/m);
+ assert.match(args, /Changes since v7\.0\.12\./);
+ assert.match(args, /### v7\.0\.15/);
+ assert.match(args, /### v7\.0\.14/);
+ assert.match(args, /### v7\.0\.13/);
+});
+
+test('release prefers the target repo package manifest when resolving the GitHub repo', () => {
+ const repoDir = initRepoOnBranch('main');
+ seedReleasePackageManifest(repoDir, {
+ repository: {
+ type: 'git',
+ url: 'git+https://github.com/example/custom-release-target.git',
+ },
+ });
fs.writeFileSync(
- fakeNpmPath,
- `#!/usr/bin/env bash\n` +
- `echo "$@" > "${markerPath}"\n` +
- `exit 0\n`,
+ path.join(repoDir, 'README.md'),
+ `## Release notes
+
+### v${cliVersion}
+- Current release fix.
+`,
'utf8',
);
- fs.chmodSync(fakeNpmPath, 0o755);
+ runCmd('git', ['remote', 'add', 'origin', 'https://github.com/example/ignored-origin.git'], repoDir);
+ seedCommit(repoDir);
+
+ const markerPath = path.join(repoDir, '.gh-release-target-called');
+ const fakeGh = createFakeGhScript(`
+if [[ "$1" == "auth" && "$2" == "status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "list" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "view" ]]; then
+ exit 1
+fi
+if [[ "$1" == "release" && "$2" == "create" ]]; then
+ printf '%s\\n' "$@" > "${markerPath}"
+ printf '%s\\n' "https://example.test/releases/tag/v${cliVersion}"
+ exit 0
+fi
+echo "unexpected gh args: $*" >&2
+exit 1
+`);
+
+ const result = runNodeWithEnv(['release'], repoDir, {
+ GUARDEX_RELEASE_REPO: repoDir,
+ GUARDEX_GH_BIN: fakeGh.fakePath,
+ });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ const args = fs.readFileSync(markerPath, 'utf8');
+ assert.match(args, /^--repo$\nexample\/custom-release-target$/m);
+ assert.doesNotMatch(args, /example\/ignored-origin/);
+});
+
+test('release edits an existing GitHub release instead of failing', () => {
+ const repoDir = initRepoOnBranch('main');
+ seedReleasePackageManifest(repoDir);
+ fs.writeFileSync(
+ path.join(repoDir, 'README.md'),
+ `## Release notes
+
+### v${cliVersion}
+- Current release fix.
+
+### v7.0.14
+- Previous release metadata bump.
+`,
+ 'utf8',
+ );
+ seedCommit(repoDir);
+
+ const markerPath = path.join(repoDir, '.gh-release-edit-called');
+ const fakeGh = createFakeGhScript(`
+if [[ "$1" == "auth" && "$2" == "status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "list" ]]; then
+ printf 'v${cliVersion}\\tLatest\\tv${cliVersion}\\t2026-04-21T11:03:27Z\\n'
+ printf 'v7.0.12\\t\\tv7.0.12\\t2026-04-21T01:42:36Z\\n'
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "view" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "edit" ]]; then
+ printf '%s\\n' "$@" > "${markerPath}"
+ printf '%s\\n' "https://example.test/releases/tag/v${cliVersion}"
+ exit 0
+fi
+echo "unexpected gh args: $*" >&2
+exit 1
+`);
const result = runNodeWithEnv(['release'], repoDir, {
GUARDEX_RELEASE_REPO: repoDir,
- PATH: `${fakeBin}:${process.env.PATH}`,
+ GUARDEX_GH_BIN: fakeGh.fakePath,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
- const args = fs.readFileSync(markerPath, 'utf8').trim();
- assert.equal(args, 'publish');
+ const args = fs.readFileSync(markerPath, 'utf8');
+ assert.match(args, /^edit$/m);
+ assert.match(args, new RegExp(`^v${escapeRegexLiteral(cliVersion)}$`, 'm'));
+ assert.match(args, /Changes since v7\.0\.12\./);
});
test('typo helper maps relaese/realaese to release', () => {
const repoDir = initRepoOnBranch('main');
+ seedReleasePackageManifest(repoDir);
+ fs.writeFileSync(
+ path.join(repoDir, 'README.md'),
+ `## Release notes
+
+### v${cliVersion}
+- Current release fix.
+`,
+ 'utf8',
+ );
seedCommit(repoDir);
- const marker = path.join(os.tmpdir(), `guardex-typo-publish-${Date.now()}-${Math.random()}.txt`);
- const fakeNpm = createFakeNpmScript(`
-if [[ "$1" == "publish" ]]; then
- echo "$@" > "${marker}"
+ const marker = path.join(os.tmpdir(), `guardex-typo-release-${Date.now()}-${Math.random()}.txt`);
+ const fakeGh = createFakeGhScript(`
+if [[ "$1" == "auth" && "$2" == "status" ]]; then
exit 0
fi
-echo "unexpected npm args: $*" >&2
+if [[ "$1" == "release" && "$2" == "list" ]]; then
+ exit 0
+fi
+if [[ "$1" == "release" && "$2" == "view" ]]; then
+ exit 1
+fi
+if [[ "$1" == "release" && "$2" == "create" ]]; then
+ printf '%s\\n' "$@" > "${marker}"
+ printf '%s\\n' "https://example.test/releases/tag/v${cliVersion}"
+ exit 0
+fi
+echo "unexpected gh args: $*" >&2
exit 1
`);
const typoA = runNodeWithEnv(['relaese'], repoDir, {
GUARDEX_RELEASE_REPO: repoDir,
- GUARDEX_NPM_BIN: fakeNpm,
+ GUARDEX_GH_BIN: fakeGh.fakePath,
});
assert.equal(typoA.status, 0, typoA.stderr || typoA.stdout);
assert.match(typoA.stdout, /Interpreting 'relaese' as 'release'/);
- assert.equal(fs.readFileSync(marker, 'utf8').trim(), 'publish');
+ assert.match(fs.readFileSync(marker, 'utf8'), /^create$/m);
const typoB = runNodeWithEnv(['realaese'], repoDir, {
GUARDEX_RELEASE_REPO: repoDir,
- GUARDEX_NPM_BIN: fakeNpm,
+ GUARDEX_GH_BIN: fakeGh.fakePath,
});
assert.equal(typoB.status, 0, typoB.stderr || typoB.stdout);
assert.match(typoB.stdout, /Interpreting 'realaese' as 'release'/);
- assert.equal(fs.readFileSync(marker, 'utf8').trim(), 'publish');
+ assert.match(fs.readFileSync(marker, 'utf8'), /^create$/m);
});
test('unknown command suggests nearest valid command', () => {
diff --git a/test/metadata.test.js b/test/metadata.test.js
index 667fabf..61b8668 100644
--- a/test/metadata.test.js
+++ b/test/metadata.test.js
@@ -50,6 +50,13 @@ test('README release notes include current package version', () => {
);
});
+test('README documents gx release as README-driven GitHub release writer', () => {
+ const readme = fs.readFileSync(readmePath, 'utf8');
+ assert.match(readme, /gx release\s+# create\/update the current GitHub release from README notes/);
+ assert.match(readme, /`gx release` is the maintainer path for package releases\./);
+ assert.match(readme, /finds the last published GitHub release, and writes one grouped GitHub release body/);
+});
+
test('security workflows are present and use pinned GitHub Actions SHAs', () => {
const workflowDir = path.join(repoRoot, '.github', 'workflows');
const expected = ['ci.yml', 'release.yml', 'scorecard.yml', 'codeql.yml', 'cr.yml'];