Repository navigation
fix(release): synchronize web changelog entries #2088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 f76ab5f
fix(release): provide sync push credentials
jatmn a20c8ce
fix(release): gate and finalize web release sync
jatmn 04bfd8c
fix(release): make web release sync recoverable
jatmn 320befb
fix(release): target release PR commands explicitly
jatmn 2cf994c
fix(release): honor manifest release configuration
jatmn 60e62e8
fix(release): recover failed web sync retries
jatmn 62106a5
ci(release): run full sync preflight
jatmn 28fe221
fix(release): validate synchronized PR head
jatmn 94ff953
fix(release): validate bot sync in release job
jatmn 39c519c
fix(release): harden bot-owned web sync
jatmn 4f7b934
fix(release): validate exact bot PR head
jatmn cb7c513
fix(release): isolate and bind PR synchronization
jatmn 5d2bfcc
fix(release): isolate validation from write credentials
jatmn a20a898
fix(release): reject non-regular generated inputs
jatmn c50c079
fix(release): require a valid forward version bump
jatmn 5df3d30
fix(release): scope sync artifacts to run attempts
jatmn ae96eae
fix(release): reuse validated artifacts across retries
jatmn ae80991
fix(release): resume readiness after a completed push
jatmn 18c3b14
fix(release): bind retries to the validated commit
jatmn f093529
fix(release): address synchronization review findings
jatmn 616d36b
fix(release): support CRLF changelog recovery
jatmn acd0ca4
fix(release): keep validation transitions fail-closed
jatmn cb50298
fix(release): restore scoped web sync and marker ownership
jatmn daa99e9
fix(release): keep web sync from blocking npm publish
jatmn bb8287a
fix(release): close web-sync trust and policy gaps
jatmn 363ced4
fix(release): validate gates against synchronized commit
jatmn 02b787a
fix(release): harden web-sync trust boundary and draft gating
jatmn ab39adc
fix(release): restore overlaid inputs before validate cleanliness gate
jatmn 24225c9
fix(release): reuse validated sync artifacts on retry
jatmn cc886f5
fix(release): validate release sync inputs and retries
jatmn 03af399
fix(release): recover web sync state transitions
jatmn d9cc9e6
fix(release): protect generated release ownership
jatmn 9144c96
fix(release): repair web sync recovery gates
jatmn a0c81c2
fix(release): bind sync artifacts to validated base
jatmn e172ca5
fix(release): verify synchronized file mode
jatmn 66d5ab1
fix(release): paginate bot PR discovery
jatmn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 🔥', | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| expect(sanitizeChangelogBullet('* invalid � and �')).toBe( | ||
| 'invalid � and �', | ||
| ) | ||
| }) | ||
|
|
||
| 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) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.