From 0fee7fa29b33407d4f550f2b347d62cbad5c9e72 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 21 Apr 2026 13:51:50 +0200 Subject: [PATCH 1/2] Generate GitHub releases from README history instead of publishing inline The maintainer release path now builds notes from versioned README sections, targets the public GitHub repo from the release manifest, and creates or updates the current GitHub release. The same change also carries the already-verified codex-agent regression coverage that was staged on this sandbox branch. Constraint: Release creation must target recodeee/gitguardex even when origin points at a mirror or worktree-management remote Rejected: Keep gx release calling npm publish directly | still leaves GitHub release notes manual and prone to drift from README history Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep README release-note sections complete and versioned because gx release now treats them as the source of truth for GitHub release bodies Tested: node --check bin/multiagent-safety.js; node --test test/metadata.test.js; node --test --test-name-pattern "release creates a GitHub release with README-generated notes|release prefers the target repo package manifest when resolving the GitHub repo|release edits an existing GitHub release instead of failing|typo helper maps relaese/realaese to release" test/install.test.js; node --test --test-name-pattern "codex-agent keeps the sandbox when base branch advances without a mergeable remote context|codex-agent surfaces commit-hook failures so unfinished sandboxes are actionable" test/install.test.js; openspec validate auto-release-writer --type change --strict; openspec validate --specs Not-tested: node --test test/install.test.js test/metadata.test.js full command because unrelated setup/doctor areas still time out in the repo baseline --- README.md | 7 + bin/multiagent-safety.js | 254 ++++++++++++++++- .../auto-release-writer/.openspec.yaml | 2 + .../changes/auto-release-writer/proposal.md | 17 ++ .../specs/release-workflow/spec.md | 23 ++ openspec/changes/auto-release-writer/tasks.md | 24 ++ test/install.test.js | 256 ++++++++++++++---- test/metadata.test.js | 7 + 8 files changed, 532 insertions(+), 58 deletions(-) create mode 100644 openspec/changes/auto-release-writer/.openspec.yaml create mode 100644 openspec/changes/auto-release-writer/proposal.md create mode 100644 openspec/changes/auto-release-writer/specs/release-workflow/spec.md create mode 100644 openspec/changes/auto-release-writer/tasks.md 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..45c02af 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\/codex\/autocommit-retry-task-/); + assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped for 'agent\/codex\/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']; From 604ce98c3664f5fe1ce735c5a6c9692eb045832a Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 21 Apr 2026 13:54:58 +0200 Subject: [PATCH 2/2] Align rebased codex-agent regression expectations with planner branch naming origin/main now exercises the autocommit retry path under agent/planner branch names, so the carried regression assertions needed a narrow follow-up after the finish-flow rebase conflict was resolved. Constraint: The rebase pulled in current workflow-contract changes from main while this branch still carried older agent/codex expectations in test/install.test.js Rejected: Leave the stale assertion in place | it would make the focused codex-agent regression slice fail after the rebase Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep codex-agent branch-name assertions aligned with the current workflow contract when rebasing long-lived release branches Tested: node --test --test-name-pattern "release creates a GitHub release with README-generated notes|release prefers the target repo package manifest when resolving the GitHub repo|release edits an existing GitHub release instead of failing|typo helper maps relaese/realaese to release|codex-agent keeps the sandbox when base branch advances without a mergeable remote context|codex-agent surfaces commit-hook failures so unfinished sandboxes are actionable" test/install.test.js Not-tested: node --test test/metadata.test.js full file after rebase because origin/main currently carries an unrelated doctor-path metadata failure --- test/install.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/install.test.js b/test/install.test.js index 45c02af..3c52ae5 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -3329,8 +3329,8 @@ exit 1 ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); const combinedOutput = `${launch.stdout}\n${launch.stderr}`; - assert.match(combinedOutput, /\[codex-agent\] Auto-committed sandbox changes on 'agent\/codex\/autocommit-retry-task-/); - assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped for 'agent\/codex\/autocommit-retry-task-/); + 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();