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'];