Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
890f8ac
fix(release): sync web changelog entries from release please
jatmn Aug 2, 2026
f76ab5f
fix(release): provide sync push credentials
jatmn Aug 2, 2026
a20c8ce
fix(release): gate and finalize web release sync
jatmn Aug 2, 2026
04bfd8c
fix(release): make web release sync recoverable
jatmn Aug 2, 2026
320befb
fix(release): target release PR commands explicitly
jatmn Aug 2, 2026
2cf994c
fix(release): honor manifest release configuration
jatmn Aug 2, 2026
60e62e8
fix(release): recover failed web sync retries
jatmn Aug 10, 2026
62106a5
ci(release): run full sync preflight
jatmn Aug 10, 2026
28fe221
fix(release): validate synchronized PR head
jatmn Aug 10, 2026
94ff953
fix(release): validate bot sync in release job
jatmn Aug 10, 2026
39c519c
fix(release): harden bot-owned web sync
jatmn Aug 10, 2026
4f7b934
fix(release): validate exact bot PR head
jatmn Aug 10, 2026
cb7c513
fix(release): isolate and bind PR synchronization
jatmn Aug 10, 2026
5d2bfcc
fix(release): isolate validation from write credentials
jatmn Aug 10, 2026
a20a898
fix(release): reject non-regular generated inputs
jatmn Aug 10, 2026
c50c079
fix(release): require a valid forward version bump
jatmn Aug 10, 2026
5df3d30
fix(release): scope sync artifacts to run attempts
jatmn Aug 10, 2026
ae96eae
fix(release): reuse validated artifacts across retries
jatmn Aug 10, 2026
ae80991
fix(release): resume readiness after a completed push
jatmn Aug 10, 2026
18c3b14
fix(release): bind retries to the validated commit
jatmn Aug 10, 2026
f093529
fix(release): address synchronization review findings
jatmn Aug 10, 2026
616d36b
fix(release): support CRLF changelog recovery
jatmn Aug 10, 2026
acd0ca4
fix(release): keep validation transitions fail-closed
jatmn Aug 10, 2026
cb50298
fix(release): restore scoped web sync and marker ownership
jatmn Aug 10, 2026
daa99e9
fix(release): keep web sync from blocking npm publish
jatmn Aug 10, 2026
bb8287a
fix(release): close web-sync trust and policy gaps
jatmn Aug 10, 2026
363ced4
fix(release): validate gates against synchronized commit
jatmn Aug 11, 2026
02b787a
fix(release): harden web-sync trust boundary and draft gating
jatmn Aug 11, 2026
ab39adc
fix(release): restore overlaid inputs before validate cleanliness gate
jatmn Aug 11, 2026
24225c9
fix(release): reuse validated sync artifacts on retry
jatmn Aug 11, 2026
cc886f5
fix(release): validate release sync inputs and retries
jatmn Aug 11, 2026
03af399
fix(release): recover web sync state transitions
jatmn Aug 11, 2026
d9cc9e6
fix(release): protect generated release ownership
jatmn Aug 11, 2026
9144c96
fix(release): repair web sync recovery gates
jatmn Aug 11, 2026
a0c81c2
fix(release): bind sync artifacts to validated base
jatmn Aug 11, 2026
e172ca5
fix(release): verify synchronized file mode
jatmn Aug 11, 2026
66d5ab1
fix(release): paginate bot PR discovery
jatmn Aug 11, 2026
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
693 changes: 690 additions & 3 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ Dependency changes need a clear project benefit — fixing a bug, addressing a s

AI-assisted and vibe-coded contributions are welcome, but please review your own changes thoroughly before opening a PR. Even frontier models produce subtle bugs, incorrect assumptions, and code that looks right but isn't.

### Release web synchronization

Release automation regenerates `web/src/data/releases.ts` on the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Web sync validates with trusted `main` scripts (overlaying only the pending changelog/manifest) in a read-only job, then a separate write-only job applies the verified `releases.ts` artifact so PR-head scripts never run with write credentials. Sync failure cannot block npm or Docker publishing for an already-created release. Automation owns the top generated entry (marked in `releases.ts`); do not hand-edit that file on the bot release branch. To recover the entry on an explicit release/web PR, check out that PR, restore the trusted merge-base copy, then sync and validate:

```bash
base_ref="$(git merge-base origin/main HEAD)"
git show "${base_ref}:web/src/data/releases.ts" > web/src/data/releases.ts
bun install --frozen-lockfile
bun run sync:web-release -- --base-ref "$base_ref"
bun test ./scripts/sync-web-release-entry.test.ts
bun run typecheck
bun run typecheck:type-tests
bun run security:pr-scan -- --base origin/main --head HEAD
git diff --check origin/main...HEAD
bun install --cwd web --frozen-lockfile
bun run web:typecheck
bun run web:build
```

`bun run sync:web-release` writes `web/src/data/releases.ts` from the pending changelog section and manifest version. Commit the generated file and confirm those checks leave tracked files unchanged.

Before submitting, run multiple rounds of review on generated code:

- check for correctness, not just whether it compiles
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"web:build": "bun run --cwd web build",
"web:preview": "bun run --cwd web preview",
"web:typecheck": "bun run --cwd web typecheck",
"sync:web-release": "bun scripts/sync-web-release-entry.ts --write",
Comment thread
jatmn marked this conversation as resolved.
"test": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:full": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:coverage": "bun test --feature=UNATTENDED_RETRY --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
Expand Down
1 change: 1 addition & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"draft-pull-request": true,
"include-v-in-tag": true
}
}
Expand Down
340 changes: 340 additions & 0 deletions scripts/sync-web-release-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import { describe, expect, test } from 'bun:test'

import {
assertVersionAdvances,
compareSemVer,
deriveTheme,
formatReleaseEntry,
GENERATED_ENTRY_MARKER,
insertReleaseEntry,
parseChangelogSection,
readCurrentTopVersion,
sanitizeChangelogBullet,
syncWebReleaseEntry,
} from './sync-web-release-entry'

const SAMPLE_CHANGELOG = `# Changelog

## [0.28.0](https://github.com/Gitlawb/openclaude/compare/v0.27.0...v0.28.0) (2026-08-10)

### Features

* **auth:** opt-in loopback proxy hosts ([#2050](https://github.com/Gitlawb/openclaude/issues/2050)) ([3925f27](https://github.com/Gitlawb/openclaude/commit/3925f27))
* **web:** replace favicon/logo with Ember Block O brand mark ([#2065](https://github.com/Gitlawb/openclaude/issues/2065)) ([56a9201](https://github.com/Gitlawb/openclaude/commit/56a9201))
* third highlight
* fourth highlight
* fifth highlight
* sixth highlight
`

const SAMPLE_RELEASES_TS = `export const releases: Release[] = [
{
version: '0.27.0',
date: '2026-07-30',
theme: 'existing',
highlights: ['one'],
},
]
`

describe('release version ordering', () => {
test('orders numeric prerelease identifiers numerically', () => {
expect(compareSemVer('1.0.0-rc.10', '1.0.0-rc.2')).toBeGreaterThan(0)
expect(compareSemVer('1.0.0-rc.2', '1.0.0-rc.10')).toBeLessThan(0)
})

test('implements SemVer prerelease precedence', () => {
expect(compareSemVer('1.0.0-alpha.1', '1.0.0-alpha.beta')).toBeLessThan(0)
expect(compareSemVer('1.0.0-alpha.beta', '1.0.0-beta')).toBeLessThan(0)
expect(compareSemVer('1.0.0-beta.11', '1.0.0-rc.1')).toBeLessThan(0)
expect(compareSemVer('1.0.0', '1.0.0-rc.1')).toBeGreaterThan(0)
expect(compareSemVer('1.0.0+build.2', '1.0.0+build.1')).toBe(0)
})

test('accepts only a valid advancing release version', () => {
expect(() => assertVersionAdvances('1.0.0-rc.2', '1.0.0-rc.10')).not.toThrow()
expect(() => assertVersionAdvances('1.0.0', '1.0.0')).toThrow(
'release version must advance: 1.0.0 -> 1.0.0',
)
expect(() => assertVersionAdvances('1.0.0', 'not-semver')).toThrow(
'invalid SemVer: not-semver',
)
})
})

describe('syncWebReleaseEntry', () => {
test('inserts a bounded, sanitized draft entry from the requested changelog section', () => {
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(readCurrentTopVersion(result.content)).toBe('0.28.0')
expect(result.content).toContain("version: '0.27.0'")
expect(result.content).toContain('auth: opt-in loopback proxy hosts')
expect(result.content).toContain('fifth highlight')
expect(result.content).not.toContain('sixth highlight')
expect(result.content).toContain(GENERATED_ENTRY_MARKER)
})

test('replaces an already-generated release PR entry when its version changes', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-09',
theme: 'old theme',
highlights: ['old highlight'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content).not.toContain('version: "0.28.0"')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})

test('preserves a published entry when creating the next release', () => {
const published = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'ready',
highlights: ['ready'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: published,
baseReleasesTs: published,
manifestVersion: '0.29.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content).toContain('version: "0.28.0"')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})

test('strips the leftover automation marker across consecutive restore-from-base syncs', () => {
const first = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})
expect(first.status).toBe('updated')
if (first.status !== 'updated') return

// CI restores releases.ts from merge-base after the previous release merged,
// so the working tree matches the marked published entry on main.
const second = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: first.content,
baseReleasesTs: first.content,
manifestVersion: '0.29.0',
})
expect(second.status).toBe('updated')
if (second.status !== 'updated') return
expect(readCurrentTopVersion(second.content)).toBe('0.29.0')
expect(second.content).toContain('version: "0.28.0"')
expect(second.content).toContain("version: '0.27.0'")
expect(second.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})

test('refreshes a generated same-version entry when changelog bullets change', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-09',
theme: 'stale',
highlights: ['stale highlight'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: generated,
baseReleasesTs: generated,
manifestVersion: '0.28.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('auth: opt-in loopback proxy hosts')
expect(result.content).not.toContain('stale highlight')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})

test('replaces an updated entry for the same release version', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.29.0',
date: '2026-08-17',
theme: 'draft',
highlights: ['draft'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0').replace('opt-in loopback proxy hosts', 'updated'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('theme: "updated"')
expect(result.content.match(/version: "0.29.0"/g)).toHaveLength(1)
})

test('replaces a generated entry in a CRLF checkout and preserves its line endings', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'draft',
highlights: ['draft'],
}).replaceAll('\n', '\r\n')
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content.replaceAll('\r\n', '')).not.toContain('\n')
})

test('refuses unmarked divergence for the target version', () => {
const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'curated theme',
highlights: ['curated highlight'],
}).replace(`${GENERATED_ENTRY_MARKER}\n`, '')

expect(() => syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: curated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})).toThrow('differs from the base without the generated-entry marker')
})

test('still refreshes entries that use the legacy generated-entry marker', () => {
const legacyMarker =
' // Generated by release automation; remove this comment before hand-curating.'
const existing = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'old theme',
highlights: ['old highlight'],
}).replace(GENERATED_ENTRY_MARKER, legacyMarker)

const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replace('third highlight', 'updated third highlight'),
releasesTs: existing,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})

expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain(GENERATED_ENTRY_MARKER)
expect(result.content).not.toContain(legacyMarker)
expect(result.content).toContain('updated third highlight')
})

test('refuses to overwrite an unmarked divergent pending entry for another version', () => {
const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'curated theme',
highlights: ['curated highlight'],
}).replace(`${GENERATED_ENTRY_MARKER}\n`, '')

expect(() => syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: curated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})).toThrow('differs from the base without the generated-entry marker')
})

test('is a no-op when the top entry already matches without a draft marker', () => {
const releasesTs = SAMPLE_RELEASES_TS.replace("version: '0.27.0'", "version: '0.28.0'")
expect(
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs,
baseReleasesTs: releasesTs,
manifestVersion: '0.28.0',
}),
).toEqual({
status: 'unchanged',
version: '0.28.0',
reason: 'releases.ts already lists this version first',
})
})

test('rejects a missing version section and an empty release section', () => {
expect(() =>
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '9.9.9',
}),
).toThrow('no CHANGELOG.md section found for version 9.9.9')
expect(() =>
syncWebReleaseEntry({
changelog: '## [0.28.0](url) (2026-08-10)\n',
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
}),
).toThrow('CHANGELOG.md section for 0.28.0 has no bullet highlights')
})

test('requires an explicit trusted base', () => {
expect(() =>
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
}),
).toThrow('missing base release ref')
})
})

describe('formatting helpers', () => {
test('strips markdown and trailing changelog references', () => {
expect(
sanitizeChangelogBullet('* **auth:** ready ([#12](https://example.test/12)) ([abcdef1](https://example.test))'),
).toBe('auth: ready')
expect(sanitizeChangelogBullet('* accept <tag>, arrows -> and 🔥')).toBe(
'accept <tag>, arrows -> and 🔥',
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(sanitizeChangelogBullet('* invalid &#x110000; and &#55296;')).toBe(
'invalid &#x110000; and &#55296;',
)
})

test('derives safe compact themes and escapes generated strings', () => {
expect(deriveTheme([])).toBe('release highlights')
expect(deriveTheme(['plain highlight'])).toBe('plain highlight')
expect(deriveTheme([`scope: ${'x'.repeat(80)}`])).toBe(`${'x'.repeat(69)}…`)
expect(deriveTheme([`scope: ${'x'.repeat(68)}🔥more`])).toBe(`${'x'.repeat(68)}🔥…`)
expect(formatReleaseEntry({ version: '0.28.0', date: '2026-08-10', theme: "it's\rready", highlights: ["don't"] })).toContain(
'theme: "it\'s\\rready"',
)
})

test('parses only the requested release section', () => {
expect(parseChangelogSection(`${SAMPLE_CHANGELOG}\n## [0.27.0](url) (2026-07-30)\n* old`, '0.28.0')?.highlights).toHaveLength(5)
})
})
Loading