Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "7.0.43"
".": "7.1.1"
}
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ Being honest about where this still has issues:
<details open>
<summary><strong>v7.x</strong></summary>

### v7.1.1
- Bumped `@imdeadpool/guardex` from `7.1.0` to `7.1.1` so the current
`main` payload can publish under a fresh npm version after `7.1.0` reached
the registry.
- Direct maintainer `npm publish` now checks npm during `prepublishOnly` and
bumps package release metadata to the next unpublished patch version when the
committed version is already published. GitHub Actions release publishes keep
the committed metadata so packed and signed assets stay aligned.

### v7.0.43
- Budget-friendly CI defaults for gitguardex-managed projects: live
workflows drop `push: main`, gate per-PR jobs on `pull_request.draft
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-17
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Context

`release-please` owns committed release bumps for the GitHub Actions path, and `.github/workflows/release.yml` already skips `npm publish` when the committed version exists on npm. The missing path is a maintainer running `npm publish` directly from a checkout after the current version has already been published.

## Decisions

- Use `prepublishOnly` so direct `npm publish` runs the check before npm creates and uploads the package.
- Query npm for the exact current package version and only mutate files when that version is already published.
- Search patch versions until the first unpublished version so repeated failed manual publishes can recover without guessing the next patch.
- Update `package.json`, root lockfile package metadata, and README release notes to keep release metadata aligned.
- Bump the committed metadata from `7.1.0` to `7.1.1` because the npm registry already contains `7.1.0` and `7.1.1` is currently unpublished.
- Skip by default during GitHub Actions and dry runs. CI release artifacts are generated from the committed package version, so CI should not rewrite the version between packing/signing and publishing.

## Risks

- The hook depends on npm registry reachability. It fails closed if the registry lookup cannot distinguish published from unpublished.
- The automatic bump is patch-only and requires the package version to be plain `x.y.z`; non-plain prerelease versions still need an intentional manual release flow.
- The generated README release note is intentionally minimal; maintainers can expand it before committing if a manual publish carries larger release narrative.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# agent-codex-auto-bump-package-version-before-npm-pub-2026-06-17-10-18 (minimal / T1)

Branch: `agent/codex/auto-bump-package-version-before-npm-pub-2026-06-17-10-18`

Direct `npm publish` now checks whether the committed package version already exists on npm and bumps to the next unpublished patch version before publishing. The current release metadata is aligned to `7.1.1` because npm already contains `7.1.0`. GitHub Actions and dry runs skip the auto-bump so release artifacts remain tied to committed metadata.

## Handoff

- Handoff: change=`agent-codex-auto-bump-package-version-before-npm-pub-2026-06-17-10-18`; branch=`agent/codex/auto-bump-package-version-before-npm-pub-2026-06-17-10-18`; scope=`npm publish prepublish version bump`; action=`continue implementation or finish cleanup`.
- Copy prompt: Continue `agent-codex-auto-bump-package-version-before-npm-pub-2026-06-17-10-18` on branch `agent/codex/auto-bump-package-version-before-npm-pub-2026-06-17-10-18`. Work inside the existing sandbox, review `openspec/changes/agent-codex-auto-bump-package-version-before-npm-pub-2026-06-17-10-18/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/auto-bump-package-version-before-npm-pub-2026-06-17-10-18 --base main --via-pr --wait-for-merge --cleanup`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/codex/auto-bump-package-version-before-npm-pub-2026-06-17-10-18 --base main --via-pr --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state in the completion handoff.
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Why

Direct maintainer `npm publish` attempts fail when `package.json` still names a version that already exists on npm. The repository already has release-please and CI skip logic, but the local publish path still leaves the maintainer to remember a manual patch bump.

## What Changes

- Add a `prepublishOnly` lifecycle hook that checks whether the current package version already exists on npm.
- When the exact version is already published, bump `package.json`, `package-lock.json`, and README release notes to the next unpublished patch version before publish continues.
- Align the current release metadata to `7.1.1`, the next unpublished patch after the failed `7.1.0` publish.
- Leave normal release-please and GitHub Actions publish flows anchored to the committed package version unless explicitly overridden.
- Add regression tests for version selection, file updates, skip behavior, and manifest wiring.

## Capabilities

### New Capabilities
- None.

### Modified Capabilities
- `release-workflow`: direct maintainer `npm publish` gains an automatic already-published-version recovery path.

## Impact

- Affects `package.json`, `package-lock.json`, `.release-please-manifest.json`, README release notes, the local npm publish lifecycle, a new helper under `scripts/`, metadata tests, and release-workflow OpenSpec requirements.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Direct npm publish auto-bumps already-published versions
Direct maintainer `npm publish` SHALL check whether the current package version is already present on npm and SHALL bump package release metadata to the next unpublished patch version before publish continues.

#### Scenario: already-published direct publish version
- **GIVEN** a maintainer runs `npm publish` from the Guardex package checkout
- **AND** the current `package.json` version already exists on npm
- **WHEN** the prepublish lifecycle runs
- **THEN** `package.json` SHALL be updated to the next unpublished patch version
- **AND** `package-lock.json` root package metadata SHALL be updated to the same version
- **AND** README release notes SHALL include the bumped version
- **AND** publish SHALL continue with the bumped package metadata.

#### Scenario: direct publish version is not published yet
- **GIVEN** a maintainer runs `npm publish` from the Guardex package checkout
- **AND** the current `package.json` version does not exist on npm
- **WHEN** the prepublish lifecycle runs
- **THEN** package release metadata SHALL remain unchanged.

#### Scenario: CI release publish keeps committed metadata
- **GIVEN** the GitHub Actions release workflow runs `npm publish`
- **WHEN** the prepublish lifecycle runs
- **THEN** the automatic bump SHALL be skipped by default so packed and signed release assets stay aligned with committed package metadata.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## 1. Specification

- [x] 1.1 Define the direct publish auto-bump behavior in `specs/release-workflow/spec.md`.
- [x] 1.2 Capture CI and dry-run skip rationale in `design.md`.

## 2. Implementation

- [x] 2.1 Add a `prepublishOnly` lifecycle hook to `package.json`.
- [x] 2.2 Add a publish helper that checks npm, bumps to the next unpublished patch version, and updates `package-lock.json` plus README release notes.
- [x] 2.3 Align committed release metadata to `7.1.1` after confirming npm already has `7.1.0`.
- [x] 2.4 Add regression coverage for version selection, manifest wiring, skip behavior, and file updates.

## 3. Verification

- [x] 3.1 Run targeted metadata and prepublish tests. Result: `node --test test/prepublish-bump-version.test.js test/metadata.test.js` passed.
- [ ] 3.2 Run `npm test`. Attempted, but the full suite produced no additional output for several minutes after the TAP header and was stopped with Ctrl-C to avoid leaving a stuck session.
- [x] 3.3 Run OpenSpec validation for this change. Result: `openspec validate agent-codex-auto-bump-package-version-before-npm-pub-2026-06-17-10-18 --type change --strict` passed.
- [x] 3.4 Run full spec validation. Result: `openspec validate --specs` passed with 133 specs.
- [x] 3.5 Run package dry-runs. Result: `env npm_config_cache=/tmp/npm-cache-gitguardex npm pack --dry-run` and `env npm_config_cache=/tmp/npm-cache-gitguardex npm publish --dry-run --access public` both reported `@imdeadpool/guardex@7.1.1`; publish dry-run ran `prepublishOnly` and skipped mutation because it was a dry run.

## 4. Completion

- [ ] 4.1 Finish the agent branch via PR merge + cleanup.
- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff.
- [ ] 4.3 Confirm sandbox cleanup or capture a `BLOCKED:` handoff if merge/cleanup is pending.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@imdeadpool/guardex",
"version": "7.1.0",
"version": "7.1.1",
"description": "Guardian T-Rex for your multi-agent repo. Isolated worktrees, file locks, and PR-only merges stop parallel Codex & Claude agents from overwriting each other's work. Auto-wires Oh My Codex, Oh My Claude, OpenSpec, and Caveman.",
"license": "MIT",
"preferGlobal": true,
Expand All @@ -12,6 +12,7 @@
},
"scripts": {
"test": "node --test test/*.test.js",
"prepublishOnly": "node scripts/prepublish-bump-version.js",
"agent:codex": "bash ./scripts/codex-agent.sh",
"agent:branch:start": "bash ./scripts/agent-branch-start.sh",
"agent:branch:finish": "bash ./scripts/agent-branch-finish.sh",
Expand Down Expand Up @@ -40,6 +41,7 @@
"files": [
"bin",
"src",
"scripts/prepublish-bump-version.js",
"skills",
"templates",
"README.md",
Expand Down
191 changes: 191 additions & 0 deletions scripts/prepublish-bump-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env node
'use strict';

const cp = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}

function escapeRegexLiteral(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function nextPatchVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || '').trim());
if (!match) {
throw new Error(`Cannot auto-bump non-plain semver version: ${version}`);
}
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
}

function defaultNpmView(name, version) {
const result = cp.spawnSync('npm', ['view', `${name}@${version}`, 'version', '--json'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});

if (result.status === 0) {
return { state: 'published' };
}

const details = `${result.stdout || ''}\n${result.stderr || ''}`.trim();
if (/E404|404\s+Not Found|No match found|is not in this registry/i.test(details)) {
return { state: 'unpublished' };
}

if (result.error) {
return { state: 'error', message: result.error.message };
}

return { state: 'error', message: details || `npm view exited with status ${result.status}` };
}

function shouldSkip(env) {
if (env.GUARDEX_SKIP_PUBLISH_BUMP === '1') {
return 'GUARDEX_SKIP_PUBLISH_BUMP=1';
}
if (env.npm_config_dry_run === 'true') {
return 'npm publish --dry-run';
}
if (env.GITHUB_ACTIONS === 'true' && env.GUARDEX_ALLOW_PUBLISH_BUMP !== '1') {
return 'GitHub Actions release workflows keep package.json as the release source of truth';
}
return null;
}

function findPublishableVersion({ name, version, npmView, maxAttempts = 100 }) {
let candidate = version;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const check = npmView(name, candidate);
if (check.state === 'unpublished') {
return { version: candidate, changed: candidate !== version };
}
if (check.state !== 'published') {
throw new Error(
`Unable to verify ${name}@${candidate} on npm: ${check.message || `state=${check.state}`}`,
);
}
candidate = nextPatchVersion(candidate);
}

throw new Error(`Unable to find an unpublished patch version after ${maxAttempts} attempts`);
}

function updatePackageLock(repoRoot, name, version) {
const lockPath = path.join(repoRoot, 'package-lock.json');
if (!fs.existsSync(lockPath)) {
return false;
}

const lockfile = readJson(lockPath);
lockfile.name = name;
lockfile.version = version;
if (lockfile.packages && lockfile.packages['']) {
lockfile.packages[''].name = name;
lockfile.packages[''].version = version;
}
writeJson(lockPath, lockfile);
return true;
}

function buildReleaseNote(name, previousVersion, version) {
return (
`### v${version}\n` +
`- Bumped \`${name}\` from \`${previousVersion}\` to \`${version}\` so direct\n` +
` \`npm publish\` can continue after \`${previousVersion}\` reached the registry.\n\n`
);
}

function updateReadmeReleaseNote(repoRoot, name, previousVersion, version) {
const readmePath = path.join(repoRoot, 'README.md');
if (!fs.existsSync(readmePath)) {
return false;
}

const readme = fs.readFileSync(readmePath, 'utf8');
const headingPattern = new RegExp(`^###\\s+v${escapeRegexLiteral(version)}\\b`, 'm');
if (headingPattern.test(readme)) {
return false;
}

const releaseNote = buildReleaseNote(name, previousVersion, version);
const major = String(version).split('.')[0];
const majorSummaryPattern = new RegExp(
`(<summary><strong>v${escapeRegexLiteral(major)}\\.x</strong></summary>\\n\\n)`,
);
if (majorSummaryPattern.test(readme)) {
fs.writeFileSync(readmePath, readme.replace(majorSummaryPattern, `$1${releaseNote}`), 'utf8');
return true;
}

if (/## Release notes\n\n/.test(readme)) {
fs.writeFileSync(readmePath, readme.replace(/## Release notes\n\n/, `## Release notes\n\n${releaseNote}`), 'utf8');
return true;
}

throw new Error('README.md is missing a Release notes section for the generated publish bump');
}

function runPrepublishBump(options = {}) {
const repoRoot = options.repoRoot || path.resolve(__dirname, '..');
const env = options.env || process.env;
const log = options.log || console.log;
const npmView = options.npmView || defaultNpmView;
const skipReason = shouldSkip(env);

if (skipReason) {
log(`[guardex] prepublish version bump skipped: ${skipReason}.`);
return { changed: false, reason: 'skipped' };
}

const packagePath = path.join(repoRoot, 'package.json');
const packageJson = readJson(packagePath);
if (!packageJson.name || !packageJson.version) {
throw new Error('package.json must include name and version before publish');
}

const next = findPublishableVersion({
name: packageJson.name,
version: packageJson.version,
npmView,
});

if (!next.changed) {
log(`[guardex] ${packageJson.name}@${packageJson.version} is not published yet; keeping package version.`);
return { changed: false, version: packageJson.version };
}

const previousVersion = packageJson.version;
packageJson.version = next.version;
writeJson(packagePath, packageJson);
updatePackageLock(repoRoot, packageJson.name, next.version);
updateReadmeReleaseNote(repoRoot, packageJson.name, previousVersion, next.version);
log(`[guardex] bumped ${packageJson.name} from ${previousVersion} to ${next.version} before npm publish.`);
return { changed: true, version: next.version, previousVersion };
}

if (require.main === module) {
try {
runPrepublishBump();
} catch (error) {
console.error(`[guardex] prepublish version bump failed: ${error.message}`);
process.exitCode = 1;
}
}

module.exports = {
defaultNpmView,
buildReleaseNote,
findPublishableVersion,
nextPatchVersion,
runPrepublishBump,
shouldSkip,
updateReadmeReleaseNote,
};
12 changes: 12 additions & 0 deletions test/metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ test('package manifest pins runtime and dev dependency versions exactly', () =>
assert.match(lockfile, /"fast-check": "3\.23\.2"/);
});

test('package publish lifecycle bumps already-published direct npm publish versions', () => {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const scriptPath = path.join(repoRoot, 'scripts', 'prepublish-bump-version.js');
const scriptSource = fs.readFileSync(scriptPath, 'utf8');

assert.equal(pkg.scripts?.prepublishOnly, 'node scripts/prepublish-bump-version.js');
assert.match(pkg.files.join('\n'), /^scripts\/prepublish-bump-version\.js$/m);
assert.match(scriptSource, /npm view/);
assert.match(scriptSource, /GITHUB_ACTIONS/);
assert.match(scriptSource, /package-lock\.json/);
});

test('frontend mirror workflow skips cleanly when the mirror PAT is missing', () => {
const workflowPath = path.join(repoRoot, '.github', 'workflows', 'sync-frontend-mirror.yml');
const workflow = fs.readFileSync(workflowPath, 'utf8');
Expand Down
Loading
Loading