From 890f8ace5b3686268f77b39388e54bdaa52d68ec Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 22:52:32 -0700 Subject: [PATCH 01/37] fix(release): sync web changelog entries from release please --- .github/workflows/release.yml | 42 +++++++ package.json | 1 + scripts/sync-web-release-entry.test.ts | 118 +++++++++++++++++++ scripts/sync-web-release-entry.ts | 152 +++++++++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 scripts/sync-web-release-entry.test.ts create mode 100644 scripts/sync-web-release-entry.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 906012118b..143a69f116 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,48 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} release-type: node + # Release Please's PR contains the generated changelog and manifest. + # Synchronize the standalone web version there, before that PR merges. + - name: Check out Release Please PR branch + if: ${{ steps.release.outputs.prs_created == 'true' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Bun for release PR sync + if: ${{ steps.release.outputs.prs_created == 'true' }} + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + no-cache: true + + - name: Sync and validate web release entry + if: ${{ steps.release.outputs.prs_created == 'true' }} + run: | + bun install --frozen-lockfile + bun run sync:web-release + bun test ./scripts/sync-web-release-entry.test.ts + bun install --cwd web --frozen-lockfile + bun run --cwd web build + + - name: Commit synced web release entry + if: ${{ steps.release.outputs.prs_created == 'true' }} + env: + RELEASE_PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} + run: | + if git diff --quiet -- web/src/data/releases.ts; then + echo "releases.ts already up to date" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): sync releases.ts from release-please changelog" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" + # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → # npm 10) and current (24 → npm 11), whose warning output differs. Publish diff --git a/package.json b/package.json index 5edabe7c1f..8dbb1799db 100644 --- a/package.json +++ b/package.json @@ -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", "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", diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts new file mode 100644 index 0000000000..3d75198f07 --- /dev/null +++ b/scripts/sync-web-release-entry.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' + +import { + deriveTheme, + formatReleaseEntry, + 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('syncWebReleaseEntry', () => { + test('inserts a bounded, sanitized draft entry from the requested changelog section', () => { + const result = syncWebReleaseEntry({ + changelog: SAMPLE_CHANGELOG, + releasesTs: 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') + }) + + test('replaces an outdated draft instead of accumulating unpublished entries', () => { + const draft = 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: draft, + 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).toContain('release-please: draft') + }) + + 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, 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, 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, + manifestVersion: '0.28.0', + }), + ).toThrow('CHANGELOG.md section for 0.28.0 has no bullet highlights') + }) +}) + +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') + }) + + 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(formatReleaseEntry({ version: '0.28.0', date: '2026-08-10', theme: "it's ready", highlights: ["don't"] })).toContain( + "theme: 'it\\'s ready'", + ) + }) + + 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) + }) +}) diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts new file mode 100644 index 0000000000..ad56311b5e --- /dev/null +++ b/scripts/sync-web-release-entry.ts @@ -0,0 +1,152 @@ +import { readFileSync, writeFileSync } from 'node:fs' + +export const RELEASES_TS_PATH = 'web/src/data/releases.ts' +export const CHANGELOG_PATH = 'CHANGELOG.md' +export const MANIFEST_PATH = '.release-please-manifest.json' +const RELEASE_PLEASE_DRAFT_MARKER = ' // release-please: draft\n' + +export type ReleaseEntry = { + version: string + date: string + theme: string + highlights: string[] +} + +export function sanitizeChangelogBullet(line: string): string { + let sanitized = line + .replace(/^\*\s+/, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .trim() + + while (true) { + const next = sanitized.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '').trim() + if (next === sanitized) break + sanitized = next + } + + return sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim() +} + +export function parseChangelogSection( + changelog: string, + version?: string, +): { version: string; date: string; highlights: string[] } | null { + const target = version ?? readManifestVersion() + const sectionPattern = new RegExp( + `^## \\[${escapeRegExp(target)}\\][^\\n]*\\((\\d{4}-\\d{2}-\\d{2})\\)\\s*$`, + 'm', + ) + const headerMatch = changelog.match(sectionPattern) + if (!headerMatch) return null + + const start = headerMatch.index ?? changelog.indexOf(headerMatch[0]) + const afterHeader = changelog.slice(start + headerMatch[0].length) + const nextSection = afterHeader.search(/^## \[/m) + const sectionBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection) + const highlights = sectionBody + .split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('* ')) + .map(sanitizeChangelogBullet) + .filter(Boolean) + .slice(0, 5) + + return { version: target, date: headerMatch[1]!, highlights } +} + +export function deriveTheme(highlights: string[]): string { + if (highlights.length === 0) return 'release highlights' + const first = highlights[0]! + const scopeMatch = first.match(/^([a-z0-9-]+):\s*/i) + const theme = scopeMatch ? first.slice(scopeMatch[0].length) : first + return theme.length > 72 ? `${theme.slice(0, 69)}…` : theme +} + +export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8')): string { + const parsed = JSON.parse(manifest) as Record + const version = parsed['.'] + if (!version) throw new Error(`missing root version in ${MANIFEST_PATH}`) + return version +} + +export function readCurrentTopVersion(releasesTs: string): string | null { + const match = releasesTs.match( + /export const releases: Release\[\] = \[\s*(?:\/\/ release-please: draft\s*)?\{\s*version: '([^']+)'/s, + ) + return match?.[1] ?? null +} + +export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string { + const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n') + return `${RELEASE_PLEASE_DRAFT_MARKER}${indent}{ +${indent} version: ${quote(entry.version)}, +${indent} date: ${quote(entry.date)}, +${indent} theme: ${quote(entry.theme)}, +${indent} highlights: [ +${highlightLines} +${indent} ], +${indent}},` +} + +export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { + const withoutDraft = releasesTs.replace(/^ \/\/ release-please: draft\n \{[\s\S]*?^ \},\n/m, '') + const marker = 'export const releases: Release[] = [' + const index = withoutDraft.indexOf(marker) + if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) + + const insertAt = index + marker.length + return `${withoutDraft.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${withoutDraft.slice(insertAt)}` +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export type SyncResult = + | { status: 'unchanged'; version: string; reason: string } + | { status: 'updated'; version: string; content: string } + +export function syncWebReleaseEntry(options: { + changelog?: string + releasesTs?: string + manifestVersion?: string +} = {}): SyncResult { + const version = options.manifestVersion ?? readManifestVersion() + const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8') + const currentTop = readCurrentTopVersion(releasesTs) + if (currentTop === version && !releasesTs.includes(RELEASE_PLEASE_DRAFT_MARKER)) + return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } + + const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8') + const section = parseChangelogSection(changelog, version) + if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`) + if (section.highlights.length === 0) + throw new Error(`CHANGELOG.md section for ${version} has no bullet highlights`) + + return { + status: 'updated', + version, + content: insertReleaseEntry(releasesTs, { + version: section.version, + date: section.date, + theme: deriveTheme(section.highlights), + highlights: section.highlights, + }), + } +} + +if (import.meta.main) { + const result = syncWebReleaseEntry() + if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) + else if (process.argv.includes('--write')) { + writeFileSync(RELEASES_TS_PATH, result.content) + console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`) + } else { + console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`) + process.exit(1) + } +} From f76ab5ff6bcd00786436ec1f55696b0183186110 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 22:53:39 -0700 Subject: [PATCH 02/37] fix(release): provide sync push credentials --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 143a69f116..c991c693f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,7 @@ jobs: - name: Commit synced web release entry if: ${{ steps.release.outputs.prs_created == 'true' }} env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} run: | if git diff --quiet -- web/src/data/releases.ts; then From a20c8ce6b2a050703f1e3683cb2346c911709b65 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:01:23 -0700 Subject: [PATCH 03/37] fix(release): gate and finalize web release sync --- .github/workflows/release.yml | 49 ++++++++++++++++++++++++++ release-please-config.json | 1 + scripts/sync-web-release-entry.test.ts | 38 +++++++++++++++++++- scripts/sync-web-release-entry.ts | 44 ++++++++++++++++------- 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c991c693f8..dc56d45497 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,16 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} release-type: node + - name: Keep the release PR as a draft until web synchronization passes + if: ${{ steps.release.outputs.prs_created == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} + run: | + if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" = "false" ]; then + gh pr ready "$RELEASE_PR_NUMBER" --undo + fi + # Release Please's PR contains the generated changelog and manifest. # Synchronize the standalone web version there, before that PR merges. - name: Check out Release Please PR branch @@ -72,6 +82,45 @@ jobs: git commit -m "chore(web): sync releases.ts from release-please changelog" git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" + - name: Mark synchronized release PR ready for review + if: ${{ steps.release.outputs.prs_created == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} + run: gh pr ready "$RELEASE_PR_NUMBER" + + - name: Check out released main branch + if: ${{ steps.release.outputs.release_created == 'true' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + persist-credentials: false + + - name: Set up Bun to finalize released web entry + if: ${{ steps.release.outputs.release_created == 'true' }} + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + no-cache: true + + - name: Finalize released web entry + if: ${{ steps.release.outputs.release_created == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun install --frozen-lockfile + bun scripts/sync-web-release-entry.ts --finalize + if git diff --quiet -- web/src/data/releases.ts; then + echo "releases.ts is already finalized" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): finalize released changelog entry" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main + # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → # npm 10) and current (24 → npm 11), whose warning output differs. Publish diff --git a/release-please-config.json b/release-please-config.json index 41a3040f8a..9c4e5b1507 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -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 } } diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 3d75198f07..090e1cb673 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { deriveTheme, + finalizeWebReleaseEntry, formatReleaseEntry, insertReleaseEntry, parseChangelogSection, @@ -68,7 +69,42 @@ describe('syncWebReleaseEntry', () => { 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).toContain('release-please: draft') + expect(result.content).toContain('release-please: draft 0.29.0') + }) + + test('preserves an earlier published entry when a later release replaces its draft', () => { + const published = finalizeWebReleaseEntry( + insertReleaseEntry(SAMPLE_RELEASES_TS, { + version: '0.28.0', + date: '2026-08-10', + theme: 'ready', + highlights: ['ready'], + }), + '0.28.0', + ) + const draft = insertReleaseEntry(published, { + version: '0.29.0', + date: '2026-08-17', + theme: 'draft', + highlights: ['draft'], + }) + + expect(draft).toContain("version: '0.29.0'") + expect(draft).toContain("version: '0.28.0'") + expect(draft).not.toContain('release-please: draft 0.28.0') + }) + + test('finalizes only the matching top draft entry', () => { + const draft = insertReleaseEntry(SAMPLE_RELEASES_TS, { + version: '0.28.0', + date: '2026-08-10', + theme: 'draft', + highlights: ['draft'], + }) + expect(finalizeWebReleaseEntry(draft, '0.28.0')).not.toContain('release-please: draft') + expect(() => finalizeWebReleaseEntry(draft, '0.29.0')).toThrow( + 'top release entry does not match released version 0.29.0', + ) }) test('is a no-op when the top entry already matches without a draft marker', () => { diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index ad56311b5e..e6002a3a50 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs' export const RELEASES_TS_PATH = 'web/src/data/releases.ts' export const CHANGELOG_PATH = 'CHANGELOG.md' export const MANIFEST_PATH = '.release-please-manifest.json' -const RELEASE_PLEASE_DRAFT_MARKER = ' // release-please: draft\n' +const RELEASE_PLEASE_DRAFT_PREFIX = ' // release-please: draft ' export type ReleaseEntry = { version: string @@ -74,7 +74,7 @@ export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8 export function readCurrentTopVersion(releasesTs: string): string | null { const match = releasesTs.match( - /export const releases: Release\[\] = \[\s*(?:\/\/ release-please: draft\s*)?\{\s*version: '([^']+)'/s, + /export const releases: Release\[\] = \[\s*(?:\/\/ release-please: draft [^\n]+\s*)?\{\s*version: '([^']+)'/s, ) return match?.[1] ?? null } @@ -82,7 +82,7 @@ export function readCurrentTopVersion(releasesTs: string): string | null { export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string { const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n') - return `${RELEASE_PLEASE_DRAFT_MARKER}${indent}{ + return `${draftMarker(entry.version)}${indent}{ ${indent} version: ${quote(entry.version)}, ${indent} date: ${quote(entry.date)}, ${indent} theme: ${quote(entry.theme)}, @@ -93,7 +93,7 @@ ${indent}},` } export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { - const withoutDraft = releasesTs.replace(/^ \/\/ release-please: draft\n \{[\s\S]*?^ \},\n/m, '') + const withoutDraft = removeTopDraft(releasesTs) const marker = 'export const releases: Release[] = [' const index = withoutDraft.indexOf(marker) if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) @@ -102,6 +102,20 @@ export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): str return `${withoutDraft.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${withoutDraft.slice(insertAt)}` } +export function finalizeWebReleaseEntry(releasesTs: string, version: string): string { + if (readCurrentTopVersion(releasesTs) !== version) + throw new Error(`top release entry does not match released version ${version}`) + return releasesTs.replace(draftMarker(version), '') +} + +function draftMarker(version: string): string { + return `${RELEASE_PLEASE_DRAFT_PREFIX}${version}\n` +} + +function removeTopDraft(releasesTs: string): string { + return releasesTs.replace(/^ \/\/ release-please: draft [^\n]+\n \{[\s\S]*?^ \},\n/m, '') +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -118,7 +132,7 @@ export function syncWebReleaseEntry(options: { const version = options.manifestVersion ?? readManifestVersion() const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8') const currentTop = readCurrentTopVersion(releasesTs) - if (currentTop === version && !releasesTs.includes(RELEASE_PLEASE_DRAFT_MARKER)) + if (currentTop === version && !releasesTs.includes(draftMarker(version))) return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8') @@ -140,13 +154,19 @@ export function syncWebReleaseEntry(options: { } if (import.meta.main) { - const result = syncWebReleaseEntry() - if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) - else if (process.argv.includes('--write')) { - writeFileSync(RELEASES_TS_PATH, result.content) - console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`) + const version = readManifestVersion() + if (process.argv.includes('--finalize')) { + writeFileSync(RELEASES_TS_PATH, finalizeWebReleaseEntry(readFileSync(RELEASES_TS_PATH, 'utf8'), version)) + console.log(`sync-web-release-entry: finalized ${version} in ${RELEASES_TS_PATH}`) } else { - console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`) - process.exit(1) + const result = syncWebReleaseEntry({ manifestVersion: version }) + if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) + else if (process.argv.includes('--write')) { + writeFileSync(RELEASES_TS_PATH, result.content) + console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`) + } else { + console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`) + process.exit(1) + } } } From 04bfd8c41f099f7905eadf1eba39db4e0684747d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:10:30 -0700 Subject: [PATCH 04/37] fix(release): make web release sync recoverable --- .github/workflows/release.yml | 32 ---------- scripts/sync-web-release-entry.test.ts | 81 ++++++++++++++------------ scripts/sync-web-release-entry.ts | 68 ++++++++++----------- 3 files changed, 80 insertions(+), 101 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc56d45497..e6d2605a66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,38 +89,6 @@ jobs: RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} run: gh pr ready "$RELEASE_PR_NUMBER" - - name: Check out released main branch - if: ${{ steps.release.outputs.release_created == 'true' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - persist-credentials: false - - - name: Set up Bun to finalize released web entry - if: ${{ steps.release.outputs.release_created == 'true' }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - no-cache: true - - - name: Finalize released web entry - if: ${{ steps.release.outputs.release_created == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - bun install --frozen-lockfile - bun scripts/sync-web-release-entry.ts --finalize - if git diff --quiet -- web/src/data/releases.ts; then - echo "releases.ts is already finalized" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add web/src/data/releases.ts - git commit -m "chore(web): finalize released changelog entry" - git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main - # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → # npm 10) and current (24 → npm 11), whose warning output differs. Publish diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 090e1cb673..d810ed9405 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'bun:test' import { deriveTheme, - finalizeWebReleaseEntry, formatReleaseEntry, insertReleaseEntry, parseChangelogSection, @@ -52,8 +51,8 @@ describe('syncWebReleaseEntry', () => { expect(result.content).not.toContain('sixth highlight') }) - test('replaces an outdated draft instead of accumulating unpublished entries', () => { - const draft = insertReleaseEntry(SAMPLE_RELEASES_TS, { + 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', @@ -61,56 +60,66 @@ describe('syncWebReleaseEntry', () => { }) const result = syncWebReleaseEntry({ changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'), - releasesTs: draft, + 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).toContain('release-please: draft 0.29.0') + expect(result.content).toContain('version: "0.29.0"') + expect(result.content).not.toContain('version: "0.28.0"') + expect(result.content).not.toContain('release-please: draft') }) - test('preserves an earlier published entry when a later release replaces its draft', () => { - const published = finalizeWebReleaseEntry( - insertReleaseEntry(SAMPLE_RELEASES_TS, { - version: '0.28.0', - date: '2026-08-10', - theme: 'ready', - highlights: ['ready'], - }), - '0.28.0', - ) - const draft = insertReleaseEntry(published, { - version: '0.29.0', - date: '2026-08-17', - theme: 'draft', - highlights: ['draft'], + 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(draft).toContain("version: '0.29.0'") - expect(draft).toContain("version: '0.28.0'") - expect(draft).not.toContain('release-please: draft 0.28.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"') }) - test('finalizes only the matching top draft entry', () => { - const draft = insertReleaseEntry(SAMPLE_RELEASES_TS, { - version: '0.28.0', - date: '2026-08-10', + 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'], }) - expect(finalizeWebReleaseEntry(draft, '0.28.0')).not.toContain('release-please: draft') - expect(() => finalizeWebReleaseEntry(draft, '0.29.0')).toThrow( - 'top release entry does not match released version 0.29.0', - ) + 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('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, manifestVersion: '0.28.0' }), + syncWebReleaseEntry({ + changelog: SAMPLE_CHANGELOG, + releasesTs, + baseReleasesTs: releasesTs, + manifestVersion: '0.28.0', + }), ).toEqual({ status: 'unchanged', version: '0.28.0', @@ -143,8 +152,8 @@ describe('formatting helpers', () => { expect(deriveTheme([])).toBe('release highlights') expect(deriveTheme(['plain highlight'])).toBe('plain highlight') expect(deriveTheme([`scope: ${'x'.repeat(80)}`])).toBe(`${'x'.repeat(69)}…`) - expect(formatReleaseEntry({ version: '0.28.0', date: '2026-08-10', theme: "it's ready", highlights: ["don't"] })).toContain( - "theme: 'it\\'s ready'", + expect(formatReleaseEntry({ version: '0.28.0', date: '2026-08-10', theme: "it's\rready", highlights: ["don't"] })).toContain( + 'theme: "it\'s\\rready"', ) }) diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index e6002a3a50..2253b1ad44 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -1,9 +1,9 @@ +import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' export const RELEASES_TS_PATH = 'web/src/data/releases.ts' export const CHANGELOG_PATH = 'CHANGELOG.md' export const MANIFEST_PATH = '.release-please-manifest.json' -const RELEASE_PLEASE_DRAFT_PREFIX = ' // release-please: draft ' export type ReleaseEntry = { version: string @@ -74,46 +74,47 @@ export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8 export function readCurrentTopVersion(releasesTs: string): string | null { const match = releasesTs.match( - /export const releases: Release\[\] = \[\s*(?:\/\/ release-please: draft [^\n]+\s*)?\{\s*version: '([^']+)'/s, + /export const releases: Release\[\] = \[\s*\{\s*version: ["']([^"']+)["']/s, ) return match?.[1] ?? null } export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string { - const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + const quote = (value: string) => JSON.stringify(value) const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n') - return `${draftMarker(entry.version)}${indent}{ + return `${indent}{ ${indent} version: ${quote(entry.version)}, ${indent} date: ${quote(entry.date)}, ${indent} theme: ${quote(entry.theme)}, ${indent} highlights: [ ${highlightLines} ${indent} ], -${indent}},` +${indent}}, +` } export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { - const withoutDraft = removeTopDraft(releasesTs) const marker = 'export const releases: Release[] = [' - const index = withoutDraft.indexOf(marker) + const index = releasesTs.indexOf(marker) if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) const insertAt = index + marker.length - return `${withoutDraft.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${withoutDraft.slice(insertAt)}` + return `${releasesTs.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${releasesTs.slice(insertAt)}` } -export function finalizeWebReleaseEntry(releasesTs: string, version: string): string { - if (readCurrentTopVersion(releasesTs) !== version) - throw new Error(`top release entry does not match released version ${version}`) - return releasesTs.replace(draftMarker(version), '') -} - -function draftMarker(version: string): string { - return `${RELEASE_PLEASE_DRAFT_PREFIX}${version}\n` +export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { + const marker = 'export const releases: Release[] = [' + const index = releasesTs.indexOf(marker) + if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) + const insertAt = index + marker.length + const existing = releasesTs.slice(insertAt) + const end = existing.search(/^ \},\n/m) + if (end === -1) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`) + return `${releasesTs.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${existing.slice(end + 5)}` } -function removeTopDraft(releasesTs: string): string { - return releasesTs.replace(/^ \/\/ release-please: draft [^\n]+\n \{[\s\S]*?^ \},\n/m, '') +export function readBaseReleasesTs(baseRef = process.env.RELEASE_BASE_REF ?? 'origin/main'): string { + return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' }) } function escapeRegExp(value: string): string { @@ -127,12 +128,14 @@ export type SyncResult = export function syncWebReleaseEntry(options: { changelog?: string releasesTs?: string + baseReleasesTs?: string manifestVersion?: string } = {}): SyncResult { const version = options.manifestVersion ?? readManifestVersion() const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8') const currentTop = readCurrentTopVersion(releasesTs) - if (currentTop === version && !releasesTs.includes(draftMarker(version))) + const baseTop = readCurrentTopVersion(options.baseReleasesTs ?? (options.releasesTs ? releasesTs : readBaseReleasesTs())) + if (currentTop === version && currentTop === baseTop) return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8') @@ -144,7 +147,12 @@ export function syncWebReleaseEntry(options: { return { status: 'updated', version, - content: insertReleaseEntry(releasesTs, { + content: currentTop === version || currentTop !== baseTop ? replaceTopReleaseEntry(releasesTs, { + version: section.version, + date: section.date, + theme: deriveTheme(section.highlights), + highlights: section.highlights, + }) : insertReleaseEntry(releasesTs, { version: section.version, date: section.date, theme: deriveTheme(section.highlights), @@ -154,19 +162,13 @@ export function syncWebReleaseEntry(options: { } if (import.meta.main) { - const version = readManifestVersion() - if (process.argv.includes('--finalize')) { - writeFileSync(RELEASES_TS_PATH, finalizeWebReleaseEntry(readFileSync(RELEASES_TS_PATH, 'utf8'), version)) - console.log(`sync-web-release-entry: finalized ${version} in ${RELEASES_TS_PATH}`) + const result = syncWebReleaseEntry() + if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) + else if (process.argv.includes('--write')) { + writeFileSync(RELEASES_TS_PATH, result.content) + console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`) } else { - const result = syncWebReleaseEntry({ manifestVersion: version }) - if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) - else if (process.argv.includes('--write')) { - writeFileSync(RELEASES_TS_PATH, result.content) - console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`) - } else { - console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`) - process.exit(1) - } + console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`) + process.exit(1) } } From 320befbbdb5d9ea591be306f7311d04848157b1d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:16:01 -0700 Subject: [PATCH 05/37] fix(release): target release PR commands explicitly --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6d2605a66..26ca51e0c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ jobs: - name: Keep the release PR as a draft until web synchronization passes if: ${{ steps.release.outputs.prs_created == 'true' }} env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} run: | @@ -85,6 +86,7 @@ jobs: - name: Mark synchronized release PR ready for review if: ${{ steps.release.outputs.prs_created == 'true' }} env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} run: gh pr ready "$RELEASE_PR_NUMBER" From 2cf994cb2f8412544960aae3f1e0b2e3267fb3d8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:18:38 -0700 Subject: [PATCH 06/37] fix(release): honor manifest release configuration --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26ca51e0c2..34dd77e9d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,6 @@ jobs: uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} - release-type: node - name: Keep the release PR as a draft until web synchronization passes if: ${{ steps.release.outputs.prs_created == 'true' }} From 60e62e8937149166cab5ae7e49dc4005ba353838 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 11:01:36 -0700 Subject: [PATCH 07/37] fix(release): recover failed web sync retries --- .github/workflows/release.yml | 56 ++++++++++++++++++++++++++++------- CONTRIBUTING.md | 4 +++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34dd77e9d5..9ba9546dc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,12 +28,48 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # A rerun after a failed sync has no `pr` output because Release Please + # does not update an unchanged pending PR. Recover that draft explicitly. + - name: Resolve Release Please PR for web synchronization + id: release-pr + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} + run: | + set -euo pipefail + + release_pr="$RELEASE_PR_JSON" + if [ -z "$release_pr" ] || [ "$release_pr" = "null" ]; then + candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number,headRefName,isDraft)" + candidate_count="$(jq length <<<"$candidates")" + if [ "$candidate_count" -eq 0 ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$candidate_count" -ne 1 ]; then + echo "expected exactly one pending Release Please PR for $RELEASE_BASE_BRANCH, found $candidate_count" >&2 + exit 1 + fi + + release_pr="$(jq -c '.[0]' <<<"$candidates")" + if [ "$(jq -r .isDraft <<<"$release_pr")" != "true" ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + echo "found=true" >> "$GITHUB_OUTPUT" + echo "number=$(jq -r .number <<<"$release_pr")" >> "$GITHUB_OUTPUT" + echo "branch=$(jq -r .headRefName <<<"$release_pr")" >> "$GITHUB_OUTPUT" + - name: Keep the release PR as a draft until web synchronization passes - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} run: | if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" = "false" ]; then gh pr ready "$RELEASE_PR_NUMBER" --undo @@ -42,22 +78,22 @@ jobs: # Release Please's PR contains the generated changelog and manifest. # Synchronize the standalone web version there, before that PR merges. - name: Check out Release Please PR branch - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} + ref: ${{ steps.release-pr.outputs.branch }} fetch-depth: 0 persist-credentials: false - name: Set up Bun for release PR sync - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version no-cache: true - name: Sync and validate web release entry - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} run: | bun install --frozen-lockfile bun run sync:web-release @@ -66,10 +102,10 @@ jobs: bun run --cwd web build - name: Commit synced web release entry - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} run: | if git diff --quiet -- web/src/data/releases.ts; then echo "releases.ts already up to date" @@ -83,11 +119,11 @@ jobs: git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" - name: Mark synchronized release PR ready for review - if: ${{ steps.release.outputs.prs_created == 'true' }} + if: ${{ steps.release-pr.outputs.found == 'true' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ fromJSON(steps.release.outputs.pr).number }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} run: gh pr ready "$RELEASE_PR_NUMBER" # Zero-warning install gate: pack the release tag and install it globally in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97918ba060..6b94b82ef6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,10 @@ 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 normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Then run `bun test ./scripts/sync-web-release-entry.test.ts` and `bun run web:build` before committing the result. + Before submitting, run multiple rounds of review on generated code: - check for correctness, not just whether it compiles From 62106a55604030c3ef4dd746ce6921a04dea8938 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 11:10:11 -0700 Subject: [PATCH 08/37] ci(release): run full sync preflight --- .github/workflows/release.yml | 4 ++++ CONTRIBUTING.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ba9546dc2..ef2da7e96b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,10 @@ jobs: bun install --frozen-lockfile bun run sync:web-release 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 --cwd web build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b94b82ef6..8b149d7fe6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Then run `bun test ./scripts/sync-web-release-entry.test.ts` and `bun run web:build` before committing the result. +Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Before committing the result, run `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`, and `bun run web:build`. Before submitting, run multiple rounds of review on generated code: From 28fe22104258952029bbf240599e721f89e9ec93 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 11:22:10 -0700 Subject: [PATCH 09/37] fix(release): validate synchronized PR head --- .github/workflows/release.yml | 34 ++++++++++++++++++++++++++++++---- CONTRIBUTING.md | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef2da7e96b..f691a50e3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,9 +60,16 @@ jobs: fi fi + number="$(jq -r '.number // empty' <<<"$release_pr")" + branch="$(jq -r '.headBranchName // .headRefName // empty' <<<"$release_pr")" + if [ -z "$number" ] || [ -z "$branch" ]; then + echo "release PR JSON is missing its number or head branch name" >&2 + exit 1 + fi + echo "found=true" >> "$GITHUB_OUTPUT" - echo "number=$(jq -r .number <<<"$release_pr")" >> "$GITHUB_OUTPUT" - echo "branch=$(jq -r .headRefName <<<"$release_pr")" >> "$GITHUB_OUTPUT" + echo "number=$number" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" - name: Keep the release PR as a draft until web synchronization passes if: ${{ steps.release-pr.outputs.found == 'true' }} @@ -100,8 +107,6 @@ jobs: 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 --cwd web build @@ -120,8 +125,29 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts git commit -m "chore(web): sync releases.ts from release-please changelog" + + - name: Validate committed synchronized release entry + if: ${{ steps.release-pr.outputs.found == 'true' }} + run: | + bun run security:pr-scan -- --base origin/main --head HEAD + git diff --check origin/main...HEAD + + - name: Push synchronized web release entry + if: ${{ steps.release-pr.outputs.found == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + run: | git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" + - name: Wait for required release PR checks + if: ${{ steps.release-pr.outputs.found == 'true' }} + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} + run: timeout 15m gh pr checks "$RELEASE_PR_NUMBER" --watch --interval 10 + - name: Mark synchronized release PR ready for review if: ${{ steps.release-pr.outputs.found == 'true' }} env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b149d7fe6..01a600649f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Before committing the result, run `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`, and `bun run web:build`. +Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Before committing the result, run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, and `bun run web:build`. Commit the generated file, then run `bun run security:pr-scan -- --base origin/main --head HEAD` and `git diff --check origin/main...HEAD` against that committed release entry before pushing it. Before submitting, run multiple rounds of review on generated code: From 94ff95388b392986bccf5249f492029e5983ea4f Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:05:52 -0700 Subject: [PATCH 10/37] fix(release): validate bot sync in release job --- .github/workflows/release.yml | 27 +++++++++++---------------- CONTRIBUTING.md | 2 +- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f691a50e3e..6a01505e1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,22 +99,15 @@ jobs: bun-version-file: .bun-version no-cache: true - - name: Sync and validate web release entry + - name: Sync web release entry if: ${{ steps.release-pr.outputs.found == 'true' }} run: | bun install --frozen-lockfile bun run sync:web-release - bun test ./scripts/sync-web-release-entry.test.ts - bun run typecheck - bun run typecheck:type-tests bun install --cwd web --frozen-lockfile - bun run --cwd web build - name: Commit synced web release entry if: ${{ steps.release-pr.outputs.found == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} run: | if git diff --quiet -- web/src/data/releases.ts; then echo "releases.ts already up to date" @@ -129,9 +122,19 @@ jobs: - name: Validate committed synchronized release entry if: ${{ steps.release-pr.outputs.found == 'true' }} run: | + bun test ./scripts/sync-web-release-entry.test.ts + bun run typecheck + bun run typecheck:type-tests + bun run --cwd web build bun run security:pr-scan -- --base origin/main --head HEAD git diff --check origin/main...HEAD + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "release validation changed tracked files after the synchronization commit" >&2 + git status --short + exit 1 + fi + - name: Push synchronized web release entry if: ${{ steps.release-pr.outputs.found == 'true' }} env: @@ -140,14 +143,6 @@ jobs: run: | git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" - - name: Wait for required release PR checks - if: ${{ steps.release-pr.outputs.found == 'true' }} - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - run: timeout 15m gh pr checks "$RELEASE_PR_NUMBER" --watch --interval 10 - - name: Mark synchronized release PR ready for review if: ${{ steps.release-pr.outputs.found == 'true' }} env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01a600649f..eb71d15993 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Before committing the result, run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, and `bun run web:build`. Commit the generated file, then run `bun run security:pr-scan -- --base origin/main --head HEAD` and `git diff --check origin/main...HEAD` against that committed release entry before pushing it. +Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base origin/main --head HEAD`, and `git diff --check origin/main...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. Before submitting, run multiple rounds of review on generated code: From 39c519c8fa837887208575d829aafcb46308fe97 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:28:13 -0700 Subject: [PATCH 11/37] fix(release): harden bot-owned web sync --- .github/workflows/release.yml | 19 ++++---- CONTRIBUTING.md | 2 +- scripts/sync-web-release-entry.test.ts | 44 ++++++++++++++++- scripts/sync-web-release-entry.ts | 65 ++++++++++++++++++++++---- web/src/data/releases.ts | 6 +-- web/src/pages/changelog.astro | 4 +- 6 files changed, 115 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a01505e1e..788e021a83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,8 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} # A rerun after a failed sync has no `pr` output because Release Please - # does not update an unchanged pending PR. Recover that draft explicitly. + # does not update an unchanged pending PR. Recover that PR explicitly; + # this job owns its draft/ready state while synchronization is pending. - name: Resolve Release Please PR for web synchronization id: release-pr env: @@ -42,7 +43,7 @@ jobs: release_pr="$RELEASE_PR_JSON" if [ -z "$release_pr" ] || [ "$release_pr" = "null" ]; then - candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number,headRefName,isDraft)" + candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number,headRefName)" candidate_count="$(jq length <<<"$candidates")" if [ "$candidate_count" -eq 0 ]; then echo "found=false" >> "$GITHUB_OUTPUT" @@ -54,10 +55,6 @@ jobs: fi release_pr="$(jq -c '.[0]' <<<"$candidates")" - if [ "$(jq -r .isDraft <<<"$release_pr")" != "true" ]; then - echo "found=false" >> "$GITHUB_OUTPUT" - exit 0 - fi fi number="$(jq -r '.number // empty' <<<"$release_pr")" @@ -101,9 +98,11 @@ jobs: - name: Sync web release entry if: ${{ steps.release-pr.outputs.found == 'true' }} + env: + RELEASE_BASE_REF: ${{ github.sha }} run: | bun install --frozen-lockfile - bun run sync:web-release + bun run sync:web-release -- --base-ref "$RELEASE_BASE_REF" bun install --cwd web --frozen-lockfile - name: Commit synced web release entry @@ -121,13 +120,15 @@ jobs: - name: Validate committed synchronized release entry if: ${{ steps.release-pr.outputs.found == 'true' }} + env: + RELEASE_BASE_REF: ${{ github.sha }} run: | bun test ./scripts/sync-web-release-entry.test.ts bun run typecheck bun run typecheck:type-tests bun run --cwd web build - bun run security:pr-scan -- --base origin/main --head HEAD - git diff --check origin/main...HEAD + bun run security:pr-scan -- --base "$RELEASE_BASE_REF" --head HEAD + git diff --check "$RELEASE_BASE_REF"...HEAD if ! git diff --quiet || ! git diff --cached --quiet; then echo "release validation changed tracked files after the synchronization commit" >&2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb71d15993..9162a7b841 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR. To recover that entry on an explicit release/web PR, run `bun run sync:web-release`; it rewrites only that generated file. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base origin/main --head HEAD`, and `git diff --check origin/main...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. +Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. Before submitting, run multiple rounds of review on generated code: diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index d810ed9405..baad6730dd 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { deriveTheme, formatReleaseEntry, + GENERATED_ENTRY_MARKER, insertReleaseEntry, parseChangelogSection, readCurrentTopVersion, @@ -49,6 +50,7 @@ describe('syncWebReleaseEntry', () => { 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', () => { @@ -69,7 +71,7 @@ describe('syncWebReleaseEntry', () => { 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).not.toContain('release-please: draft') + expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1) }) test('preserves a published entry when creating the next release', () => { @@ -111,6 +113,42 @@ describe('syncWebReleaseEntry', () => { expect(result.content.match(/version: "0.29.0"/g)).toHaveLength(1) }) + test('preserves a hand-curated pending entry 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', + })).toEqual({ + status: 'unchanged', + version: '0.28.0', + reason: 'preserving hand-curated release entry', + }) + }) + + 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( @@ -146,12 +184,16 @@ describe('formatting helpers', () => { expect( sanitizeChangelogBullet('* **auth:** ready ([#12](https://example.test/12)) ([abcdef1](https://example.test))'), ).toBe('auth: ready') + expect(sanitizeChangelogBullet('* accept <tag>, arrows -> and 🔥')).toBe( + 'accept , arrows -> 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"', ) diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 2253b1ad44..39d155e05e 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -4,6 +4,8 @@ import { readFileSync, writeFileSync } from 'node:fs' export const RELEASES_TS_PATH = 'web/src/data/releases.ts' export const CHANGELOG_PATH = 'CHANGELOG.md' export const MANIFEST_PATH = '.release-please-manifest.json' +export const GENERATED_ENTRY_MARKER = + ' // Generated by release automation; remove this comment before hand-curating.' export type ReleaseEntry = { version: string @@ -27,7 +29,26 @@ export function sanitizeChangelogBullet(line: string): string { sanitized = next } - return sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim() + return decodeHtmlEntities(sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim()) +} + +function decodeHtmlEntities(value: string): string { + const named: Record = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + quot: '"', + } + + return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, decimal, hexadecimal, name) => { + if (name) return named[name.toLowerCase()] ?? entity + const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16) + if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) + return entity + if (codePoint >= 0xd800 && codePoint <= 0xdfff) return entity + return String.fromCodePoint(codePoint) + }) } export function parseChangelogSection( @@ -62,7 +83,8 @@ export function deriveTheme(highlights: string[]): string { const first = highlights[0]! const scopeMatch = first.match(/^([a-z0-9-]+):\s*/i) const theme = scopeMatch ? first.slice(scopeMatch[0].length) : first - return theme.length > 72 ? `${theme.slice(0, 69)}…` : theme + const characters = [...theme] + return characters.length > 72 ? `${characters.slice(0, 69).join('')}…` : theme } export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8')): string { @@ -74,7 +96,7 @@ export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8 export function readCurrentTopVersion(releasesTs: string): string | null { const match = releasesTs.match( - /export const releases: Release\[\] = \[\s*\{\s*version: ["']([^"']+)["']/s, + /export const releases: Release\[\] = \[\s*(?:\/\/[^\n]*\s*)?\{\s*version: ["']([^"']+)["']/s, ) return match?.[1] ?? null } @@ -99,7 +121,7 @@ export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): str if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) const insertAt = index + marker.length - return `${releasesTs.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${releasesTs.slice(insertAt)}` + return `${releasesTs.slice(0, insertAt)}\n${GENERATED_ENTRY_MARKER}\n${formatReleaseEntry(entry)}${releasesTs.slice(insertAt)}` } export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { @@ -110,10 +132,17 @@ export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): const existing = releasesTs.slice(insertAt) const end = existing.search(/^ \},\n/m) if (end === -1) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`) - return `${releasesTs.slice(0, insertAt)}\n${formatReleaseEntry(entry)}${existing.slice(end + 5)}` + return `${releasesTs.slice(0, insertAt)}\n${GENERATED_ENTRY_MARKER}\n${formatReleaseEntry(entry)}${existing.slice(end + 5)}` +} + +export function hasGeneratedTopEntry(releasesTs: string): boolean { + const marker = 'export const releases: Release[] = [' + const index = releasesTs.indexOf(marker) + if (index === -1) return false + return releasesTs.slice(index + marker.length).startsWith(`\n${GENERATED_ENTRY_MARKER}\n`) } -export function readBaseReleasesTs(baseRef = process.env.RELEASE_BASE_REF ?? 'origin/main'): string { +export function readBaseReleasesTs(baseRef: string): string { return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' }) } @@ -129,15 +158,29 @@ export function syncWebReleaseEntry(options: { changelog?: string releasesTs?: string baseReleasesTs?: string + baseRef?: string manifestVersion?: string } = {}): SyncResult { const version = options.manifestVersion ?? readManifestVersion() const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8') const currentTop = readCurrentTopVersion(releasesTs) - const baseTop = readCurrentTopVersion(options.baseReleasesTs ?? (options.releasesTs ? releasesTs : readBaseReleasesTs())) + const baseReleasesTs = options.baseReleasesTs + ?? (options.releasesTs ? releasesTs : options.baseRef ? readBaseReleasesTs(options.baseRef) : null) + if (baseReleasesTs === null) + throw new Error('missing base release ref; pass --base-ref or provide baseReleasesTs') + const baseTop = readCurrentTopVersion(baseReleasesTs) if (currentTop === version && currentTop === baseTop) return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } + const replaceGeneratedTop = currentTop !== baseTop && hasGeneratedTopEntry(releasesTs) + if (currentTop !== baseTop && !replaceGeneratedTop) { + if (currentTop === version) + return { status: 'unchanged', version, reason: 'preserving hand-curated release entry' } + throw new Error( + `${RELEASES_TS_PATH} differs from the base without the generated-entry marker; refusing to overwrite it`, + ) + } + const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8') const section = parseChangelogSection(changelog, version) if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`) @@ -147,7 +190,7 @@ export function syncWebReleaseEntry(options: { return { status: 'updated', version, - content: currentTop === version || currentTop !== baseTop ? replaceTopReleaseEntry(releasesTs, { + content: replaceGeneratedTop ? replaceTopReleaseEntry(releasesTs, { version: section.version, date: section.date, theme: deriveTheme(section.highlights), @@ -162,7 +205,11 @@ export function syncWebReleaseEntry(options: { } if (import.meta.main) { - const result = syncWebReleaseEntry() + const baseRefIndex = process.argv.indexOf('--base-ref') + const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1] + if (baseRefIndex !== -1 && (!baseRef || baseRef.startsWith('--'))) + throw new Error('--base-ref requires a git ref argument') + const result = syncWebReleaseEntry({ baseRef }) if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`) else if (process.argv.includes('--write')) { writeFileSync(RELEASES_TS_PATH, result.content) diff --git a/web/src/data/releases.ts b/web/src/data/releases.ts index 37bd5e0a24..20530e850e 100644 --- a/web/src/data/releases.ts +++ b/web/src/data/releases.ts @@ -1,6 +1,6 @@ -// Curated release highlights for the /changelog page. Full notes live on -// GitHub releases; this list is the hand-picked "what actually matters" -// per minor version. Newest first. +// Generated release highlights for the /changelog page. Release automation +// selects the first five changelog bullets; full notes live on GitHub releases. +// Newest first. // // Ownership: release/web process only (release automation and dedicated web // release PRs). Do not edit this file from ordinary feature or bugfix PRs, diff --git a/web/src/pages/changelog.astro b/web/src/pages/changelog.astro index 675d770d12..78b6a11be0 100644 --- a/web/src/pages/changelog.astro +++ b/web/src/pages/changelog.astro @@ -4,7 +4,7 @@ import { SITE } from '../data/site' import { releases, releaseUrl, RELEASES_URL } from '../data/releases' const title = `what's new in openclaude — release highlights | openclaude` -const description = `Curated highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.` +const description = `Generated highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.` const jsonLd = [ { @@ -32,7 +32,7 @@ const jsonLd = [

currently v{SITE.version}

what's new.

- the highlights that matter from each release — hand-picked, not a commit dump. + a quick generated summary from each release changelog. full notes live on github releases.

From 4f7b9340e27bf1e7b7b43ab08cc803198a174c77 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:35:48 -0700 Subject: [PATCH 12/37] fix(release): validate exact bot PR head --- .github/workflows/release.yml | 52 ++++++++++++++++++++++++++++--- scripts/sync-web-release-entry.ts | 2 +- web/src/data/releases.ts | 6 ++-- web/src/pages/changelog.astro | 4 +-- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 788e021a83..a9b3529e0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: # this job owns its draft/ready state while synchronization is pending. - name: Resolve Release Please PR for web synchronization id: release-pr + if: ${{ steps.release.outputs.release_created != 'true' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -89,6 +90,25 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Resolve the release PR merge base + id: sync-base + if: ${{ steps.release-pr.outputs.found == 'true' }} + env: + RELEASE_BASE_BRANCH: ${{ github.ref_name }} + run: | + base_sha="$(git merge-base "origin/$RELEASE_BASE_BRANCH" HEAD)" + if [ -z "$base_sha" ]; then + echo "could not resolve the release PR merge base" >&2 + exit 1 + fi + echo "sha=$base_sha" >> "$GITHUB_OUTPUT" + + - name: Set up current Node.js for release PR validation + if: ${{ steps.release-pr.outputs.found == 'true' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.11.x" + - name: Set up Bun for release PR sync if: ${{ steps.release-pr.outputs.found == 'true' }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -99,7 +119,7 @@ jobs: - name: Sync web release entry if: ${{ steps.release-pr.outputs.found == 'true' }} env: - RELEASE_BASE_REF: ${{ github.sha }} + RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} run: | bun install --frozen-lockfile bun run sync:web-release -- --base-ref "$RELEASE_BASE_REF" @@ -118,18 +138,42 @@ jobs: git add web/src/data/releases.ts git commit -m "chore(web): sync releases.ts from release-please changelog" - - name: Validate committed synchronized release entry + - name: Validate synchronized release PR on current Node.js if: ${{ steps.release-pr.outputs.found == 'true' }} env: - RELEASE_BASE_REF: ${{ github.sha }} + RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} run: | bun test ./scripts/sync-web-release-entry.test.ts + bun run check bun run typecheck bun run typecheck:type-tests - bun run --cwd web build + node bin/openclaude --version + NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version + bun run test:provider + bun run test:provider-recommendation bun run security:pr-scan -- --base "$RELEASE_BASE_REF" --head HEAD git diff --check "$RELEASE_BASE_REF"...HEAD + - name: Set up minimum supported Node.js for release PR validation + if: ${{ steps.release-pr.outputs.found == 'true' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22.0.0" + + - name: Validate synchronized release PR on the Node.js floor + if: ${{ steps.release-pr.outputs.found == 'true' }} + run: | + bun run check + node bin/openclaude --version + NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version + bun run test:provider + bun run test:provider-recommendation + bun run --cwd web typecheck + bun run --cwd web build + + - name: Confirm release validation left the commit unchanged + if: ${{ steps.release-pr.outputs.found == 'true' }} + run: | if ! git diff --quiet || ! git diff --cached --quiet; then echo "release validation changed tracked files after the synchronization commit" >&2 git status --short diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 39d155e05e..6bb00d2a8e 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -103,7 +103,7 @@ export function readCurrentTopVersion(releasesTs: string): string | null { export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string { const quote = (value: string) => JSON.stringify(value) - const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n') + const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n') return `${indent}{ ${indent} version: ${quote(entry.version)}, ${indent} date: ${quote(entry.date)}, diff --git a/web/src/data/releases.ts b/web/src/data/releases.ts index 20530e850e..00d29a5649 100644 --- a/web/src/data/releases.ts +++ b/web/src/data/releases.ts @@ -1,6 +1,6 @@ -// Generated release highlights for the /changelog page. Release automation -// selects the first five changelog bullets; full notes live on GitHub releases. -// Newest first. +// Release highlights for the /changelog page. Automation selects the first +// five changelog bullets for new entries; older entries were hand-curated. +// Full notes live on GitHub releases. Newest first. // // Ownership: release/web process only (release automation and dedicated web // release PRs). Do not edit this file from ordinary feature or bugfix PRs, diff --git a/web/src/pages/changelog.astro b/web/src/pages/changelog.astro index 78b6a11be0..833e54e06f 100644 --- a/web/src/pages/changelog.astro +++ b/web/src/pages/changelog.astro @@ -4,7 +4,7 @@ import { SITE } from '../data/site' import { releases, releaseUrl, RELEASES_URL } from '../data/releases' const title = `what's new in openclaude — release highlights | openclaude` -const description = `Generated highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.` +const description = `Highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.` const jsonLd = [ { @@ -32,7 +32,7 @@ const jsonLd = [

currently v{SITE.version}

what's new.

- a quick generated summary from each release changelog. + a quick summary of each release. full notes live on github releases.

From cb7c5139b5c2008484dc42330c75d4107451909d Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:46:00 -0700 Subject: [PATCH 13/37] fix(release): isolate and bind PR synchronization --- .github/workflows/release.yml | 100 +++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9b3529e0d..e6e7372bde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} + pr: ${{ steps.release.outputs.pr }} steps: - name: Run release-please id: release @@ -28,23 +29,31 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + release-pr-sync: + name: Synchronize Release Please PR + needs: release-please + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: # A rerun after a failed sync has no `pr` output because Release Please # does not update an unchanged pending PR. Recover that PR explicitly; # this job owns its draft/ready state while synchronization is pending. - name: Resolve Release Please PR for web synchronization id: release-pr - if: ${{ steps.release.outputs.release_created != 'true' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} + RELEASE_PR_JSON: ${{ needs.release-please.outputs.pr }} + RELEASE_REPO_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail release_pr="$RELEASE_PR_JSON" if [ -z "$release_pr" ] || [ "$release_pr" = "null" ]; then - candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number,headRefName)" + candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number)" candidate_count="$(jq length <<<"$candidates")" if [ "$candidate_count" -eq 0 ]; then echo "found=false" >> "$GITHUB_OUTPUT" @@ -59,15 +68,37 @@ jobs: fi number="$(jq -r '.number // empty' <<<"$release_pr")" - branch="$(jq -r '.headBranchName // .headRefName // empty' <<<"$release_pr")" - if [ -z "$number" ] || [ -z "$branch" ]; then - echo "release PR JSON is missing its number or head branch name" >&2 + if [ -z "$number" ]; then + echo "release PR JSON is missing its number" >&2 + exit 1 + fi + + release_pr="$(gh pr view "$number" --json number,state,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + state="$(jq -r .state <<<"$release_pr")" + author="$(jq -r .author.login <<<"$release_pr")" + base="$(jq -r .baseRefName <<<"$release_pr")" + branch="$(jq -r .headRefName <<<"$release_pr")" + head_sha="$(jq -r .headRefOid <<<"$release_pr")" + head_owner="$(jq -r .headRepositoryOwner.login <<<"$release_pr")" + cross_repo="$(jq -r .isCrossRepository <<<"$release_pr")" + expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" + + if [ "$state" != "OPEN" ] \ + || { [ "$author" != "app/github-actions" ] && [ "$author" != "github-actions[bot]" ]; } \ + || [ "$base" != "$RELEASE_BASE_BRANCH" ] \ + || [[ "$branch" != "$expected_prefix"* ]] \ + || [ -z "$head_sha" ] \ + || [ "$head_owner" != "$RELEASE_REPO_OWNER" ] \ + || [ "$cross_repo" != "false" ]; then + echo "pending PR #$number does not match the expected same-repository Release Please identity" >&2 + jq . <<<"$release_pr" >&2 exit 1 fi echo "found=true" >> "$GITHUB_OUTPUT" echo "number=$number" >> "$GITHUB_OUTPUT" echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - name: Keep the release PR as a draft until web synchronization passes if: ${{ steps.release-pr.outputs.found == 'true' }} @@ -86,7 +117,7 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ steps.release-pr.outputs.branch }} + ref: ${{ steps.release-pr.outputs.head_sha }} fetch-depth: 0 persist-credentials: false @@ -184,9 +215,26 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} + RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | - git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${RELEASE_PR_BRANCH}" + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,baseRefName,headRefName,headRefOid)" + if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ + || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ + || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ + || [ "$(jq -r .headRefOid <<<"$current")" != "$RELEASE_PR_HEAD_SHA" ]; then + echo "release PR changed or closed during validation; refusing to push" >&2 + jq . <<<"$current" >&2 + exit 1 + fi + + git push \ + --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:${RELEASE_PR_BRANCH}" - name: Mark synchronized release PR ready for review if: ${{ steps.release-pr.outputs.found == 'true' }} @@ -194,7 +242,41 @@ jobs: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - run: gh pr ready "$RELEASE_PR_NUMBER" + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} + run: | + local_sha="$(git rev-parse HEAD)" + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" + + if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ + || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ + || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ + || [ "$(jq -r .headRefOid <<<"$current")" != "$local_sha" ]; then + echo "release PR no longer points at the validated commit" >&2 + jq . <<<"$current" >&2 + if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then + gh pr ready "$RELEASE_PR_NUMBER" --undo || true + fi + exit 1 + fi + + if [ "$(jq -r .isDraft <<<"$current")" = "true" ]; then + gh pr ready "$RELEASE_PR_NUMBER" + fi + + final="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" + if [ "$(jq -r .state <<<"$final")" != "OPEN" ] \ + || [ "$(jq -r .isDraft <<<"$final")" != "false" ] \ + || [ "$(jq -r .baseRefName <<<"$final")" != "$RELEASE_BASE_BRANCH" ] \ + || [ "$(jq -r .headRefName <<<"$final")" != "$RELEASE_PR_BRANCH" ] \ + || [ "$(jq -r .headRefOid <<<"$final")" != "$local_sha" ]; then + echo "release PR changed while it was being marked ready" >&2 + jq . <<<"$final" >&2 + if [ "$(jq -r .state <<<"$final")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$final")" = "false" ]; then + gh pr ready "$RELEASE_PR_NUMBER" --undo || true + fi + exit 1 + fi # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → From 5d2bfcc5c855075daed19424f55e6a2be68ec2b8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:55:03 -0700 Subject: [PATCH 14/37] fix(release): isolate validation from write credentials --- .github/workflows/release.yml | 147 +++++++++++++++++++++++++++++----- CONTRIBUTING.md | 2 +- 2 files changed, 127 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6e7372bde..410175f425 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,13 +29,18 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - release-pr-sync: - name: Synchronize Release Please PR + release-pr-resolve: + name: Resolve Release Please PR needs: release-please runs-on: ubuntu-latest permissions: - contents: write + contents: read pull-requests: write + outputs: + found: ${{ steps.release-pr.outputs.found }} + number: ${{ steps.release-pr.outputs.number }} + branch: ${{ steps.release-pr.outputs.branch }} + head_sha: ${{ steps.release-pr.outputs.head_sha }} steps: # A rerun after a failed sync has no `pr` output because Release Please # does not update an unchanged pending PR. Recover that PR explicitly; @@ -111,19 +116,27 @@ jobs: gh pr ready "$RELEASE_PR_NUMBER" --undo fi + release-pr-validate: + name: Validate Release Please PR + needs: release-pr-resolve + if: ${{ needs.release-pr-resolve.outputs.found == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tree_sha: ${{ steps.validated-tree.outputs.sha }} + steps: # Release Please's PR contains the generated changelog and manifest. # Synchronize the standalone web version there, before that PR merges. - name: Check out Release Please PR branch - if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ steps.release-pr.outputs.head_sha }} + ref: ${{ needs.release-pr-resolve.outputs.head_sha }} fetch-depth: 0 persist-credentials: false - name: Resolve the release PR merge base id: sync-base - if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | @@ -134,21 +147,62 @@ jobs: fi echo "sha=$base_sha" >> "$GITHUB_OUTPUT" + - name: Verify the Release Please change contract + env: + RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} + run: | + unexpected=false + while IFS= read -r path; do + case "$path" in + .release-please-manifest.json|CHANGELOG.md|package.json|web/src/data/releases.ts) ;; + *) + echo "unexpected file in Release Please PR: $path" >&2 + unexpected=true + ;; + esac + done < <(git diff --name-only "$RELEASE_BASE_REF"...HEAD) + if [ "$unexpected" = "true" ]; then + exit 1 + fi + + if ! diff -u \ + <(git show "${RELEASE_BASE_REF}:package.json" | jq -S 'del(.version)') \ + <(jq -S 'del(.version)' package.json); then + echo "Release Please PR changes package.json fields other than version" >&2 + exit 1 + fi + + if ! diff -u \ + <(git show "${RELEASE_BASE_REF}:.release-please-manifest.json" | jq -S 'del(.["."])') \ + <(jq -S 'del(.["."])' .release-please-manifest.json); then + echo "Release Please PR changes manifest fields other than the root version" >&2 + exit 1 + fi + + package_version="$(jq -r .version package.json)" + manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" + if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] \ + || [ "$package_version" != "$manifest_version" ]; then + echo "package and Release Please manifest versions do not match" >&2 + exit 1 + fi + + # The bot owns this file. Rebuild it from the trusted base so a + # collaborator push cannot smuggle executable TypeScript into it. + git show "${RELEASE_BASE_REF}:web/src/data/releases.ts" > web/src/data/releases.ts + - name: Set up current Node.js for release PR validation - if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24.11.x" - name: Set up Bun for release PR sync - if: ${{ steps.release-pr.outputs.found == 'true' }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version no-cache: true - name: Sync web release entry - if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} run: | @@ -157,7 +211,6 @@ jobs: bun install --cwd web --frozen-lockfile - name: Commit synced web release entry - if: ${{ steps.release-pr.outputs.found == 'true' }} run: | if git diff --quiet -- web/src/data/releases.ts; then echo "releases.ts already up to date" @@ -170,7 +223,6 @@ jobs: git commit -m "chore(web): sync releases.ts from release-please changelog" - name: Validate synchronized release PR on current Node.js - if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} run: | @@ -186,13 +238,11 @@ jobs: git diff --check "$RELEASE_BASE_REF"...HEAD - name: Set up minimum supported Node.js for release PR validation - if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.0.0" - name: Validate synchronized release PR on the Node.js floor - if: ${{ steps.release-pr.outputs.found == 'true' }} run: | bun run check node bin/openclaude --version @@ -203,7 +253,6 @@ jobs: bun run --cwd web build - name: Confirm release validation left the commit unchanged - if: ${{ steps.release-pr.outputs.found == 'true' }} run: | if ! git diff --quiet || ! git diff --cached --quiet; then echo "release validation changed tracked files after the synchronization commit" >&2 @@ -211,14 +260,71 @@ jobs: exit 1 fi + - name: Capture validated tree + id: validated-tree + run: echo "sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + + - name: Save validated web release data + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + path: web/src/data/releases.ts + if-no-files-found: error + retention-days: 1 + + release-pr-finalize: + name: Finalize Release Please PR + needs: [release-pr-resolve, release-pr-validate] + if: ${{ needs.release-pr-resolve.outputs.found == 'true' && needs.release-pr-validate.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check out captured Release Please PR head + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-pr-resolve.outputs.head_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Restore validated web release data + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + path: web/src/data + + - name: Recreate the validated synchronized commit + env: + VALIDATED_TREE_SHA: ${{ needs.release-pr-validate.outputs.tree_sha }} + run: | + if ! git diff --quiet -- web/src/data/releases.ts; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): sync releases.ts from release-please changelog" + fi + + actual_tree_sha="$(git rev-parse 'HEAD^{tree}')" + if [ "$actual_tree_sha" != "$VALIDATED_TREE_SHA" ]; then + echo "restored release data does not recreate the validated tree" >&2 + echo "expected $VALIDATED_TREE_SHA, got $actual_tree_sha" >&2 + exit 1 + fi + + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "finalization left uncommitted tracked changes" >&2 + git status --short + exit 1 + fi + - name: Push synchronized web release entry - if: ${{ steps.release-pr.outputs.found == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} + RELEASE_PR_BRANCH: ${{ needs.release-pr-resolve.outputs.branch }} + RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} + RELEASE_PR_HEAD_SHA: ${{ needs.release-pr-resolve.outputs.head_sha }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,baseRefName,headRefName,headRefOid)" @@ -237,12 +343,11 @@ jobs: "HEAD:${RELEASE_PR_BRANCH}" - name: Mark synchronized release PR ready for review - if: ${{ steps.release-pr.outputs.found == 'true' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} + RELEASE_PR_BRANCH: ${{ needs.release-pr-resolve.outputs.branch }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | local_sha="$(git rev-parse HEAD)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9162a7b841..c98358c8f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation normally regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. +Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. The automated path rebuilds the entry from the trusted PR base; it does not execute or preserve edits pushed directly to the bot-owned file. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the manual command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. Before submitting, run multiple rounds of review on generated code: From a20a898115c09f658a189832226c53cce4bdeaf3 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 12:56:28 -0700 Subject: [PATCH 15/37] fix(release): reject non-regular generated inputs --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 410175f425..4d9bd381ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,6 +165,14 @@ jobs: exit 1 fi + for path in .release-please-manifest.json CHANGELOG.md package.json web/src/data/releases.ts; do + entry="$(git ls-tree HEAD -- "$path")" + if [[ "$entry" != "100644 blob "* ]]; then + echo "Release Please file must remain a regular non-executable blob: $path" >&2 + exit 1 + fi + done + if ! diff -u \ <(git show "${RELEASE_BASE_REF}:package.json" | jq -S 'del(.version)') \ <(jq -S 'del(.version)' package.json); then From c50c079b6b59674c8ce72b339dc2c008b96ba508 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:02:28 -0700 Subject: [PATCH 16/37] fix(release): require a valid forward version bump --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d9bd381ca..3e5d6b7faf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,12 +189,56 @@ jobs: package_version="$(jq -r .version package.json)" manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" - if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] \ - || [ "$package_version" != "$manifest_version" ]; then + base_package_version="$(git show "${RELEASE_BASE_REF}:package.json" | jq -r .version)" + base_manifest_version="$(git show "${RELEASE_BASE_REF}:.release-please-manifest.json" | jq -r '.["."]')" + if [ "$package_version" != "$manifest_version" ] \ + || [ "$base_package_version" != "$base_manifest_version" ]; then echo "package and Release Please manifest versions do not match" >&2 exit 1 fi + BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" node -e ' + const identifier = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + const pattern = new RegExp( + `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + + `(?:-(${identifier}(?:\\.${identifier})*))?` + + `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, + ) + const parse = value => { + const match = value.match(pattern) + if (!match) throw new Error(`invalid SemVer: ${value}`) + return { + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease: match[4]?.split(".") ?? null, + } + } + const compare = (left, right) => { + for (let index = 0; index < 3; index++) { + if (left.core[index] !== right.core[index]) + return left.core[index] < right.core[index] ? -1 : 1 + } + if (left.prerelease === null || right.prerelease === null) + return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1 + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index++) { + const a = left.prerelease[index] + const b = right.prerelease[index] + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1 + if (a === b) continue + const aNumeric = /^\\d+$/.test(a) + const bNumeric = /^\\d+$/.test(b) + if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 + return a < b ? -1 : 1 + } + return 0 + } + const base = parse(process.env.BASE_VERSION) + const next = parse(process.env.NEXT_VERSION) + if (compare(next, base) <= 0) + throw new Error(`release version must advance: ${process.env.BASE_VERSION} -> ${process.env.NEXT_VERSION}`) + ' + # The bot owns this file. Rebuild it from the trusted base so a # collaborator push cannot smuggle executable TypeScript into it. git show "${RELEASE_BASE_REF}:web/src/data/releases.ts" > web/src/data/releases.ts From 5df3d30fa4adeca437f265ce6824a4045225dcd5 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:05:07 -0700 Subject: [PATCH 17/37] fix(release): scope sync artifacts to run attempts --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e5d6b7faf..4556fe80a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,7 +319,7 @@ jobs: - name: Save validated web release data uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + name: release-web-sync-${{ github.run_attempt }}-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} path: web/src/data/releases.ts if-no-files-found: error retention-days: 1 @@ -343,7 +343,7 @@ jobs: - name: Restore validated web release data uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + name: release-web-sync-${{ github.run_attempt }}-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} path: web/src/data - name: Recreate the validated synchronized commit From ae96eae544ab53fd9cf23b46ef1217714d3619aa Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:07:56 -0700 Subject: [PATCH 18/37] fix(release): reuse validated artifacts across retries --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4556fe80a8..93be319b96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,9 +319,10 @@ jobs: - name: Save validated web release data uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: release-web-sync-${{ github.run_attempt }}-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} path: web/src/data/releases.ts if-no-files-found: error + overwrite: true retention-days: 1 release-pr-finalize: @@ -343,7 +344,7 @@ jobs: - name: Restore validated web release data uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: release-web-sync-${{ github.run_attempt }}-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} + name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} path: web/src/data - name: Recreate the validated synchronized commit From ae809915b7537f8beaf0cb4cc13f7f9b41b25034 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:11:57 -0700 Subject: [PATCH 19/37] fix(release): resume readiness after a completed push --- .github/workflows/release.yml | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93be319b96..f7f1e17b24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -372,6 +372,7 @@ jobs: fi - name: Push synchronized web release entry + id: push-state env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} @@ -379,21 +380,38 @@ jobs: RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} RELEASE_PR_HEAD_SHA: ${{ needs.release-pr-resolve.outputs.head_sha }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} + VALIDATED_TREE_SHA: ${{ needs.release-pr-validate.outputs.tree_sha }} run: | current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,baseRefName,headRefName,headRefOid)" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ - || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ - || [ "$(jq -r .headRefOid <<<"$current")" != "$RELEASE_PR_HEAD_SHA" ]; then - echo "release PR changed or closed during validation; refusing to push" >&2 + || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ]; then + echo "release PR changed or closed during validation" >&2 jq . <<<"$current" >&2 exit 1 fi - git push \ - --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:${RELEASE_PR_BRANCH}" + current_sha="$(jq -r .headRefOid <<<"$current")" + if [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then + git push \ + --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:${RELEASE_PR_BRANCH}" + validated_head_sha="$(git rev-parse HEAD)" + else + # A failed-job rerun may start after the prior attempt pushed but + # failed during readiness. Accept only the exact validated tree. + git fetch --no-tags origin "$current_sha" + current_tree_sha="$(git rev-parse "${current_sha}^{tree}")" + if [ "$current_tree_sha" != "$VALIDATED_TREE_SHA" ]; then + echo "release PR head changed to an unvalidated tree" >&2 + jq . <<<"$current" >&2 + exit 1 + fi + validated_head_sha="$current_sha" + fi + + echo "head_sha=$validated_head_sha" >> "$GITHUB_OUTPUT" - name: Mark synchronized release PR ready for review env: @@ -402,14 +420,14 @@ jobs: RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} RELEASE_PR_BRANCH: ${{ needs.release-pr-resolve.outputs.branch }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} + VALIDATED_HEAD_SHA: ${{ steps.push-state.outputs.head_sha }} run: | - local_sha="$(git rev-parse HEAD)" current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ - || [ "$(jq -r .headRefOid <<<"$current")" != "$local_sha" ]; then + || [ "$(jq -r .headRefOid <<<"$current")" != "$VALIDATED_HEAD_SHA" ]; then echo "release PR no longer points at the validated commit" >&2 jq . <<<"$current" >&2 if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then @@ -427,7 +445,7 @@ jobs: || [ "$(jq -r .isDraft <<<"$final")" != "false" ] \ || [ "$(jq -r .baseRefName <<<"$final")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$final")" != "$RELEASE_PR_BRANCH" ] \ - || [ "$(jq -r .headRefOid <<<"$final")" != "$local_sha" ]; then + || [ "$(jq -r .headRefOid <<<"$final")" != "$VALIDATED_HEAD_SHA" ]; then echo "release PR changed while it was being marked ready" >&2 jq . <<<"$final" >&2 if [ "$(jq -r .state <<<"$final")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$final")" = "false" ]; then From 18c3b140a96cdf2394957c84749e92b22e62da3d Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:16:11 -0700 Subject: [PATCH 20/37] fix(release): bind retries to the validated commit --- .github/workflows/release.yml | 40 +++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7f1e17b24..b745ca0136 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,7 @@ jobs: contents: read outputs: tree_sha: ${{ steps.validated-tree.outputs.sha }} + commit_sha: ${{ steps.validated-tree.outputs.commit_sha }} steps: # Release Please's PR contains the generated changelog and manifest. # Synchronize the standalone web version there, before that PR merges. @@ -272,7 +273,9 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts - git commit -m "chore(web): sync releases.ts from release-please changelog" + commit_date="$(git show -s --format=%cI HEAD)" + GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ + git commit -m "chore(web): sync releases.ts from release-please changelog" - name: Validate synchronized release PR on current Node.js env: @@ -314,7 +317,9 @@ jobs: - name: Capture validated tree id: validated-tree - run: echo "sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + run: | + echo "sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Save validated web release data uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -323,7 +328,7 @@ jobs: path: web/src/data/releases.ts if-no-files-found: error overwrite: true - retention-days: 1 + retention-days: 30 release-pr-finalize: name: Finalize Release Please PR @@ -350,12 +355,15 @@ jobs: - name: Recreate the validated synchronized commit env: VALIDATED_TREE_SHA: ${{ needs.release-pr-validate.outputs.tree_sha }} + VALIDATED_COMMIT_SHA: ${{ needs.release-pr-validate.outputs.commit_sha }} run: | if ! git diff --quiet -- web/src/data/releases.ts; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts - git commit -m "chore(web): sync releases.ts from release-please changelog" + commit_date="$(git show -s --format=%cI HEAD)" + GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ + git commit -m "chore(web): sync releases.ts from release-please changelog" fi actual_tree_sha="$(git rev-parse 'HEAD^{tree}')" @@ -365,6 +373,13 @@ jobs: exit 1 fi + actual_commit_sha="$(git rev-parse HEAD)" + if [ "$actual_commit_sha" != "$VALIDATED_COMMIT_SHA" ]; then + echo "restored release data does not recreate the validated commit" >&2 + echo "expected $VALIDATED_COMMIT_SHA, got $actual_commit_sha" >&2 + exit 1 + fi + if ! git diff --quiet || ! git diff --cached --quiet; then echo "finalization left uncommitted tracked changes" >&2 git status --short @@ -380,7 +395,7 @@ jobs: RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} RELEASE_PR_HEAD_SHA: ${{ needs.release-pr-resolve.outputs.head_sha }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - VALIDATED_TREE_SHA: ${{ needs.release-pr-validate.outputs.tree_sha }} + VALIDATED_COMMIT_SHA: ${{ needs.release-pr-validate.outputs.commit_sha }} run: | current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,baseRefName,headRefName,headRefOid)" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ @@ -398,17 +413,14 @@ jobs: "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ "HEAD:${RELEASE_PR_BRANCH}" validated_head_sha="$(git rev-parse HEAD)" - else + elif [ "$current_sha" = "$VALIDATED_COMMIT_SHA" ]; then # A failed-job rerun may start after the prior attempt pushed but - # failed during readiness. Accept only the exact validated tree. - git fetch --no-tags origin "$current_sha" - current_tree_sha="$(git rev-parse "${current_sha}^{tree}")" - if [ "$current_tree_sha" != "$VALIDATED_TREE_SHA" ]; then - echo "release PR head changed to an unvalidated tree" >&2 - jq . <<<"$current" >&2 - exit 1 - fi + # failed during readiness. Accept only the exact validated commit. validated_head_sha="$current_sha" + else + echo "release PR head changed to an unvalidated commit" >&2 + jq . <<<"$current" >&2 + exit 1 fi echo "head_sha=$validated_head_sha" >> "$GITHUB_OUTPUT" From f0935297fd60e5cac5c28e91012f7f4539f93ab5 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 13:47:01 -0700 Subject: [PATCH 21/37] fix(release): address synchronization review findings --- .github/workflows/release.yml | 18 +++++++++++------- CONTRIBUTING.md | 2 +- scripts/sync-web-release-entry.test.ts | 3 +++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b745ca0136..1f5944ca3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write + pull-requests: write # Return the release PR to draft while validation runs. outputs: found: ${{ steps.release-pr.outputs.found }} number: ${{ steps.release-pr.outputs.number }} @@ -226,8 +226,8 @@ jobs: const b = right.prerelease[index] if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1 if (a === b) continue - const aNumeric = /^\\d+$/.test(a) - const bNumeric = /^\\d+$/.test(b) + const aNumeric = /^[0-9]+$/.test(a) + const bNumeric = /^[0-9]+$/.test(b) if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 return a < b ? -1 : 1 @@ -336,8 +336,8 @@ jobs: if: ${{ needs.release-pr-resolve.outputs.found == 'true' && needs.release-pr-validate.result == 'success' }} runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + contents: write # Push the exact synchronized commit validated above. + pull-requests: write # Mark that release PR ready after the push is verified. steps: - name: Check out captured Release Please PR head uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -408,9 +408,13 @@ jobs: current_sha="$(jq -r .headRefOid <<<"$current")" if [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then - git push \ + auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}" \ + git push \ --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "https://github.com/${GITHUB_REPOSITORY}.git" \ "HEAD:${RELEASE_PR_BRANCH}" validated_head_sha="$(git rev-parse HEAD)" elif [ "$current_sha" = "$VALIDATED_COMMIT_SHA" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c98358c8f9..b2cad2d6bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. The automated path rebuilds the entry from the trusted PR base; it does not execute or preserve edits pushed directly to the bot-owned file. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the manual command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. +Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. The automated path rebuilds the entry from the trusted PR base; it does not execute or preserve edits pushed directly to the bot-owned file. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the manual command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:typecheck`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. Before submitting, run multiple rounds of review on generated code: diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index baad6730dd..536cab84ec 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -187,6 +187,9 @@ describe('formatting helpers', () => { expect(sanitizeChangelogBullet('* accept <tag>, arrows -> and 🔥')).toBe( 'accept , arrows -> and 🔥', ) + expect(sanitizeChangelogBullet('* invalid � and �')).toBe( + 'invalid � and �', + ) }) test('derives safe compact themes and escapes generated strings', () => { From 616d36beaa8aa7c3ae88af28c0560c4ba95606bc Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 14:07:13 -0700 Subject: [PATCH 22/37] fix(release): support CRLF changelog recovery --- scripts/sync-web-release-entry.test.ts | 20 ++++++++++++++++++++ scripts/sync-web-release-entry.ts | 21 ++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 536cab84ec..5ff4b7fbd5 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -113,6 +113,26 @@ describe('syncWebReleaseEntry', () => { 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('preserves a hand-curated pending entry for the target version', () => { const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, { version: '0.28.0', diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 6bb00d2a8e..3be0431e39 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -121,7 +121,9 @@ export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): str if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) const insertAt = index + marker.length - return `${releasesTs.slice(0, insertAt)}\n${GENERATED_ENTRY_MARKER}\n${formatReleaseEntry(entry)}${releasesTs.slice(insertAt)}` + const eol = detectLineEnding(releasesTs) + const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol) + return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${releasesTs.slice(insertAt)}` } export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { @@ -130,16 +132,25 @@ export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) const insertAt = index + marker.length const existing = releasesTs.slice(insertAt) - const end = existing.search(/^ \},\n/m) - if (end === -1) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`) - return `${releasesTs.slice(0, insertAt)}\n${GENERATED_ENTRY_MARKER}\n${formatReleaseEntry(entry)}${existing.slice(end + 5)}` + const endMatch = /^ \},\r?\n/m.exec(existing) + if (!endMatch) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`) + const eol = detectLineEnding(releasesTs) + const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol) + const afterEntry = endMatch.index + endMatch[0].length + return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${existing.slice(afterEntry)}` } export function hasGeneratedTopEntry(releasesTs: string): boolean { const marker = 'export const releases: Release[] = [' const index = releasesTs.indexOf(marker) if (index === -1) return false - return releasesTs.slice(index + marker.length).startsWith(`\n${GENERATED_ENTRY_MARKER}\n`) + const existing = releasesTs.slice(index + marker.length) + return existing.startsWith(`\n${GENERATED_ENTRY_MARKER}\n`) + || existing.startsWith(`\r\n${GENERATED_ENTRY_MARKER}\r\n`) +} + +function detectLineEnding(value: string): '\n' | '\r\n' { + return value.includes('\r\n') ? '\r\n' : '\n' } export function readBaseReleasesTs(baseRef: string): string { From acd0ca490047ade38e1812cc1cc102972830919e Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 14:30:14 -0700 Subject: [PATCH 23/37] fix(release): keep validation transitions fail-closed --- .github/workflows/release.yml | 101 ++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f5944ca3a..2b296b3ba4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,14 +15,63 @@ jobs: name: Release Please runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + contents: write # Create release commits and tags. + pull-requests: write # Draft and update the bot-owned release PR. outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} pr: ${{ steps.release.outputs.pr }} steps: + # Release Please pushes an existing PR branch before it observes that + # the PR already exists, and its draft option applies only on creation. + # Draft the verified bot PR first so an updated head is never exposed as + # ready before the exact-head validation jobs run. + - name: Keep an existing Release Please PR draft before updating it + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_REPO_OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + + candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number)" + candidate_count="$(jq length <<<"$candidates")" + if [ "$candidate_count" -eq 0 ]; then + exit 0 + fi + if [ "$candidate_count" -ne 1 ]; then + echo "expected at most one pending Release Please PR for $RELEASE_BASE_BRANCH, found $candidate_count" >&2 + exit 1 + fi + + number="$(jq -r '.[0].number' <<<"$candidates")" + release_pr="$(gh pr view "$number" --json state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" + + if [ "$(jq -r .state <<<"$release_pr")" != "OPEN" ] \ + || { [ "$(jq -r .author.login <<<"$release_pr")" != "app/github-actions" ] \ + && [ "$(jq -r .author.login <<<"$release_pr")" != "github-actions[bot]" ]; } \ + || [ "$(jq -r .baseRefName <<<"$release_pr")" != "$RELEASE_BASE_BRANCH" ] \ + || [[ "$(jq -r .headRefName <<<"$release_pr")" != "$expected_prefix"* ]] \ + || [ -z "$(jq -r .headRefOid <<<"$release_pr")" ] \ + || [ "$(jq -r .headRepositoryOwner.login <<<"$release_pr")" != "$RELEASE_REPO_OWNER" ] \ + || [ "$(jq -r .isCrossRepository <<<"$release_pr")" != "false" ]; then + echo "pending PR #$number does not match the expected same-repository Release Please identity" >&2 + jq . <<<"$release_pr" >&2 + exit 1 + fi + + if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then + gh pr ready "$number" --undo + fi + + if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep pending Release Please PR #$number in draft state" >&2 + exit 1 + fi + - name: Run release-please id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 @@ -430,6 +479,7 @@ jobs: echo "head_sha=$validated_head_sha" >> "$GITHUB_OUTPUT" - name: Mark synchronized release PR ready for review + id: ready-state env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -447,7 +497,7 @@ jobs: echo "release PR no longer points at the validated commit" >&2 jq . <<<"$current" >&2 if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then - gh pr ready "$RELEASE_PR_NUMBER" --undo || true + gh pr ready "$RELEASE_PR_NUMBER" --undo fi exit 1 fi @@ -465,11 +515,54 @@ jobs: echo "release PR changed while it was being marked ready" >&2 jq . <<<"$final" >&2 if [ "$(jq -r .state <<<"$final")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$final")" = "false" ]; then - gh pr ready "$RELEASE_PR_NUMBER" --undo || true + gh pr ready "$RELEASE_PR_NUMBER" --undo fi exit 1 fi + - name: Return the release PR to draft after incomplete finalization + if: ${{ always() && steps.ready-state.outcome != 'success' }} + timeout-minutes: 2 + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} + run: | + for attempt in 1 2 3 4 5; do + if current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft 2>/dev/null)"; then + state="$(jq -r .state <<<"$current")" + is_draft="$(jq -r .isDraft <<<"$current")" + + if [ "$state" = "CLOSED" ]; then + exit 0 + fi + if [ "$state" = "OPEN" ] && [ "$is_draft" = "true" ]; then + exit 0 + fi + if [ "$state" != "OPEN" ]; then + echo "release PR #$RELEASE_PR_NUMBER reached terminal state $state before it could be re-drafted" >&2 + exit 1 + fi + + if gh pr ready "$RELEASE_PR_NUMBER" --undo; then + if after="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft 2>/dev/null)"; then + after_state="$(jq -r .state <<<"$after")" + after_draft="$(jq -r .isDraft <<<"$after")" + if [ "$after_state" = "CLOSED" ] \ + || { [ "$after_state" = "OPEN" ] && [ "$after_draft" = "true" ]; }; then + exit 0 + fi + fi + fi + fi + + echo "attempt $attempt failed to return release PR #$RELEASE_PR_NUMBER to draft" >&2 + sleep "$((attempt * 2))" + done + + echo "release PR #$RELEASE_PR_NUMBER may still be ready after incomplete finalization" >&2 + exit 1 + # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → # npm 10) and current (24 → npm 11), whose warning output differs. Publish From cb50298c40ef75b10e10ce86c33bcc0c1aa784b1 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 15:31:55 -0700 Subject: [PATCH 24/37] fix(release): restore scoped web sync and marker ownership Cut the multi-job finalize state machine back to a single draft-until-push sync path, and fix consecutive releases leaving stacked automation markers by stripping leftover draft ownership when inserting the next version. --- .github/workflows/release.yml | 414 +++++-------------------- CONTRIBUTING.md | 11 +- scripts/sync-web-release-entry.test.ts | 48 +++ scripts/sync-web-release-entry.ts | 52 ++-- 4 files changed, 165 insertions(+), 360 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b296b3ba4..25c7d03212 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,18 +15,16 @@ jobs: name: Release Please runs-on: ubuntu-latest permissions: - contents: write # Create release commits and tags. - pull-requests: write # Draft and update the bot-owned release PR. + contents: write # Create release commits/tags and push the synced releases.ts commit. + pull-requests: write # Keep the bot release PR draft until sync lands, then mark ready. outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} - pr: ${{ steps.release.outputs.pr }} steps: - # Release Please pushes an existing PR branch before it observes that - # the PR already exists, and its draft option applies only on creation. - # Draft the verified bot PR first so an updated head is never exposed as - # ready before the exact-head validation jobs run. + # Release Please's draft option applies only on PR creation. Draft any + # existing pending bot PR before it updates the branch so an unvalidated + # head is not left ready for merge. - name: Keep an existing Release Please PR draft before updating it env: GH_REPO: ${{ github.repository }} @@ -78,29 +76,18 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - release-pr-resolve: - name: Resolve Release Please PR - needs: release-please - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write # Return the release PR to draft while validation runs. - outputs: - found: ${{ steps.release-pr.outputs.found }} - number: ${{ steps.release-pr.outputs.number }} - branch: ${{ steps.release-pr.outputs.branch }} - head_sha: ${{ steps.release-pr.outputs.head_sha }} - steps: - # A rerun after a failed sync has no `pr` output because Release Please - # does not update an unchanged pending PR. Recover that PR explicitly; - # this job owns its draft/ready state while synchronization is pending. - - name: Resolve Release Please PR for web synchronization + # Resolve the pending bot PR (from the action output, or by label on + # reruns where Release Please leaves an unchanged PR untouched). Keep it + # draft, sync releases.ts from the trusted merge base, validate the sync + # locally, push, then mark ready. Do not wait for ordinary PR checks: + # GITHUB_TOKEN pushes do not start them. + - name: Resolve pending Release Please PR id: release-pr env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - RELEASE_PR_JSON: ${{ needs.release-please.outputs.pr }} + RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} RELEASE_REPO_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail @@ -117,7 +104,6 @@ jobs: echo "expected exactly one pending Release Please PR for $RELEASE_BASE_BRANCH, found $candidate_count" >&2 exit 1 fi - release_pr="$(jq -c '.[0]' <<<"$candidates")" fi @@ -127,7 +113,7 @@ jobs: exit 1 fi - release_pr="$(gh pr view "$number" --json number,state,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + release_pr="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" state="$(jq -r .state <<<"$release_pr")" author="$(jq -r .author.login <<<"$release_pr")" base="$(jq -r .baseRefName <<<"$release_pr")" @@ -149,313 +135,107 @@ jobs: exit 1 fi + if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then + gh pr ready "$number" --undo + fi + if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep pending Release Please PR #$number in draft state" >&2 + exit 1 + fi + echo "found=true" >> "$GITHUB_OUTPUT" echo "number=$number" >> "$GITHUB_OUTPUT" echo "branch=$branch" >> "$GITHUB_OUTPUT" echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - - name: Keep the release PR as a draft until web synchronization passes + - name: Check out Release Please PR head if: ${{ steps.release-pr.outputs.found == 'true' }} - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - run: | - if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" = "false" ]; then - gh pr ready "$RELEASE_PR_NUMBER" --undo - fi - - release-pr-validate: - name: Validate Release Please PR - needs: release-pr-resolve - if: ${{ needs.release-pr-resolve.outputs.found == 'true' }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - tree_sha: ${{ steps.validated-tree.outputs.sha }} - commit_sha: ${{ steps.validated-tree.outputs.commit_sha }} - steps: - # Release Please's PR contains the generated changelog and manifest. - # Synchronize the standalone web version there, before that PR merges. - - name: Check out Release Please PR branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ needs.release-pr-resolve.outputs.head_sha }} + ref: ${{ steps.release-pr.outputs.head_sha }} fetch-depth: 0 persist-credentials: false - - name: Resolve the release PR merge base - id: sync-base - env: - RELEASE_BASE_BRANCH: ${{ github.ref_name }} - run: | - base_sha="$(git merge-base "origin/$RELEASE_BASE_BRANCH" HEAD)" - if [ -z "$base_sha" ]; then - echo "could not resolve the release PR merge base" >&2 - exit 1 - fi - echo "sha=$base_sha" >> "$GITHUB_OUTPUT" - - - name: Verify the Release Please change contract - env: - RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} - run: | - unexpected=false - while IFS= read -r path; do - case "$path" in - .release-please-manifest.json|CHANGELOG.md|package.json|web/src/data/releases.ts) ;; - *) - echo "unexpected file in Release Please PR: $path" >&2 - unexpected=true - ;; - esac - done < <(git diff --name-only "$RELEASE_BASE_REF"...HEAD) - if [ "$unexpected" = "true" ]; then - exit 1 - fi - - for path in .release-please-manifest.json CHANGELOG.md package.json web/src/data/releases.ts; do - entry="$(git ls-tree HEAD -- "$path")" - if [[ "$entry" != "100644 blob "* ]]; then - echo "Release Please file must remain a regular non-executable blob: $path" >&2 - exit 1 - fi - done - - if ! diff -u \ - <(git show "${RELEASE_BASE_REF}:package.json" | jq -S 'del(.version)') \ - <(jq -S 'del(.version)' package.json); then - echo "Release Please PR changes package.json fields other than version" >&2 - exit 1 - fi - - if ! diff -u \ - <(git show "${RELEASE_BASE_REF}:.release-please-manifest.json" | jq -S 'del(.["."])') \ - <(jq -S 'del(.["."])' .release-please-manifest.json); then - echo "Release Please PR changes manifest fields other than the root version" >&2 - exit 1 - fi - - package_version="$(jq -r .version package.json)" - manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" - base_package_version="$(git show "${RELEASE_BASE_REF}:package.json" | jq -r .version)" - base_manifest_version="$(git show "${RELEASE_BASE_REF}:.release-please-manifest.json" | jq -r '.["."]')" - if [ "$package_version" != "$manifest_version" ] \ - || [ "$base_package_version" != "$base_manifest_version" ]; then - echo "package and Release Please manifest versions do not match" >&2 - exit 1 - fi - - BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" node -e ' - const identifier = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" - const pattern = new RegExp( - `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + - `(?:-(${identifier}(?:\\.${identifier})*))?` + - `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, - ) - const parse = value => { - const match = value.match(pattern) - if (!match) throw new Error(`invalid SemVer: ${value}`) - return { - core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], - prerelease: match[4]?.split(".") ?? null, - } - } - const compare = (left, right) => { - for (let index = 0; index < 3; index++) { - if (left.core[index] !== right.core[index]) - return left.core[index] < right.core[index] ? -1 : 1 - } - if (left.prerelease === null || right.prerelease === null) - return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1 - const length = Math.max(left.prerelease.length, right.prerelease.length) - for (let index = 0; index < length; index++) { - const a = left.prerelease[index] - const b = right.prerelease[index] - if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1 - if (a === b) continue - const aNumeric = /^[0-9]+$/.test(a) - const bNumeric = /^[0-9]+$/.test(b) - if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 - if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 - return a < b ? -1 : 1 - } - return 0 - } - const base = parse(process.env.BASE_VERSION) - const next = parse(process.env.NEXT_VERSION) - if (compare(next, base) <= 0) - throw new Error(`release version must advance: ${process.env.BASE_VERSION} -> ${process.env.NEXT_VERSION}`) - ' - - # The bot owns this file. Rebuild it from the trusted base so a - # collaborator push cannot smuggle executable TypeScript into it. - git show "${RELEASE_BASE_REF}:web/src/data/releases.ts" > web/src/data/releases.ts - - - name: Set up current Node.js for release PR validation - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24.11.x" - - name: Set up Bun for release PR sync + if: ${{ steps.release-pr.outputs.found == 'true' }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version no-cache: true - - name: Sync web release entry - env: - RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} - run: | - bun install --frozen-lockfile - bun run sync:web-release -- --base-ref "$RELEASE_BASE_REF" - bun install --cwd web --frozen-lockfile - - - name: Commit synced web release entry - run: | - if git diff --quiet -- web/src/data/releases.ts; then - echo "releases.ts already up to date" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add web/src/data/releases.ts - commit_date="$(git show -s --format=%cI HEAD)" - GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ - git commit -m "chore(web): sync releases.ts from release-please changelog" - - - name: Validate synchronized release PR on current Node.js + - name: Sync and validate web release entry + id: sync-web + if: ${{ steps.release-pr.outputs.found == 'true' }} env: - RELEASE_BASE_REF: ${{ steps.sync-base.outputs.sha }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | - bun test ./scripts/sync-web-release-entry.test.ts - bun run check - bun run typecheck - bun run typecheck:type-tests - node bin/openclaude --version - NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version - bun run test:provider - bun run test:provider-recommendation - bun run security:pr-scan -- --base "$RELEASE_BASE_REF" --head HEAD - git diff --check "$RELEASE_BASE_REF"...HEAD - - - name: Set up minimum supported Node.js for release PR validation - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "22.0.0" + set -euo pipefail - - name: Validate synchronized release PR on the Node.js floor - run: | - bun run check - node bin/openclaude --version - NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version - bun run test:provider - bun run test:provider-recommendation - bun run --cwd web typecheck - bun run --cwd web build - - - name: Confirm release validation left the commit unchanged - run: | - if ! git diff --quiet || ! git diff --cached --quiet; then - echo "release validation changed tracked files after the synchronization commit" >&2 - git status --short + base_sha="$(git merge-base "origin/$RELEASE_BASE_BRANCH" HEAD)" + if [ -z "$base_sha" ]; then + echo "could not resolve the release PR merge base" >&2 exit 1 fi - - name: Capture validated tree - id: validated-tree - run: | - echo "sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" - echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - - name: Save validated web release data - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} - path: web/src/data/releases.ts - if-no-files-found: error - overwrite: true - retention-days: 30 - - release-pr-finalize: - name: Finalize Release Please PR - needs: [release-pr-resolve, release-pr-validate] - if: ${{ needs.release-pr-resolve.outputs.found == 'true' && needs.release-pr-validate.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: write # Push the exact synchronized commit validated above. - pull-requests: write # Mark that release PR ready after the push is verified. - steps: - - name: Check out captured Release Please PR head - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.release-pr-resolve.outputs.head_sha }} - fetch-depth: 0 - persist-credentials: false + # Rebuild from the trusted base so a collaborator push cannot smuggle + # executable TypeScript through the bot-owned file. + git show "${base_sha}:web/src/data/releases.ts" > web/src/data/releases.ts - - name: Restore validated web release data - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: release-web-sync-${{ needs.release-pr-resolve.outputs.number }}-${{ needs.release-pr-resolve.outputs.head_sha }} - path: web/src/data + bun install --frozen-lockfile + bun run sync:web-release -- --base-ref "$base_sha" + bun test ./scripts/sync-web-release-entry.test.ts + bun install --cwd web --frozen-lockfile + bun run web:typecheck + bun run web:build - - name: Recreate the validated synchronized commit - env: - VALIDATED_TREE_SHA: ${{ needs.release-pr-validate.outputs.tree_sha }} - VALIDATED_COMMIT_SHA: ${{ needs.release-pr-validate.outputs.commit_sha }} - run: | if ! git diff --quiet -- web/src/data/releases.ts; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts - commit_date="$(git show -s --format=%cI HEAD)" - GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ - git commit -m "chore(web): sync releases.ts from release-please changelog" - fi - - actual_tree_sha="$(git rev-parse 'HEAD^{tree}')" - if [ "$actual_tree_sha" != "$VALIDATED_TREE_SHA" ]; then - echo "restored release data does not recreate the validated tree" >&2 - echo "expected $VALIDATED_TREE_SHA, got $actual_tree_sha" >&2 - exit 1 - fi - - actual_commit_sha="$(git rev-parse HEAD)" - if [ "$actual_commit_sha" != "$VALIDATED_COMMIT_SHA" ]; then - echo "restored release data does not recreate the validated commit" >&2 - echo "expected $VALIDATED_COMMIT_SHA, got $actual_commit_sha" >&2 - exit 1 + git commit -m "chore(web): sync releases.ts from release-please changelog" + else + echo "releases.ts already up to date" fi if ! git diff --quiet || ! git diff --cached --quiet; then - echo "finalization left uncommitted tracked changes" >&2 + echo "sync validation left uncommitted tracked changes" >&2 git status --short exit 1 fi - name: Push synchronized web release entry - id: push-state + id: push-sync + if: ${{ steps.release-pr.outputs.found == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - RELEASE_PR_BRANCH: ${{ needs.release-pr-resolve.outputs.branch }} - RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} - RELEASE_PR_HEAD_SHA: ${{ needs.release-pr-resolve.outputs.head_sha }} + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} + RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - VALIDATED_COMMIT_SHA: ${{ needs.release-pr-validate.outputs.commit_sha }} run: | - current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,baseRefName,headRefName,headRefOid)" + set -euo pipefail + + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ]; then - echo "release PR changed or closed during validation" >&2 + echo "release PR changed or closed during sync" >&2 jq . <<<"$current" >&2 exit 1 fi + # Stay draft until the synced commit is on the remote head. + if [ "$(jq -r .isDraft <<<"$current")" != "true" ]; then + gh pr ready "$RELEASE_PR_NUMBER" --undo + if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep release PR #$RELEASE_PR_NUMBER draft before push" >&2 + exit 1 + fi + fi + current_sha="$(jq -r .headRefOid <<<"$current")" + local_sha="$(git rev-parse HEAD)" if [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" GIT_CONFIG_COUNT=1 \ @@ -465,36 +245,35 @@ jobs: --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ "https://github.com/${GITHUB_REPOSITORY}.git" \ "HEAD:${RELEASE_PR_BRANCH}" - validated_head_sha="$(git rev-parse HEAD)" - elif [ "$current_sha" = "$VALIDATED_COMMIT_SHA" ]; then - # A failed-job rerun may start after the prior attempt pushed but - # failed during readiness. Accept only the exact validated commit. - validated_head_sha="$current_sha" + elif [ "$current_sha" = "$local_sha" ]; then + # A rerun after a successful push can skip the lease push. + echo "release PR already points at the synchronized commit" else echo "release PR head changed to an unvalidated commit" >&2 jq . <<<"$current" >&2 exit 1 fi - echo "head_sha=$validated_head_sha" >> "$GITHUB_OUTPUT" + echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Mark synchronized release PR ready for review - id: ready-state + if: ${{ steps.release-pr.outputs.found == 'true' && steps.push-sync.outcome == 'success' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} - RELEASE_PR_BRANCH: ${{ needs.release-pr-resolve.outputs.branch }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - VALIDATED_HEAD_SHA: ${{ steps.push-state.outputs.head_sha }} + VALIDATED_HEAD_SHA: ${{ steps.push-sync.outputs.head_sha }} run: | - current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" + set -euo pipefail + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ || [ "$(jq -r .headRefOid <<<"$current")" != "$VALIDATED_HEAD_SHA" ]; then - echo "release PR no longer points at the validated commit" >&2 + echo "release PR no longer points at the synchronized commit" >&2 jq . <<<"$current" >&2 if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then gh pr ready "$RELEASE_PR_NUMBER" --undo @@ -520,49 +299,6 @@ jobs: exit 1 fi - - name: Return the release PR to draft after incomplete finalization - if: ${{ always() && steps.ready-state.outcome != 'success' }} - timeout-minutes: 2 - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ needs.release-pr-resolve.outputs.number }} - run: | - for attempt in 1 2 3 4 5; do - if current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft 2>/dev/null)"; then - state="$(jq -r .state <<<"$current")" - is_draft="$(jq -r .isDraft <<<"$current")" - - if [ "$state" = "CLOSED" ]; then - exit 0 - fi - if [ "$state" = "OPEN" ] && [ "$is_draft" = "true" ]; then - exit 0 - fi - if [ "$state" != "OPEN" ]; then - echo "release PR #$RELEASE_PR_NUMBER reached terminal state $state before it could be re-drafted" >&2 - exit 1 - fi - - if gh pr ready "$RELEASE_PR_NUMBER" --undo; then - if after="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft 2>/dev/null)"; then - after_state="$(jq -r .state <<<"$after")" - after_draft="$(jq -r .isDraft <<<"$after")" - if [ "$after_state" = "CLOSED" ] \ - || { [ "$after_state" = "OPEN" ] && [ "$after_draft" = "true" ]; }; then - exit 0 - fi - fi - fi - fi - - echo "attempt $attempt failed to return release PR #$RELEASE_PR_NUMBER to draft" >&2 - sleep "$((attempt * 2))" - done - - echo "release PR #$RELEASE_PR_NUMBER may still be ready after incomplete finalization" >&2 - exit 1 - # Zero-warning install gate: pack the release tag and install it globally in # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → # npm 10) and current (24 → npm 11), whose warning output differs. Publish diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2cad2d6bc..78fe475038 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,16 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR and owns that PR's draft/ready transition while synchronization is pending. The automated path rebuilds the entry from the trusted PR base; it does not execute or preserve edits pushed directly to the bot-owned file. To recover the entry on an explicit release/web PR, fetch its current base and run `bun run sync:web-release -- --base-ref `; the manual command rewrites only the automation-marked entry and preserves hand-curated entries. Commit the generated file, then run `bun test ./scripts/sync-web-release-entry.test.ts`, `bun run typecheck`, `bun run typecheck:type-tests`, `bun run web:typecheck`, `bun run web:build`, `bun run security:pr-scan -- --base --head HEAD`, and `git diff --check ...HEAD` against that committed release entry before pushing it. Confirm those checks leave tracked files unchanged. +Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Automation picks the first five changelog bullets for a new entry; remove the generated-entry marker before hand-curating. To recover the entry on an explicit release/web PR, fetch its current base and run: + +```bash +bun run sync:web-release -- --base-ref +bun test ./scripts/sync-web-release-entry.test.ts +bun run web:typecheck +bun run web:build +``` + +Commit the generated file and confirm those checks leave tracked files unchanged. Before submitting, run multiple rounds of review on generated code: diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 5ff4b7fbd5..9d8baa642b 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -92,6 +92,54 @@ describe('syncWebReleaseEntry', () => { 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', () => { diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 3be0431e39..6097878d79 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -123,7 +123,10 @@ export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): str const insertAt = index + marker.length const eol = detectLineEnding(releasesTs) const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol) - return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${releasesTs.slice(insertAt)}` + // A merged release can leave the automation marker on the published top + // entry. Strip that leftover marker so only the new draft owns it. + const rest = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), eol) + return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${rest}` } export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string { @@ -131,7 +134,7 @@ export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): const index = releasesTs.indexOf(marker) if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`) const insertAt = index + marker.length - const existing = releasesTs.slice(insertAt) + const existing = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), detectLineEnding(releasesTs)) const endMatch = /^ \},\r?\n/m.exec(existing) if (!endMatch) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`) const eol = detectLineEnding(releasesTs) @@ -153,6 +156,11 @@ function detectLineEnding(value: string): '\n' | '\r\n' { return value.includes('\r\n') ? '\r\n' : '\n' } +function stripLeadingGeneratedMarker(value: string, eol: '\n' | '\r\n'): string { + const prefix = `${eol}${GENERATED_ENTRY_MARKER}${eol}` + return value.startsWith(prefix) ? value.slice(prefix.length) : value +} + export function readBaseReleasesTs(baseRef: string): string { return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' }) } @@ -180,11 +188,11 @@ export function syncWebReleaseEntry(options: { if (baseReleasesTs === null) throw new Error('missing base release ref; pass --base-ref or provide baseReleasesTs') const baseTop = readCurrentTopVersion(baseReleasesTs) - if (currentTop === version && currentTop === baseTop) - return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } + const generatedTop = hasGeneratedTopEntry(releasesTs) + const divergedFromBase = currentTop !== baseTop - const replaceGeneratedTop = currentTop !== baseTop && hasGeneratedTopEntry(releasesTs) - if (currentTop !== baseTop && !replaceGeneratedTop) { + // Unmarked divergence is hand-owned. Never overwrite it from automation. + if (divergedFromBase && !generatedTop) { if (currentTop === version) return { status: 'unchanged', version, reason: 'preserving hand-curated release entry' } throw new Error( @@ -192,27 +200,31 @@ export function syncWebReleaseEntry(options: { ) } + // Same version without a generated marker is already published or curated. + if (currentTop === version && !generatedTop) + return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } + const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8') const section = parseChangelogSection(changelog, version) if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`) if (section.highlights.length === 0) throw new Error(`CHANGELOG.md section for ${version} has no bullet highlights`) - return { - status: 'updated', - version, - content: replaceGeneratedTop ? replaceTopReleaseEntry(releasesTs, { - version: section.version, - date: section.date, - theme: deriveTheme(section.highlights), - highlights: section.highlights, - }) : insertReleaseEntry(releasesTs, { - version: section.version, - date: section.date, - theme: deriveTheme(section.highlights), - highlights: section.highlights, - }), + const entry: ReleaseEntry = { + version: section.version, + date: section.date, + theme: deriveTheme(section.highlights), + highlights: section.highlights, } + + // Generated tops are automation-owned: refresh in place for the same version, + // or replace when the pending Release Please PR bumps the draft version. + if (generatedTop && (currentTop === version || divergedFromBase)) + return { status: 'updated', version, content: replaceTopReleaseEntry(releasesTs, entry) } + + // New version on top of the trusted base. insertReleaseEntry strips any + // leftover marker that remained on a previously generated published entry. + return { status: 'updated', version, content: insertReleaseEntry(releasesTs, entry) } } if (import.meta.main) { From daa99e91edcbdb8175c9347a3a546ec75682112e Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 16:18:20 -0700 Subject: [PATCH 25/37] fix(release): keep web sync from blocking npm publish Move pending Release Please web sync into its own job so a sync failure cannot skip install-verify, npm, or docker after a release tag is already created. --- .github/workflows/release.yml | 19 ++++++++++++++++--- CONTRIBUTING.md | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25c7d03212..e4d867aef9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,13 @@ jobs: name: Release Please runs-on: ubuntu-latest permissions: - contents: write # Create release commits/tags and push the synced releases.ts commit. - pull-requests: write # Keep the bot release PR draft until sync lands, then mark ready. + contents: write # Create release commits/tags. + pull-requests: write # Keep the bot release PR draft before Release Please updates it. outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} + release_pr: ${{ steps.release.outputs.pr }} steps: # Release Please's draft option applies only on PR creation. Draft any # existing pending bot PR before it updates the branch so an unvalidated @@ -76,6 +77,18 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # Web sync for the pending Release Please PR runs in its own job so a sync + # failure cannot skip npm/docker publish after Release Please already created + # a release/tag on this push. + sync-web-release: + name: Sync web release entry + needs: release-please + if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write # Push the synced releases.ts commit to the bot release PR branch. + pull-requests: write # Keep the bot release PR draft until sync lands, then mark ready. + steps: # Resolve the pending bot PR (from the action output, or by label on # reruns where Release Please leaves an unchanged PR untouched). Keep it # draft, sync releases.ts from the trusted merge base, validate the sync @@ -87,7 +100,7 @@ jobs: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} - RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} + RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }} RELEASE_REPO_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78fe475038..9276c9abdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Automation picks the first five changelog bullets for a new entry; remove the generated-entry marker before hand-curating. To recover the entry on an explicit release/web PR, fetch its current base and run: +Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Web sync runs in a separate workflow job from Release Please so a sync failure cannot block npm or Docker publishing for an already-created release. Automation picks the first five changelog bullets for a new entry; remove the generated-entry marker before hand-curating. To recover the entry on an explicit release/web PR, fetch its current base and run: ```bash bun run sync:web-release -- --base-ref From bb8287a0c6f9d3660bcbfcabb41f53732ab944dd Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 16:57:12 -0700 Subject: [PATCH 26/37] fix(release): close web-sync trust and policy gaps Remove hand-curation escape hatches, split read-only validation from write-only push, discover bot PRs by branch identity, and run the full local gate suite before marking the release PR ready. --- .github/workflows/release.yml | 370 ++++++++++++++++++------- CONTRIBUTING.md | 14 +- scripts/sync-web-release-entry.test.ts | 30 +- scripts/sync-web-release-entry.ts | 27 +- web/src/data/releases.ts | 4 +- 5 files changed, 326 insertions(+), 119 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4d867aef9..a5fcfbf168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,39 +35,44 @@ jobs: run: | set -euo pipefail - candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number)" + candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 10 --json number,headRefName,author)" candidate_count="$(jq length <<<"$candidates")" if [ "$candidate_count" -eq 0 ]; then exit 0 fi - if [ "$candidate_count" -ne 1 ]; then - echo "expected at most one pending Release Please PR for $RELEASE_BASE_BRANCH, found $candidate_count" >&2 - exit 1 - fi - number="$(jq -r '.[0].number' <<<"$candidates")" - release_pr="$(gh pr view "$number" --json state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" + drafted=0 + while IFS= read -r number; do + [ -n "$number" ] || continue + release_pr="$(gh pr view "$number" --json state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + + if [ "$(jq -r .state <<<"$release_pr")" != "OPEN" ] \ + || { [ "$(jq -r .author.login <<<"$release_pr")" != "app/github-actions" ] \ + && [ "$(jq -r .author.login <<<"$release_pr")" != "github-actions[bot]" ]; } \ + || [ "$(jq -r .baseRefName <<<"$release_pr")" != "$RELEASE_BASE_BRANCH" ] \ + || [[ "$(jq -r .headRefName <<<"$release_pr")" != "$expected_prefix"* ]] \ + || [ -z "$(jq -r .headRefOid <<<"$release_pr")" ] \ + || [ "$(jq -r .headRepositoryOwner.login <<<"$release_pr")" != "$RELEASE_REPO_OWNER" ] \ + || [ "$(jq -r .isCrossRepository <<<"$release_pr")" != "false" ]; then + echo "ignoring non-bot or mismatched autorelease: pending PR #$number" >&2 + jq . <<<"$release_pr" >&2 + continue + fi - if [ "$(jq -r .state <<<"$release_pr")" != "OPEN" ] \ - || { [ "$(jq -r .author.login <<<"$release_pr")" != "app/github-actions" ] \ - && [ "$(jq -r .author.login <<<"$release_pr")" != "github-actions[bot]" ]; } \ - || [ "$(jq -r .baseRefName <<<"$release_pr")" != "$RELEASE_BASE_BRANCH" ] \ - || [[ "$(jq -r .headRefName <<<"$release_pr")" != "$expected_prefix"* ]] \ - || [ -z "$(jq -r .headRefOid <<<"$release_pr")" ] \ - || [ "$(jq -r .headRepositoryOwner.login <<<"$release_pr")" != "$RELEASE_REPO_OWNER" ] \ - || [ "$(jq -r .isCrossRepository <<<"$release_pr")" != "false" ]; then - echo "pending PR #$number does not match the expected same-repository Release Please identity" >&2 - jq . <<<"$release_pr" >&2 - exit 1 - fi + if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then + gh pr ready "$number" --undo + fi - if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then - gh pr ready "$number" --undo - fi + if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep pending Release Please PR #$number in draft state" >&2 + exit 1 + fi + drafted=$((drafted + 1)) + done < <(jq -r '.[].number' <<<"$candidates") - if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then - echo "failed to keep pending Release Please PR #$number in draft state" >&2 + if [ "$drafted" -gt 1 ]; then + echo "expected at most one pending Release Please bot PR for $RELEASE_BASE_BRANCH, drafted $drafted" >&2 exit 1 fi @@ -77,23 +82,28 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - # Web sync for the pending Release Please PR runs in its own job so a sync - # failure cannot skip npm/docker publish after Release Please already created - # a release/tag on this push. - sync-web-release: - name: Sync web release entry + # Validate the pending Release Please web entry with read-only credentials. + # A separate write job applies only the validated releases.ts artifact so + # PR-head scripts never run with contents/pull-requests write access. + # Sync failure must not block npm/docker after a release/tag is created. + validate-web-release: + name: Validate web release entry needs: release-please if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }} runs-on: ubuntu-latest + concurrency: + group: sync-web-release-${{ github.ref }} + cancel-in-progress: true permissions: - contents: write # Push the synced releases.ts commit to the bot release PR branch. - pull-requests: write # Keep the bot release PR draft until sync lands, then mark ready. + contents: read # Check out the bot PR head and run sync/validation only. + pull-requests: read # Resolve the pending bot release PR identity. + outputs: + found: ${{ steps.release-pr.outputs.found }} + number: ${{ steps.release-pr.outputs.number }} + branch: ${{ steps.release-pr.outputs.branch }} + head_sha: ${{ steps.release-pr.outputs.head_sha }} + needs_commit: ${{ steps.sync-web.outputs.needs_commit }} steps: - # Resolve the pending bot PR (from the action output, or by label on - # reruns where Release Please leaves an unchanged PR untouched). Keep it - # draft, sync releases.ts from the trusted merge base, validate the sync - # locally, push, then mark ready. Do not wait for ordinary PR checks: - # GITHUB_TOKEN pushes do not start them. - name: Resolve pending Release Please PR id: release-pr env: @@ -105,56 +115,94 @@ jobs: run: | set -euo pipefail - release_pr="$RELEASE_PR_JSON" - if [ -z "$release_pr" ] || [ "$release_pr" = "null" ]; then - candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 2 --json number)" - candidate_count="$(jq length <<<"$candidates")" - if [ "$candidate_count" -eq 0 ]; then + expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" + + matches_bot_identity() { + local release_pr="$1" + local state author base branch head_sha head_owner cross_repo + state="$(jq -r .state <<<"$release_pr")" + author="$(jq -r .author.login <<<"$release_pr")" + base="$(jq -r .baseRefName <<<"$release_pr")" + branch="$(jq -r .headRefName <<<"$release_pr")" + head_sha="$(jq -r .headRefOid <<<"$release_pr")" + head_owner="$(jq -r .headRepositoryOwner.login <<<"$release_pr")" + cross_repo="$(jq -r .isCrossRepository <<<"$release_pr")" + [ "$state" = "OPEN" ] \ + && { [ "$author" = "app/github-actions" ] || [ "$author" = "github-actions[bot]" ]; } \ + && [ "$base" = "$RELEASE_BASE_BRANCH" ] \ + && [[ "$branch" == "$expected_prefix"* ]] \ + && [ -n "$head_sha" ] \ + && [ "$head_owner" = "$RELEASE_REPO_OWNER" ] \ + && [ "$cross_repo" = "false" ] + } + + discover_bot_prs() { + # Discover by bot branch prefix, not only the label, so a stripped + # label cannot leave a ready bot PR unvalidated. Ignore mislabeled + # non-bot PRs instead of failing the release pipeline. + gh pr list \ + --state open \ + --base "$RELEASE_BASE_BRANCH" \ + --limit 50 \ + --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ + | jq -c \ + --arg prefix "$expected_prefix" \ + --arg owner "$RELEASE_REPO_OWNER" \ + --arg base "$RELEASE_BASE_BRANCH" ' + [ + .[] + | select( + .state == "OPEN" + and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") + and .baseRefName == $base + and (.headRefName | startswith($prefix)) + and (.headRefOid | length) > 0 + and .headRepositoryOwner.login == $owner + and .isCrossRepository == false + ) + ] + ' + } + + release_pr="" + if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then + number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")" + if [ -n "$number" ]; then + candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + if matches_bot_identity "$candidate"; then + release_pr="$candidate" + else + echo "release-please pr output #$number is not an open same-repo bot release PR; falling back to discovery" >&2 + jq . <<<"$candidate" >&2 + fi + fi + fi + + if [ -z "$release_pr" ]; then + matches="$(discover_bot_prs)" + match_count="$(jq length <<<"$matches")" + if [ "$match_count" -eq 0 ]; then + # If something carries the pending label but is not a bot release + # PR, ignore it and stay green only when no bot release PR exists. + labeled="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 10 --json number,headRefName,author)" + if [ "$(jq length <<<"$labeled")" -gt 0 ]; then + echo "ignoring non-bot autorelease: pending PR(s); no matching Release Please bot PR found" >&2 + jq . <<<"$labeled" >&2 + fi echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi - if [ "$candidate_count" -ne 1 ]; then - echo "expected exactly one pending Release Please PR for $RELEASE_BASE_BRANCH, found $candidate_count" >&2 + if [ "$match_count" -ne 1 ]; then + echo "expected exactly one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2 + jq . <<<"$matches" >&2 exit 1 fi - release_pr="$(jq -c '.[0]' <<<"$candidates")" - fi - - number="$(jq -r '.number // empty' <<<"$release_pr")" - if [ -z "$number" ]; then - echo "release PR JSON is missing its number" >&2 - exit 1 + release_pr="$(jq -c '.[0]' <<<"$matches")" fi - release_pr="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" - state="$(jq -r .state <<<"$release_pr")" - author="$(jq -r .author.login <<<"$release_pr")" - base="$(jq -r .baseRefName <<<"$release_pr")" + number="$(jq -r .number <<<"$release_pr")" branch="$(jq -r .headRefName <<<"$release_pr")" head_sha="$(jq -r .headRefOid <<<"$release_pr")" - head_owner="$(jq -r .headRepositoryOwner.login <<<"$release_pr")" - cross_repo="$(jq -r .isCrossRepository <<<"$release_pr")" - expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" - - if [ "$state" != "OPEN" ] \ - || { [ "$author" != "app/github-actions" ] && [ "$author" != "github-actions[bot]" ]; } \ - || [ "$base" != "$RELEASE_BASE_BRANCH" ] \ - || [[ "$branch" != "$expected_prefix"* ]] \ - || [ -z "$head_sha" ] \ - || [ "$head_owner" != "$RELEASE_REPO_OWNER" ] \ - || [ "$cross_repo" != "false" ]; then - echo "pending PR #$number does not match the expected same-repository Release Please identity" >&2 - jq . <<<"$release_pr" >&2 - exit 1 - fi - - if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then - gh pr ready "$number" --undo - fi - if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then - echo "failed to keep pending Release Please PR #$number in draft state" >&2 - exit 1 - fi echo "found=true" >> "$GITHUB_OUTPUT" echo "number=$number" >> "$GITHUB_OUTPUT" @@ -184,6 +232,7 @@ jobs: run: | set -euo pipefail + git fetch --no-tags origin "$RELEASE_BASE_BRANCH" base_sha="$(git merge-base "origin/$RELEASE_BASE_BRANCH" HEAD)" if [ -z "$base_sha" ]; then echo "could not resolve the release PR merge base" >&2 @@ -197,48 +246,127 @@ jobs: bun install --frozen-lockfile bun run sync:web-release -- --base-ref "$base_sha" bun test ./scripts/sync-web-release-entry.test.ts + # GITHUB_TOKEN pushes do not start ordinary PR checks, so run the + # repo's standard gates against the synchronized tree before ready. + bun run typecheck + bun run typecheck:type-tests + bun run security:pr-scan -- --base "origin/$RELEASE_BASE_BRANCH" --head HEAD + git diff --check "origin/$RELEASE_BASE_BRANCH"...HEAD bun install --cwd web --frozen-lockfile bun run web:typecheck bun run web:build + needs_commit=false if ! git diff --quiet -- web/src/data/releases.ts; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add web/src/data/releases.ts - git commit -m "chore(web): sync releases.ts from release-please changelog" - else - echo "releases.ts already up to date" + needs_commit=true fi + echo "needs_commit=$needs_commit" >> "$GITHUB_OUTPUT" + mkdir -p .release-sync-artifact + cp web/src/data/releases.ts .release-sync-artifact/releases.ts + { + echo "number=${{ steps.release-pr.outputs.number }}" + echo "branch=${{ steps.release-pr.outputs.branch }}" + echo "head_sha=${{ steps.release-pr.outputs.head_sha }}" + echo "base_sha=$base_sha" + echo "needs_commit=$needs_commit" + sha256sum web/src/data/releases.ts | awk '{print "releases_sha256="$1}' + } > .release-sync-artifact/metadata.env + + # Validation must not leave a dirty tree for the artifact handoff. + git checkout -- web/src/data/releases.ts if ! git diff --quiet || ! git diff --cached --quiet; then echo "sync validation left uncommitted tracked changes" >&2 git status --short exit 1 fi - - name: Push synchronized web release entry - id: push-sync + - name: Upload validated releases.ts artifact if: ${{ steps.release-pr.outputs.found == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: web-release-sync-${{ github.run_id }}-${{ github.run_attempt }} + path: .release-sync-artifact/ + if-no-files-found: error + retention-days: 1 + + push-web-release: + name: Push web release entry + needs: validate-web-release + if: ${{ github.repository == 'Gitlawb/openclaude' && needs.validate-web-release.outputs.found == 'true' }} + runs-on: ubuntu-latest + concurrency: + group: sync-web-release-${{ github.ref }} + cancel-in-progress: true + permissions: + contents: write # Push only the validated releases.ts commit to the bot branch. + pull-requests: write # Keep the bot PR draft until push lands, then mark ready. + steps: + - name: Check out Release Please PR head + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate-web-release.outputs.head_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download validated releases.ts artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: web-release-sync-${{ github.run_id }}-${{ github.run_attempt }} + path: .release-sync-artifact + + - name: Apply validated releases.ts and push + id: push-sync env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} + RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }} + RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }} + RELEASE_PR_HEAD_SHA: ${{ needs.validate-web-release.outputs.head_sha }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail - current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" + # shellcheck disable=SC1091 + source .release-sync-artifact/metadata.env + if [ "${number}" != "$RELEASE_PR_NUMBER" ] \ + || [ "${branch}" != "$RELEASE_PR_BRANCH" ] \ + || [ "${head_sha}" != "$RELEASE_PR_HEAD_SHA" ]; then + echo "validated artifact metadata does not match the resolved release PR" >&2 + cat .release-sync-artifact/metadata.env >&2 + exit 1 + fi + + expected_sha256="$releases_sha256" + actual_sha256="$(sha256sum .release-sync-artifact/releases.ts | awk '{print $1}')" + if [ "$actual_sha256" != "$expected_sha256" ]; then + echo "validated releases.ts digest mismatch" >&2 + exit 1 + fi + + # Apply only the validated file — do not execute PR-head scripts here. + cp .release-sync-artifact/releases.ts web/src/data/releases.ts + applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" + if [ "$applied_sha256" != "$expected_sha256" ]; then + echo "failed to apply validated releases.ts" >&2 + exit 1 + fi + + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid,author,headRepositoryOwner,isCrossRepository)" + expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ + || { [ "$(jq -r .author.login <<<"$current")" != "app/github-actions" ] \ + && [ "$(jq -r .author.login <<<"$current")" != "github-actions[bot]" ]; } \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ - || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ]; then + || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ + || [[ "$(jq -r .headRefName <<<"$current")" != "$expected_prefix"* ]] \ + || [ "$(jq -r .headRepositoryOwner.login <<<"$current")" != "${GITHUB_REPOSITORY_OWNER}" ] \ + || [ "$(jq -r .isCrossRepository <<<"$current")" != "false" ]; then echo "release PR changed or closed during sync" >&2 jq . <<<"$current" >&2 exit 1 fi - # Stay draft until the synced commit is on the remote head. if [ "$(jq -r .isDraft <<<"$current")" != "true" ]; then gh pr ready "$RELEASE_PR_NUMBER" --undo if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" != "true" ]; then @@ -247,35 +375,75 @@ jobs: fi fi + if [ "$needs_commit" = "true" ]; then + if git diff --quiet -- web/src/data/releases.ts; then + echo "artifact required a commit but working tree already matches" >&2 + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): sync releases.ts from release-please changelog" + else + if ! git diff --quiet -- web/src/data/releases.ts; then + echo "artifact claimed no commit but working tree differs" >&2 + exit 1 + fi + echo "releases.ts already up to date" + fi + + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "push preparation left uncommitted tracked changes" >&2 + git status --short + exit 1 + fi + current_sha="$(jq -r .headRefOid <<<"$current")" local_sha="$(git rev-parse HEAD)" if [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" - GIT_CONFIG_COUNT=1 \ + if ! GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}" \ git push \ --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ "https://github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:${RELEASE_PR_BRANCH}" + "HEAD:${RELEASE_PR_BRANCH}"; then + # A concurrent sync may have already pushed the same content. + remote_sha="$(gh pr view "$RELEASE_PR_NUMBER" --json headRefOid --jq .headRefOid)" + if [ "$remote_sha" = "$local_sha" ]; then + echo "release PR already points at the synchronized commit after lease race" + else + echo "release PR head moved during push to an unexpected commit" >&2 + echo "remote=$remote_sha local=$local_sha" >&2 + exit 1 + fi + fi elif [ "$current_sha" = "$local_sha" ]; then - # A rerun after a successful push can skip the lease push. echo "release PR already points at the synchronized commit" else - echo "release PR head changed to an unvalidated commit" >&2 - jq . <<<"$current" >&2 - exit 1 + # Another sync may have landed an equivalent releases.ts commit. + remote_file_sha="$(gh api "repos/${GITHUB_REPOSITORY}/contents/web/src/data/releases.ts?ref=${current_sha}" --jq .sha)" + local_git_sha="$(git rev-parse "HEAD:web/src/data/releases.ts")" + if [ "$remote_file_sha" = "$local_git_sha" ]; then + echo "release PR head already has the validated releases.ts blob" + local_sha="$current_sha" + else + echo "release PR head changed to an unvalidated commit" >&2 + jq . <<<"$current" >&2 + exit 1 + fi fi - echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "head_sha=$local_sha" >> "$GITHUB_OUTPUT" - name: Mark synchronized release PR ready for review - if: ${{ steps.release-pr.outputs.found == 'true' && steps.push-sync.outcome == 'success' }} + if: ${{ steps.push-sync.outcome == 'success' }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} - RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} + RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }} + RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} VALIDATED_HEAD_SHA: ${{ steps.push-sync.outputs.head_sha }} run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9276c9abdb..aacec3188f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,16 +140,24 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### Release web synchronization -Release automation regenerates `web/src/data/releases.ts` from the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Web sync runs in a separate workflow job from Release Please so a sync failure cannot block npm or Docker publishing for an already-created release. Automation picks the first five changelog bullets for a new entry; remove the generated-entry marker before hand-curating. To recover the entry on an explicit release/web PR, fetch its current base and run: +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 in a read-only job and pushes from a separate write-only job so a sync failure cannot block npm or Docker publishing for an already-created release, and so PR-head scripts never run with write credentials. 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 -bun run sync:web-release -- --base-ref +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 ``` -Commit the generated file and confirm those checks leave tracked files unchanged. +`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: diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 9d8baa642b..699b02b67e 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -181,7 +181,7 @@ describe('syncWebReleaseEntry', () => { expect(result.content.replaceAll('\r\n', '')).not.toContain('\n') }) - test('preserves a hand-curated pending entry for the target version', () => { + test('refuses unmarked divergence for the target version', () => { const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, { version: '0.28.0', date: '2026-08-10', @@ -189,16 +189,36 @@ describe('syncWebReleaseEntry', () => { highlights: ['curated highlight'], }).replace(`${GENERATED_ENTRY_MARKER}\n`, '') - expect(syncWebReleaseEntry({ + expect(() => syncWebReleaseEntry({ changelog: SAMPLE_CHANGELOG, releasesTs: curated, baseReleasesTs: SAMPLE_RELEASES_TS, manifestVersion: '0.28.0', - })).toEqual({ - status: 'unchanged', + })).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', - reason: 'preserving hand-curated release entry', + 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', () => { diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 6097878d79..27252363bd 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -5,8 +5,14 @@ export const RELEASES_TS_PATH = 'web/src/data/releases.ts' export const CHANGELOG_PATH = 'CHANGELOG.md' export const MANIFEST_PATH = '.release-please-manifest.json' export const GENERATED_ENTRY_MARKER = + ' // Generated by release automation; do not edit this entry by hand.' + +/** Prior wording kept only so pending bot PRs with the old marker still refresh. */ +const LEGACY_GENERATED_ENTRY_MARKER = ' // Generated by release automation; remove this comment before hand-curating.' +const GENERATED_ENTRY_MARKERS = [GENERATED_ENTRY_MARKER, LEGACY_GENERATED_ENTRY_MARKER] as const + export type ReleaseEntry = { version: string date: string @@ -148,8 +154,11 @@ export function hasGeneratedTopEntry(releasesTs: string): boolean { const index = releasesTs.indexOf(marker) if (index === -1) return false const existing = releasesTs.slice(index + marker.length) - return existing.startsWith(`\n${GENERATED_ENTRY_MARKER}\n`) - || existing.startsWith(`\r\n${GENERATED_ENTRY_MARKER}\r\n`) + return GENERATED_ENTRY_MARKERS.some( + generated => + existing.startsWith(`\n${generated}\n`) + || existing.startsWith(`\r\n${generated}\r\n`), + ) } function detectLineEnding(value: string): '\n' | '\r\n' { @@ -157,8 +166,11 @@ function detectLineEnding(value: string): '\n' | '\r\n' { } function stripLeadingGeneratedMarker(value: string, eol: '\n' | '\r\n'): string { - const prefix = `${eol}${GENERATED_ENTRY_MARKER}${eol}` - return value.startsWith(prefix) ? value.slice(prefix.length) : value + for (const generated of GENERATED_ENTRY_MARKERS) { + const prefix = `${eol}${generated}${eol}` + if (value.startsWith(prefix)) return value.slice(prefix.length) + } + return value } export function readBaseReleasesTs(baseRef: string): string { @@ -191,16 +203,15 @@ export function syncWebReleaseEntry(options: { const generatedTop = hasGeneratedTopEntry(releasesTs) const divergedFromBase = currentTop !== baseTop - // Unmarked divergence is hand-owned. Never overwrite it from automation. + // Unmarked divergence is not automation-owned. Fail closed — never preserve + // or overwrite hand edits on the bot sync path. if (divergedFromBase && !generatedTop) { - if (currentTop === version) - return { status: 'unchanged', version, reason: 'preserving hand-curated release entry' } throw new Error( `${RELEASES_TS_PATH} differs from the base without the generated-entry marker; refusing to overwrite it`, ) } - // Same version without a generated marker is already published or curated. + // Same version without a generated marker is already published on the trusted base. if (currentTop === version && !generatedTop) return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' } diff --git a/web/src/data/releases.ts b/web/src/data/releases.ts index 00d29a5649..7387be6ddc 100644 --- a/web/src/data/releases.ts +++ b/web/src/data/releases.ts @@ -1,6 +1,6 @@ // Release highlights for the /changelog page. Automation selects the first -// five changelog bullets for new entries; older entries were hand-curated. -// Full notes live on GitHub releases. Newest first. +// five changelog bullets for new entries; older published entries may predate +// that automation. Full notes live on GitHub releases. Newest first. // // Ownership: release/web process only (release automation and dedicated web // release PRs). Do not edit this file from ordinary feature or bugfix PRs, From 363ced42a77dd9fd73876d95a04726f102bb4e80 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 17:32:10 -0700 Subject: [PATCH 27/37] fix(release): validate gates against synchronized commit Commit the synced releases.ts in the read-only validate job before typecheck, security scan, and whitespace checks so those gates inspect the content that will be marked ready, not the pre-sync HEAD. --- .github/workflows/release.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5fcfbf168..8f63223f38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -246,8 +246,19 @@ jobs: bun install --frozen-lockfile bun run sync:web-release -- --base-ref "$base_sha" bun test ./scripts/sync-web-release-entry.test.ts + + needs_commit=false + if ! git diff --quiet -- web/src/data/releases.ts; then + needs_commit=true + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): sync releases.ts from release-please changelog" + fi + echo "needs_commit=$needs_commit" >> "$GITHUB_OUTPUT" + # GITHUB_TOKEN pushes do not start ordinary PR checks, so run the - # repo's standard gates against the synchronized tree before ready. + # repo's standard gates against the synchronized commit before ready. bun run typecheck bun run typecheck:type-tests bun run security:pr-scan -- --base "origin/$RELEASE_BASE_BRANCH" --head HEAD @@ -256,12 +267,6 @@ jobs: bun run web:typecheck bun run web:build - needs_commit=false - if ! git diff --quiet -- web/src/data/releases.ts; then - needs_commit=true - fi - echo "needs_commit=$needs_commit" >> "$GITHUB_OUTPUT" - mkdir -p .release-sync-artifact cp web/src/data/releases.ts .release-sync-artifact/releases.ts { @@ -273,8 +278,6 @@ jobs: sha256sum web/src/data/releases.ts | awk '{print "releases_sha256="$1}' } > .release-sync-artifact/metadata.env - # Validation must not leave a dirty tree for the artifact handoff. - git checkout -- web/src/data/releases.ts if ! git diff --quiet || ! git diff --cached --quiet; then echo "sync validation left uncommitted tracked changes" >&2 git status --short From 02b787aac5fd79e82cb20b6db7f0b5ea2ff9e5e9 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 17:37:16 -0700 Subject: [PATCH 28/37] fix(release): harden web-sync trust boundary and draft gating Run sync from trusted main with only changelog/manifest overlaid from the bot PR, re-draft after release-please, serialize sync without canceling in-flight pushes, and require an explicit sync base. --- .github/workflows/release.yml | 187 ++++++++++++++++--------- CONTRIBUTING.md | 2 +- scripts/sync-web-release-entry.test.ts | 19 ++- scripts/sync-web-release-entry.ts | 2 +- 4 files changed, 144 insertions(+), 66 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f63223f38..f0cd279b0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,53 +35,127 @@ jobs: run: | set -euo pipefail - candidates="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 10 --json number,headRefName,author)" - candidate_count="$(jq length <<<"$candidates")" - if [ "$candidate_count" -eq 0 ]; then + expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" + matches="$(gh pr list \ + --state open \ + --base "$RELEASE_BASE_BRANCH" \ + --limit 50 \ + --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ + | jq -c \ + --arg prefix "$expected_prefix" \ + --arg owner "$RELEASE_REPO_OWNER" \ + --arg base "$RELEASE_BASE_BRANCH" ' + [ + .[] + | select( + .state == "OPEN" + and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") + and .baseRefName == $base + and (.headRefName | startswith($prefix)) + and (.headRefOid | length) > 0 + and .headRepositoryOwner.login == $owner + and .isCrossRepository == false + ) + ] + ')" + match_count="$(jq length <<<"$matches")" + if [ "$match_count" -eq 0 ]; then exit 0 fi + if [ "$match_count" -ne 1 ]; then + echo "expected at most one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2 + jq . <<<"$matches" >&2 + exit 1 + fi + + number="$(jq -r '.[0].number' <<<"$matches")" + if [ "$(jq -r '.[0].isDraft' <<<"$matches")" = "false" ]; then + gh pr ready "$number" --undo + fi + if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep pending Release Please PR #$number in draft state" >&2 + exit 1 + fi + + - name: Run release-please + id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + # release-please can leave an existing PR non-draft after updating it. + # Re-draft immediately in this write-capable job before validate/push run. + - name: Keep Release Please PR draft after updates + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_REPO_OWNER: ${{ github.repository_owner }} + RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} + run: | + set -euo pipefail expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" - drafted=0 - while IFS= read -r number; do - [ -n "$number" ] || continue - release_pr="$(gh pr view "$number" --json state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" - - if [ "$(jq -r .state <<<"$release_pr")" != "OPEN" ] \ - || { [ "$(jq -r .author.login <<<"$release_pr")" != "app/github-actions" ] \ - && [ "$(jq -r .author.login <<<"$release_pr")" != "github-actions[bot]" ]; } \ - || [ "$(jq -r .baseRefName <<<"$release_pr")" != "$RELEASE_BASE_BRANCH" ] \ - || [[ "$(jq -r .headRefName <<<"$release_pr")" != "$expected_prefix"* ]] \ - || [ -z "$(jq -r .headRefOid <<<"$release_pr")" ] \ - || [ "$(jq -r .headRepositoryOwner.login <<<"$release_pr")" != "$RELEASE_REPO_OWNER" ] \ - || [ "$(jq -r .isCrossRepository <<<"$release_pr")" != "false" ]; then - echo "ignoring non-bot or mismatched autorelease: pending PR #$number" >&2 - jq . <<<"$release_pr" >&2 - continue + release_pr="" + if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then + number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")" + if [ -n "$number" ]; then + candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)" + if [ "$(jq -r .state <<<"$candidate")" = "OPEN" ] \ + && { [ "$(jq -r .author.login <<<"$candidate")" = "app/github-actions" ] \ + || [ "$(jq -r .author.login <<<"$candidate")" = "github-actions[bot]" ]; } \ + && [ "$(jq -r .baseRefName <<<"$candidate")" = "$RELEASE_BASE_BRANCH" ] \ + && [[ "$(jq -r .headRefName <<<"$candidate")" == "$expected_prefix"* ]] \ + && [ "$(jq -r .headRepositoryOwner.login <<<"$candidate")" = "$RELEASE_REPO_OWNER" ] \ + && [ "$(jq -r .isCrossRepository <<<"$candidate")" = "false" ]; then + release_pr="$candidate" + fi fi + fi - if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then - gh pr ready "$number" --undo + if [ -z "$release_pr" ]; then + matches="$(gh pr list \ + --state open \ + --base "$RELEASE_BASE_BRANCH" \ + --limit 50 \ + --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ + | jq -c \ + --arg prefix "$expected_prefix" \ + --arg owner "$RELEASE_REPO_OWNER" \ + --arg base "$RELEASE_BASE_BRANCH" ' + [ + .[] + | select( + .state == "OPEN" + and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") + and .baseRefName == $base + and (.headRefName | startswith($prefix)) + and (.headRefOid | length) > 0 + and .headRepositoryOwner.login == $owner + and .isCrossRepository == false + ) + ] + ')" + if [ "$(jq length <<<"$matches")" -eq 0 ]; then + exit 0 fi - - if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then - echo "failed to keep pending Release Please PR #$number in draft state" >&2 + if [ "$(jq length <<<"$matches")" -ne 1 ]; then + echo "expected at most one pending Release Please bot PR after release-please" >&2 + jq . <<<"$matches" >&2 exit 1 fi - drafted=$((drafted + 1)) - done < <(jq -r '.[].number' <<<"$candidates") + release_pr="$(jq -c '.[0]' <<<"$matches")" + fi - if [ "$drafted" -gt 1 ]; then - echo "expected at most one pending Release Please bot PR for $RELEASE_BASE_BRANCH, drafted $drafted" >&2 + number="$(jq -r .number <<<"$release_pr")" + if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then + gh pr ready "$number" --undo + fi + if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then + echo "failed to keep pending Release Please PR #$number draft after release-please" >&2 exit 1 fi - - name: Run release-please - id: release - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - # Validate the pending Release Please web entry with read-only credentials. # A separate write job applies only the validated releases.ts artifact so # PR-head scripts never run with contents/pull-requests write access. @@ -93,9 +167,9 @@ jobs: runs-on: ubuntu-latest concurrency: group: sync-web-release-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: - contents: read # Check out the bot PR head and run sync/validation only. + contents: read # Check out trusted main and overlay pending release inputs only. pull-requests: read # Resolve the pending bot release PR identity. outputs: found: ${{ steps.release-pr.outputs.found }} @@ -209,11 +283,11 @@ jobs: echo "branch=$branch" >> "$GITHUB_OUTPUT" echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - - name: Check out Release Please PR head + - name: Check out trusted main tip if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ steps.release-pr.outputs.head_sha }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -229,22 +303,18 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} run: | set -euo pipefail - git fetch --no-tags origin "$RELEASE_BASE_BRANCH" - base_sha="$(git merge-base "origin/$RELEASE_BASE_BRANCH" HEAD)" - if [ -z "$base_sha" ]; then - echo "could not resolve the release PR merge base" >&2 - exit 1 - fi - - # Rebuild from the trusted base so a collaborator push cannot smuggle - # executable TypeScript through the bot-owned file. - git show "${base_sha}:web/src/data/releases.ts" > web/src/data/releases.ts + # Run trusted main scripts against the pending bot PR's release inputs. + # Do not execute package.json/scripts from the bot PR head. + git fetch --no-tags origin "$RELEASE_PR_HEAD_SHA" + git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md + git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json bun install --frozen-lockfile - bun run sync:web-release -- --base-ref "$base_sha" + bun run sync:web-release -- --base-ref HEAD bun test ./scripts/sync-web-release-entry.test.ts needs_commit=false @@ -273,7 +343,6 @@ jobs: echo "number=${{ steps.release-pr.outputs.number }}" echo "branch=${{ steps.release-pr.outputs.branch }}" echo "head_sha=${{ steps.release-pr.outputs.head_sha }}" - echo "base_sha=$base_sha" echo "needs_commit=$needs_commit" sha256sum web/src/data/releases.ts | awk '{print "releases_sha256="$1}' } > .release-sync-artifact/metadata.env @@ -300,7 +369,7 @@ jobs: runs-on: ubuntu-latest concurrency: group: sync-web-release-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: write # Push only the validated releases.ts commit to the bot branch. pull-requests: write # Keep the bot PR draft until push lands, then mark ready. @@ -412,7 +481,7 @@ jobs: --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \ "https://github.com/${GITHUB_REPOSITORY}.git" \ "HEAD:${RELEASE_PR_BRANCH}"; then - # A concurrent sync may have already pushed the same content. + # A concurrent sync may have already pushed the same commit. remote_sha="$(gh pr view "$RELEASE_PR_NUMBER" --json headRefOid --jq .headRefOid)" if [ "$remote_sha" = "$local_sha" ]; then echo "release PR already points at the synchronized commit after lease race" @@ -425,17 +494,9 @@ jobs: elif [ "$current_sha" = "$local_sha" ]; then echo "release PR already points at the synchronized commit" else - # Another sync may have landed an equivalent releases.ts commit. - remote_file_sha="$(gh api "repos/${GITHUB_REPOSITORY}/contents/web/src/data/releases.ts?ref=${current_sha}" --jq .sha)" - local_git_sha="$(git rev-parse "HEAD:web/src/data/releases.ts")" - if [ "$remote_file_sha" = "$local_git_sha" ]; then - echo "release PR head already has the validated releases.ts blob" - local_sha="$current_sha" - else - echo "release PR head changed to an unvalidated commit" >&2 - jq . <<<"$current" >&2 - exit 1 - fi + echo "release PR head changed to an unvalidated commit" >&2 + jq . <<<"$current" >&2 + exit 1 fi echo "head_sha=$local_sha" >> "$GITHUB_OUTPUT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aacec3188f..c290735f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,7 @@ AI-assisted and vibe-coded contributions are welcome, but please review your own ### 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 in a read-only job and pushes from a separate write-only job so a sync failure cannot block npm or Docker publishing for an already-created release, and so PR-head scripts never run with write credentials. 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: +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)" diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 699b02b67e..6db47a22f5 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -40,6 +40,7 @@ describe('syncWebReleaseEntry', () => { const result = syncWebReleaseEntry({ changelog: SAMPLE_CHANGELOG, releasesTs: SAMPLE_RELEASES_TS, + baseReleasesTs: SAMPLE_RELEASES_TS, manifestVersion: '0.28.0', }) @@ -255,16 +256,32 @@ describe('syncWebReleaseEntry', () => { test('rejects a missing version section and an empty release section', () => { expect(() => - syncWebReleaseEntry({ changelog: SAMPLE_CHANGELOG, releasesTs: SAMPLE_RELEASES_TS, manifestVersion: '9.9.9' }), + 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', () => { diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 27252363bd..611d6bf379 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -196,7 +196,7 @@ export function syncWebReleaseEntry(options: { const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8') const currentTop = readCurrentTopVersion(releasesTs) const baseReleasesTs = options.baseReleasesTs - ?? (options.releasesTs ? releasesTs : options.baseRef ? readBaseReleasesTs(options.baseRef) : null) + ?? (options.baseRef ? readBaseReleasesTs(options.baseRef) : null) if (baseReleasesTs === null) throw new Error('missing base release ref; pass --base-ref or provide baseReleasesTs') const baseTop = readCurrentTopVersion(baseReleasesTs) From ab39adc0dee4ad4c360f76ef5dff8c9a6cf6b767 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 17:41:30 -0700 Subject: [PATCH 29/37] fix(release): restore overlaid inputs before validate cleanliness gate Fetching changelog/manifest from the bot PR dirtied tracked files on the trusted main checkout and made the final git-diff gate fail on every pending release. Restore those overlays after sync and fetch origin/main for the security/whitespace checks. --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0cd279b0b..2a070975ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,12 +309,14 @@ jobs: # Run trusted main scripts against the pending bot PR's release inputs. # Do not execute package.json/scripts from the bot PR head. - git fetch --no-tags origin "$RELEASE_PR_HEAD_SHA" + git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA" git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json bun install --frozen-lockfile bun run sync:web-release -- --base-ref HEAD + # Restore overlaid inputs so the cleanliness gate only sees releases.ts. + git checkout -- CHANGELOG.md .release-please-manifest.json bun test ./scripts/sync-web-release-entry.test.ts needs_commit=false From 24225c922ad8678d789bb9c8d9ea3e0835ef1c68 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 18:17:29 -0700 Subject: [PATCH 30/37] fix(release): reuse validated sync artifacts on retry --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a070975ef..cd1a64164c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,9 +359,14 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: web-release-sync-${{ github.run_id }}-${{ github.run_attempt }} + # Keep the artifact addressable when only the failed push job is + # retried: run_attempt changes on a retry, while this validated head + # remains the push job's immutable input. + name: web-release-sync-${{ github.run_id }}-${{ steps.release-pr.outputs.head_sha }} path: .release-sync-artifact/ if-no-files-found: error + # A full validation rerun for the same head replaces its prior artifact. + overwrite: true retention-days: 1 push-web-release: @@ -386,7 +391,7 @@ jobs: - name: Download validated releases.ts artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: web-release-sync-${{ github.run_id }}-${{ github.run_attempt }} + name: web-release-sync-${{ github.run_id }}-${{ needs.validate-web-release.outputs.head_sha }} path: .release-sync-artifact - name: Apply validated releases.ts and push From cc886f5083ec336555d8cb24e7f129c8d4e220c4 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 18:44:57 -0700 Subject: [PATCH 31/37] fix(release): validate release sync inputs and retries --- .github/workflows/release.yml | 91 +++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd1a64164c..aba3c5a69b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,7 +176,6 @@ jobs: number: ${{ steps.release-pr.outputs.number }} branch: ${{ steps.release-pr.outputs.branch }} head_sha: ${{ steps.release-pr.outputs.head_sha }} - needs_commit: ${{ steps.sync-web.outputs.needs_commit }} steps: - name: Resolve pending Release Please PR id: release-pr @@ -310,24 +309,95 @@ jobs: # Run trusted main scripts against the pending bot PR's release inputs. # Do not execute package.json/scripts from the bot PR head. git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA" + expected_release_files="$(printf '%s\n' \ + .release-please-manifest.json \ + CHANGELOG.md \ + package.json \ + | sort)" + actual_release_files="$(git diff --name-only "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" | sort)" + if [ "$actual_release_files" != "$expected_release_files" ]; then + echo "Release Please PR changed files outside its release-input contract" >&2 + diff -u <(printf '%s\n' "$expected_release_files") <(printf '%s\n' "$actual_release_files") || true + exit 1 + fi + + mkdir -p .release-sync-input + git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json + if ! diff -u \ + <(jq -S 'del(.version)' package.json) \ + <(jq -S 'del(.version)' .release-sync-input/package.json); then + echo "Release Please PR changes package.json fields other than version" >&2 + exit 1 + fi + if ! diff -u \ + <(jq -S 'del(.["."])' <(git show HEAD:.release-please-manifest.json)) \ + <(jq -S 'del(.["."])' .release-please-manifest.json); then + echo "Release Please PR changes manifest fields other than the root version" >&2 + exit 1 + fi + + package_version="$(jq -r .version .release-sync-input/package.json)" + manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" + base_package_version="$(jq -r .version package.json)" + base_manifest_version="$(git show HEAD:.release-please-manifest.json | jq -r '.["."]')" + if [ "$package_version" != "$manifest_version" ] \ + || [ "$base_package_version" != "$base_manifest_version" ]; then + echo "package and Release Please manifest versions do not match" >&2 + exit 1 + fi + BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" node -e ' + const identifier = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + const pattern = new RegExp( + `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + + `(?:-(${identifier}(?:\\.${identifier})*))?` + + `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, + ) + const parse = value => { + const match = value.match(pattern) + if (!match) throw new Error(`invalid SemVer: ${value}`) + return { core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], prerelease: match[4]?.split(".") ?? null } + } + const compare = (left, right) => { + for (let index = 0; index < 3; index++) { + if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index] ? -1 : 1 + } + if (left.prerelease === null || right.prerelease === null) + return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1 + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index++) { + const a = left.prerelease[index] + const b = right.prerelease[index] + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1 + if (a === b) continue + const aNumeric = /^\\d+$/.test(a) + const bNumeric = /^\\d+$/.test(b) + if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 + return a < b ? -1 : 1 + } + return 0 + } + const base = parse(process.env.BASE_VERSION) + const next = parse(process.env.NEXT_VERSION) + if (compare(next, base) <= 0) + throw new Error(`release version must advance: ${process.env.BASE_VERSION} -> ${process.env.NEXT_VERSION}`) + ' + bun install --frozen-lockfile bun run sync:web-release -- --base-ref HEAD # Restore overlaid inputs so the cleanliness gate only sees releases.ts. git checkout -- CHANGELOG.md .release-please-manifest.json bun test ./scripts/sync-web-release-entry.test.ts - needs_commit=false if ! git diff --quiet -- web/src/data/releases.ts; then - needs_commit=true git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts git commit -m "chore(web): sync releases.ts from release-please changelog" fi - echo "needs_commit=$needs_commit" >> "$GITHUB_OUTPUT" # GITHUB_TOKEN pushes do not start ordinary PR checks, so run the # repo's standard gates against the synchronized commit before ready. @@ -345,7 +415,6 @@ jobs: echo "number=${{ steps.release-pr.outputs.number }}" echo "branch=${{ steps.release-pr.outputs.branch }}" echo "head_sha=${{ steps.release-pr.outputs.head_sha }}" - echo "needs_commit=$needs_commit" sha256sum web/src/data/releases.ts | awk '{print "releases_sha256="$1}' } > .release-sync-artifact/metadata.env @@ -454,21 +523,13 @@ jobs: fi fi - if [ "$needs_commit" = "true" ]; then - if git diff --quiet -- web/src/data/releases.ts; then - echo "artifact required a commit but working tree already matches" >&2 - exit 1 - fi + if ! git diff --quiet -- web/src/data/releases.ts; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts git commit -m "chore(web): sync releases.ts from release-please changelog" else - if ! git diff --quiet -- web/src/data/releases.ts; then - echo "artifact claimed no commit but working tree differs" >&2 - exit 1 - fi - echo "releases.ts already up to date" + echo "release PR already has the validated releases.ts artifact" fi if ! git diff --quiet || ! git diff --cached --quiet; then From 03af39992933c128666bb9bf12e2ed99a50dace4 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 18:51:41 -0700 Subject: [PATCH 32/37] fix(release): recover web sync state transitions --- .github/workflows/release.yml | 68 +++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aba3c5a69b..36e2b90ed7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,15 +83,24 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # This must be separate from release-please: after a tag/release exists, a + # transient PR API failure must not suppress npm or Docker publishing. + prepare-web-release: + name: Prepare Release Please PR for web sync + needs: release-please + if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: # release-please can leave an existing PR non-draft after updating it. - # Re-draft immediately in this write-capable job before validate/push run. - name: Keep Release Please PR draft after updates env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} RELEASE_REPO_OWNER: ${{ github.repository_owner }} - RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} + RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }} run: | set -euo pipefail @@ -162,8 +171,8 @@ jobs: # Sync failure must not block npm/docker after a release/tag is created. validate-web-release: name: Validate web release entry - needs: release-please - if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }} + needs: [release-please, prepare-web-release] + if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' && needs.prepare-web-release.result == 'success' }} runs-on: ubuntu-latest concurrency: group: sync-web-release-${{ github.ref }} @@ -309,17 +318,24 @@ jobs: # Run trusted main scripts against the pending bot PR's release inputs. # Do not execute package.json/scripts from the bot PR head. git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA" - expected_release_files="$(printf '%s\n' \ - .release-please-manifest.json \ - CHANGELOG.md \ - package.json \ - | sort)" - actual_release_files="$(git diff --name-only "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" | sort)" - if [ "$actual_release_files" != "$expected_release_files" ]; then + unexpected_release_files="$(git diff --name-only "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" \ + | grep -Fvx \ + -e .release-please-manifest.json \ + -e CHANGELOG.md \ + -e package.json \ + -e web/src/data/releases.ts \ + || true)" + if [ -n "$unexpected_release_files" ]; then echo "Release Please PR changed files outside its release-input contract" >&2 - diff -u <(printf '%s\n' "$expected_release_files") <(printf '%s\n' "$actual_release_files") || true + printf '%s\n' "$unexpected_release_files" >&2 exit 1 fi + for required_release_file in .release-please-manifest.json CHANGELOG.md package.json; do + if git diff --quiet "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" -- "$required_release_file"; then + echo "Release Please PR is missing required release input $required_release_file" >&2 + exit 1 + fi + done mkdir -p .release-sync-input git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json @@ -523,11 +539,32 @@ jobs: fi fi - if ! git diff --quiet -- web/src/data/releases.ts; then + current_sha="$(jq -r .headRefOid <<<"$current")" + validated_release_blob="$(git hash-object .release-sync-artifact/releases.ts)" + already_synchronized=false + if [ "$current_sha" != "$RELEASE_PR_HEAD_SHA" ]; then + git fetch --no-tags origin "$current_sha" + if ! git diff --quiet "$RELEASE_PR_HEAD_SHA" "$current_sha" -- . ':(exclude)web/src/data/releases.ts'; then + echo "release PR head changed outside the validated release artifact" >&2 + jq . <<<"$current" >&2 + exit 1 + fi + remote_release_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/web/src/data/releases.ts?ref=${current_sha}" --jq .sha)" + if [ "$remote_release_blob" != "$validated_release_blob" ]; then + echo "release PR head changed to an unvalidated release entry" >&2 + jq . <<<"$current" >&2 + exit 1 + fi + already_synchronized=true + fi + + if [ "$already_synchronized" = "false" ] && ! git diff --quiet -- web/src/data/releases.ts; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add web/src/data/releases.ts git commit -m "chore(web): sync releases.ts from release-please changelog" + elif [ "$already_synchronized" = "true" ]; then + echo "release PR already has the validated releases.ts artifact" else echo "release PR already has the validated releases.ts artifact" fi @@ -538,9 +575,10 @@ jobs: exit 1 fi - current_sha="$(jq -r .headRefOid <<<"$current")" local_sha="$(git rev-parse HEAD)" - if [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then + if [ "$already_synchronized" = "true" ]; then + local_sha="$current_sha" + elif [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" if ! GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ From d9cc9e6e08204c0e0d2e7a1018b327eb2cf9378d Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 18:57:13 -0700 Subject: [PATCH 33/37] fix(release): protect generated release ownership --- .github/workflows/release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36e2b90ed7..e35fbb3094 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,6 +341,7 @@ jobs: git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json + git show "${RELEASE_PR_HEAD_SHA}:web/src/data/releases.ts" > .release-sync-input/releases.ts if ! diff -u \ <(jq -S 'del(.version)' package.json) \ @@ -403,6 +404,16 @@ jobs: ' bun install --frozen-lockfile + if ! git diff --quiet "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then + if ! bun -e ' + import { hasGeneratedTopEntry } from "./scripts/sync-web-release-entry" + const releases = await Bun.file(".release-sync-input/releases.ts").text() + if (!hasGeneratedTopEntry(releases)) process.exit(1) + '; then + echo "Release Please PR has an unmarked releases.ts edit; refusing to overwrite it" >&2 + exit 1 + fi + fi bun run sync:web-release -- --base-ref HEAD # Restore overlaid inputs so the cleanliness gate only sees releases.ts. git checkout -- CHANGELOG.md .release-please-manifest.json From 9144c965c85f4c10ffaebfed0db16d6f72c60d08 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 19:36:59 -0700 Subject: [PATCH 34/37] fix(release): repair web sync recovery gates --- .github/workflows/release.yml | 113 +++++++++++-------------- scripts/sync-web-release-entry.test.ts | 27 ++++++ scripts/sync-web-release-entry.ts | 52 ++++++++++++ 3 files changed, 129 insertions(+), 63 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e35fbb3094..07cdf68ddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }} runs-on: ubuntu-latest permissions: - pull-requests: write + pull-requests: write # Re-draft the bot release PR after Release Please updates it. steps: # release-please can leave an existing PR non-draft after updating it. - name: Keep Release Please PR draft after updates @@ -311,7 +311,9 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} run: | set -euo pipefail @@ -365,45 +367,11 @@ jobs: echo "package and Release Please manifest versions do not match" >&2 exit 1 fi - BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" node -e ' - const identifier = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" - const pattern = new RegExp( - `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + - `(?:-(${identifier}(?:\\.${identifier})*))?` + - `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, - ) - const parse = value => { - const match = value.match(pattern) - if (!match) throw new Error(`invalid SemVer: ${value}`) - return { core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], prerelease: match[4]?.split(".") ?? null } - } - const compare = (left, right) => { - for (let index = 0; index < 3; index++) { - if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index] ? -1 : 1 - } - if (left.prerelease === null || right.prerelease === null) - return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1 - const length = Math.max(left.prerelease.length, right.prerelease.length) - for (let index = 0; index < length; index++) { - const a = left.prerelease[index] - const b = right.prerelease[index] - if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1 - if (a === b) continue - const aNumeric = /^\\d+$/.test(a) - const bNumeric = /^\\d+$/.test(b) - if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 - if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 - return a < b ? -1 : 1 - } - return 0 - } - const base = parse(process.env.BASE_VERSION) - const next = parse(process.env.NEXT_VERSION) - if (compare(next, base) <= 0) - throw new Error(`release version must advance: ${process.env.BASE_VERSION} -> ${process.env.NEXT_VERSION}`) - ' - bun install --frozen-lockfile + BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" bun -e ' + import { assertVersionAdvances } from "./scripts/sync-web-release-entry" + assertVersionAdvances(process.env.BASE_VERSION!, process.env.NEXT_VERSION!) + ' if ! git diff --quiet "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then if ! bun -e ' import { hasGeneratedTopEntry } from "./scripts/sync-web-release-entry" @@ -438,12 +406,14 @@ jobs: mkdir -p .release-sync-artifact cp web/src/data/releases.ts .release-sync-artifact/releases.ts - { - echo "number=${{ steps.release-pr.outputs.number }}" - echo "branch=${{ steps.release-pr.outputs.branch }}" - echo "head_sha=${{ steps.release-pr.outputs.head_sha }}" - sha256sum web/src/data/releases.ts | awk '{print "releases_sha256="$1}' - } > .release-sync-artifact/metadata.env + releases_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" + jq -n \ + --arg number "$RELEASE_PR_NUMBER" \ + --arg branch "$RELEASE_PR_BRANCH" \ + --arg head_sha "$RELEASE_PR_HEAD_SHA" \ + --arg releases_sha256 "$releases_sha256" \ + '{ number: $number, branch: $branch, head_sha: $head_sha, releases_sha256: $releases_sha256 }' \ + > .release-sync-artifact/metadata.json if ! git diff --quiet || ! git diff --cached --quiet; then echo "sync validation left uncommitted tracked changes" >&2 @@ -502,13 +472,26 @@ jobs: run: | set -euo pipefail - # shellcheck disable=SC1091 - source .release-sync-artifact/metadata.env + metadata_path=.release-sync-artifact/metadata.json + if ! jq -e ' + type == "object" + and (.number | type == "string" and length > 0) + and (.branch | type == "string" and length > 0) + and (.head_sha | type == "string" and test("^[0-9a-f]{40}$")) + and (.releases_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + ' "$metadata_path" >/dev/null; then + echo "validated artifact metadata is malformed" >&2 + exit 1 + fi + number="$(jq -r .number "$metadata_path")" + branch="$(jq -r .branch "$metadata_path")" + head_sha="$(jq -r .head_sha "$metadata_path")" + releases_sha256="$(jq -r .releases_sha256 "$metadata_path")" if [ "${number}" != "$RELEASE_PR_NUMBER" ] \ || [ "${branch}" != "$RELEASE_PR_BRANCH" ] \ || [ "${head_sha}" != "$RELEASE_PR_HEAD_SHA" ]; then echo "validated artifact metadata does not match the resolved release PR" >&2 - cat .release-sync-artifact/metadata.env >&2 + jq . "$metadata_path" >&2 exit 1 fi @@ -519,14 +502,6 @@ jobs: exit 1 fi - # Apply only the validated file — do not execute PR-head scripts here. - cp .release-sync-artifact/releases.ts web/src/data/releases.ts - applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" - if [ "$applied_sha256" != "$expected_sha256" ]; then - echo "failed to apply validated releases.ts" >&2 - exit 1 - fi - current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid,author,headRepositoryOwner,isCrossRepository)" expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ @@ -569,15 +544,27 @@ jobs: already_synchronized=true fi - if [ "$already_synchronized" = "false" ] && ! git diff --quiet -- web/src/data/releases.ts; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add web/src/data/releases.ts - git commit -m "chore(web): sync releases.ts from release-please changelog" - elif [ "$already_synchronized" = "true" ]; then + if [ "$already_synchronized" = "true" ]; then + # The captured validation head is stale after a concurrent push. + # Adopt the exact verified remote commit before checking cleanliness. + git switch --detach "$current_sha" echo "release PR already has the validated releases.ts artifact" else - echo "release PR already has the validated releases.ts artifact" + # Apply only the validated file — do not execute PR-head scripts here. + cp .release-sync-artifact/releases.ts web/src/data/releases.ts + applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" + if [ "$applied_sha256" != "$expected_sha256" ]; then + echo "failed to apply validated releases.ts" >&2 + exit 1 + fi + if ! git diff --quiet -- web/src/data/releases.ts; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/data/releases.ts + git commit -m "chore(web): sync releases.ts from release-please changelog" + else + echo "release PR already has the validated releases.ts artifact" + fi fi if ! git diff --quiet || ! git diff --cached --quiet; then diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts index 6db47a22f5..d5a08e637a 100644 --- a/scripts/sync-web-release-entry.test.ts +++ b/scripts/sync-web-release-entry.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' import { + assertVersionAdvances, + compareSemVer, deriveTheme, formatReleaseEntry, GENERATED_ENTRY_MARKER, @@ -35,6 +37,31 @@ const SAMPLE_RELEASES_TS = `export const releases: Release[] = [ ] ` +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({ diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts index 611d6bf379..f5ee13bd9c 100644 --- a/scripts/sync-web-release-entry.ts +++ b/scripts/sync-web-release-entry.ts @@ -13,6 +13,13 @@ const LEGACY_GENERATED_ENTRY_MARKER = const GENERATED_ENTRY_MARKERS = [GENERATED_ENTRY_MARKER, LEGACY_GENERATED_ENTRY_MARKER] as const +const SEMVER_IDENTIFIER = '(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)' +const SEMVER_PATTERN = new RegExp( + `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + + `(?:-(${SEMVER_IDENTIFIER}(?:\\.${SEMVER_IDENTIFIER})*))?` + + `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, +) + export type ReleaseEntry = { version: string date: string @@ -20,6 +27,51 @@ export type ReleaseEntry = { highlights: string[] } +type ParsedSemVer = { + core: [bigint, bigint, bigint] + prerelease: string[] | null +} + +function parseSemVer(version: string): ParsedSemVer { + const match = version.match(SEMVER_PATTERN) + if (!match) throw new Error(`invalid SemVer: ${version}`) + return { + core: [BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)], + prerelease: match[4]?.split('.') ?? null, + } +} + +export function compareSemVer(leftVersion: string, rightVersion: string): number { + const left = parseSemVer(leftVersion) + const right = parseSemVer(rightVersion) + for (let index = 0; index < left.core.length; index++) { + if (left.core[index] !== right.core[index]) + return left.core[index]! < right.core[index]! ? -1 : 1 + } + if (left.prerelease === null || right.prerelease === null) + return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1 + + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index++) { + const a = left.prerelease[index] + const b = right.prerelease[index] + if (a === undefined || b === undefined) + return a === b ? 0 : a === undefined ? -1 : 1 + if (a === b) continue + const aNumeric = /^\d+$/.test(a) + const bNumeric = /^\d+$/.test(b) + if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1 + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 + return a < b ? -1 : 1 + } + return 0 +} + +export function assertVersionAdvances(baseVersion: string, nextVersion: string): void { + if (compareSemVer(nextVersion, baseVersion) <= 0) + throw new Error(`release version must advance: ${baseVersion} -> ${nextVersion}`) +} + export function sanitizeChangelogBullet(line: string): string { let sanitized = line .replace(/^\*\s+/, '') From a0c81c2eb6911786ecd192e557da44e160c55b77 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 19:51:59 -0700 Subject: [PATCH 35/37] fix(release): bind sync artifacts to validated base --- .github/workflows/release.yml | 71 ++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07cdf68ddd..415686a138 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -311,6 +311,7 @@ jobs: if: ${{ steps.release-pr.outputs.found == 'true' }} env: RELEASE_BASE_BRANCH: ${{ github.ref_name }} + RELEASE_EVENT_SHA: ${{ github.sha }} RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }} RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }} RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }} @@ -320,7 +321,19 @@ jobs: # Run trusted main scripts against the pending bot PR's release inputs. # Do not execute package.json/scripts from the bot PR head. git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA" - unexpected_release_files="$(git diff --name-only "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" \ + release_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")" + if [ "$(git rev-parse HEAD)" != "$RELEASE_EVENT_SHA" ] \ + || [ "$release_base_sha" != "$RELEASE_EVENT_SHA" ]; then + echo "main advanced during release synchronization; a newer workflow run must validate the new base" >&2 + echo "event=$RELEASE_EVENT_SHA current=$release_base_sha" >&2 + exit 1 + fi + if [ "$(git merge-base "$release_base_sha" "$RELEASE_PR_HEAD_SHA")" != "$release_base_sha" ]; then + echo "Release Please PR is not based on the validated main commit" >&2 + exit 1 + fi + + unexpected_release_files="$(git diff --name-only "$release_base_sha...$RELEASE_PR_HEAD_SHA" \ | grep -Fvx \ -e .release-please-manifest.json \ -e CHANGELOG.md \ @@ -333,11 +346,18 @@ jobs: exit 1 fi for required_release_file in .release-please-manifest.json CHANGELOG.md package.json; do - if git diff --quiet "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" -- "$required_release_file"; then + if git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- "$required_release_file"; then echo "Release Please PR is missing required release input $required_release_file" >&2 exit 1 fi done + for release_file in .release-please-manifest.json CHANGELOG.md package.json web/src/data/releases.ts; do + tree_entry="$(git ls-tree "$RELEASE_PR_HEAD_SHA" -- "$release_file")" + if [[ "$tree_entry" != "100644 blob "*$'\t'"$release_file" ]]; then + echo "Release Please file must remain a regular non-executable blob: $release_file" >&2 + exit 1 + fi + done mkdir -p .release-sync-input git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json @@ -372,7 +392,7 @@ jobs: import { assertVersionAdvances } from "./scripts/sync-web-release-entry" assertVersionAdvances(process.env.BASE_VERSION!, process.env.NEXT_VERSION!) ' - if ! git diff --quiet "origin/$RELEASE_BASE_BRANCH...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then + if ! git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then if ! bun -e ' import { hasGeneratedTopEntry } from "./scripts/sync-web-release-entry" const releases = await Bun.file(".release-sync-input/releases.ts").text() @@ -398,22 +418,23 @@ jobs: # repo's standard gates against the synchronized commit before ready. bun run typecheck bun run typecheck:type-tests - bun run security:pr-scan -- --base "origin/$RELEASE_BASE_BRANCH" --head HEAD - git diff --check "origin/$RELEASE_BASE_BRANCH"...HEAD + bun run security:pr-scan -- --base "$release_base_sha" --head HEAD + git diff --check "$release_base_sha"...HEAD bun install --cwd web --frozen-lockfile bun run web:typecheck bun run web:build - mkdir -p .release-sync-artifact - cp web/src/data/releases.ts .release-sync-artifact/releases.ts + mkdir -p release-sync-artifact + cp web/src/data/releases.ts release-sync-artifact/releases.ts releases_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" jq -n \ --arg number "$RELEASE_PR_NUMBER" \ --arg branch "$RELEASE_PR_BRANCH" \ + --arg base_sha "$release_base_sha" \ --arg head_sha "$RELEASE_PR_HEAD_SHA" \ --arg releases_sha256 "$releases_sha256" \ - '{ number: $number, branch: $branch, head_sha: $head_sha, releases_sha256: $releases_sha256 }' \ - > .release-sync-artifact/metadata.json + '{ number: $number, branch: $branch, base_sha: $base_sha, head_sha: $head_sha, releases_sha256: $releases_sha256 }' \ + > release-sync-artifact/metadata.json if ! git diff --quiet || ! git diff --cached --quiet; then echo "sync validation left uncommitted tracked changes" >&2 @@ -429,7 +450,7 @@ jobs: # retried: run_attempt changes on a retry, while this validated head # remains the push job's immutable input. name: web-release-sync-${{ github.run_id }}-${{ steps.release-pr.outputs.head_sha }} - path: .release-sync-artifact/ + path: release-sync-artifact/ if-no-files-found: error # A full validation rerun for the same head replaces its prior artifact. overwrite: true @@ -458,7 +479,7 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: web-release-sync-${{ github.run_id }}-${{ needs.validate-web-release.outputs.head_sha }} - path: .release-sync-artifact + path: release-sync-artifact - name: Apply validated releases.ts and push id: push-sync @@ -472,11 +493,12 @@ jobs: run: | set -euo pipefail - metadata_path=.release-sync-artifact/metadata.json + metadata_path=release-sync-artifact/metadata.json if ! jq -e ' type == "object" and (.number | type == "string" and length > 0) and (.branch | type == "string" and length > 0) + and (.base_sha | type == "string" and test("^[0-9a-f]{40}$")) and (.head_sha | type == "string" and test("^[0-9a-f]{40}$")) and (.releases_sha256 | type == "string" and test("^[0-9a-f]{64}$")) ' "$metadata_path" >/dev/null; then @@ -485,6 +507,7 @@ jobs: fi number="$(jq -r .number "$metadata_path")" branch="$(jq -r .branch "$metadata_path")" + base_sha="$(jq -r .base_sha "$metadata_path")" head_sha="$(jq -r .head_sha "$metadata_path")" releases_sha256="$(jq -r .releases_sha256 "$metadata_path")" if [ "${number}" != "$RELEASE_PR_NUMBER" ] \ @@ -496,12 +519,20 @@ jobs: fi expected_sha256="$releases_sha256" - actual_sha256="$(sha256sum .release-sync-artifact/releases.ts | awk '{print $1}')" + actual_sha256="$(sha256sum release-sync-artifact/releases.ts | awk '{print $1}')" if [ "$actual_sha256" != "$expected_sha256" ]; then echo "validated releases.ts digest mismatch" >&2 exit 1 fi + git fetch --no-tags origin "$RELEASE_BASE_BRANCH" + current_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")" + if [ "$current_base_sha" != "$base_sha" ]; then + echo "main advanced after web-release validation; refusing to push a stale artifact" >&2 + echo "validated=$base_sha current=$current_base_sha" >&2 + exit 1 + fi + current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid,author,headRepositoryOwner,isCrossRepository)" expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ @@ -526,7 +557,7 @@ jobs: fi current_sha="$(jq -r .headRefOid <<<"$current")" - validated_release_blob="$(git hash-object .release-sync-artifact/releases.ts)" + validated_release_blob="$(git hash-object release-sync-artifact/releases.ts)" already_synchronized=false if [ "$current_sha" != "$RELEASE_PR_HEAD_SHA" ]; then git fetch --no-tags origin "$current_sha" @@ -551,7 +582,7 @@ jobs: echo "release PR already has the validated releases.ts artifact" else # Apply only the validated file — do not execute PR-head scripts here. - cp .release-sync-artifact/releases.ts web/src/data/releases.ts + cp release-sync-artifact/releases.ts web/src/data/releases.ts applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')" if [ "$applied_sha256" != "$expected_sha256" ]; then echo "failed to apply validated releases.ts" >&2 @@ -604,6 +635,7 @@ jobs: fi echo "head_sha=$local_sha" >> "$GITHUB_OUTPUT" + echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" - name: Mark synchronized release PR ready for review if: ${{ steps.push-sync.outcome == 'success' }} @@ -613,12 +645,15 @@ jobs: RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }} RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }} RELEASE_BASE_BRANCH: ${{ github.ref_name }} + VALIDATED_BASE_SHA: ${{ steps.push-sync.outputs.base_sha }} VALIDATED_HEAD_SHA: ${{ steps.push-sync.outputs.head_sha }} run: | set -euo pipefail + current_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)" current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" - if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ + if [ "$current_base_sha" != "$VALIDATED_BASE_SHA" ] \ + || [ "$(jq -r .state <<<"$current")" != "OPEN" ] \ || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \ || [ "$(jq -r .headRefOid <<<"$current")" != "$VALIDATED_HEAD_SHA" ]; then @@ -634,8 +669,10 @@ jobs: gh pr ready "$RELEASE_PR_NUMBER" fi + final_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)" final="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)" - if [ "$(jq -r .state <<<"$final")" != "OPEN" ] \ + if [ "$final_base_sha" != "$VALIDATED_BASE_SHA" ] \ + || [ "$(jq -r .state <<<"$final")" != "OPEN" ] \ || [ "$(jq -r .isDraft <<<"$final")" != "false" ] \ || [ "$(jq -r .baseRefName <<<"$final")" != "$RELEASE_BASE_BRANCH" ] \ || [ "$(jq -r .headRefName <<<"$final")" != "$RELEASE_PR_BRANCH" ] \ From e172ca5cd1b2d4b796896b2b830b8ac688712901 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 20:01:04 -0700 Subject: [PATCH 36/37] fix(release): verify synchronized file mode --- .github/workflows/release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 415686a138..3ae812d225 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -566,9 +566,10 @@ jobs: jq . <<<"$current" >&2 exit 1 fi - remote_release_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/web/src/data/releases.ts?ref=${current_sha}" --jq .sha)" - if [ "$remote_release_blob" != "$validated_release_blob" ]; then - echo "release PR head changed to an unvalidated release entry" >&2 + remote_release_entry="$(git ls-tree "$current_sha" -- web/src/data/releases.ts)" + expected_release_entry="100644 blob ${validated_release_blob}"$'\t''web/src/data/releases.ts' + if [ "$remote_release_entry" != "$expected_release_entry" ]; then + echo "release PR head changed to an unvalidated release entry or file mode" >&2 jq . <<<"$current" >&2 exit 1 fi From 66d5ab12d2f53d0096d4e91100370fcb6c82da25 Mon Sep 17 00:00:00 2001 From: jatmn Date: Mon, 10 Aug 2026 20:07:52 -0700 Subject: [PATCH 37/37] fix(release): paginate bot PR discovery --- .github/workflows/release.yml | 114 +++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ae812d225..d5a9fadabd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,26 +36,36 @@ jobs: set -euo pipefail expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--" - matches="$(gh pr list \ - --state open \ - --base "$RELEASE_BASE_BRANCH" \ - --limit 50 \ - --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ - | jq -c \ + matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \ + -f state=open \ + -f base="$RELEASE_BASE_BRANCH" \ + -f per_page=100 \ + | jq -s -c \ --arg prefix "$expected_prefix" \ --arg owner "$RELEASE_REPO_OWNER" \ --arg base "$RELEASE_BASE_BRANCH" ' [ - .[] + add[] | select( - .state == "OPEN" - and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") - and .baseRefName == $base - and (.headRefName | startswith($prefix)) - and (.headRefOid | length) > 0 - and .headRepositoryOwner.login == $owner - and .isCrossRepository == false + .state == "open" + and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]") + and .base.ref == $base + and (.head.ref | startswith($prefix)) + and (.head.sha | length) > 0 + and .head.repo.owner.login == $owner + and .head.repo.full_name == .base.repo.full_name ) + | { + number, + state: (.state | ascii_upcase), + isDraft: .draft, + author: { login: .user.login }, + baseRefName: .base.ref, + headRefName: .head.ref, + headRefOid: .head.sha, + headRepositoryOwner: { login: .head.repo.owner.login }, + isCrossRepository: false + } ] ')" match_count="$(jq length <<<"$matches")" @@ -123,26 +133,36 @@ jobs: fi if [ -z "$release_pr" ]; then - matches="$(gh pr list \ - --state open \ - --base "$RELEASE_BASE_BRANCH" \ - --limit 50 \ - --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ - | jq -c \ + matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \ + -f state=open \ + -f base="$RELEASE_BASE_BRANCH" \ + -f per_page=100 \ + | jq -s -c \ --arg prefix "$expected_prefix" \ --arg owner "$RELEASE_REPO_OWNER" \ --arg base "$RELEASE_BASE_BRANCH" ' [ - .[] + add[] | select( - .state == "OPEN" - and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") - and .baseRefName == $base - and (.headRefName | startswith($prefix)) - and (.headRefOid | length) > 0 - and .headRepositoryOwner.login == $owner - and .isCrossRepository == false + .state == "open" + and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]") + and .base.ref == $base + and (.head.ref | startswith($prefix)) + and (.head.sha | length) > 0 + and .head.repo.owner.login == $owner + and .head.repo.full_name == .base.repo.full_name ) + | { + number, + state: (.state | ascii_upcase), + isDraft: .draft, + author: { login: .user.login }, + baseRefName: .base.ref, + headRefName: .head.ref, + headRefOid: .head.sha, + headRepositoryOwner: { login: .head.repo.owner.login }, + isCrossRepository: false + } ] ')" if [ "$(jq length <<<"$matches")" -eq 0 ]; then @@ -222,26 +242,36 @@ jobs: # Discover by bot branch prefix, not only the label, so a stripped # label cannot leave a ready bot PR unvalidated. Ignore mislabeled # non-bot PRs instead of failing the release pipeline. - gh pr list \ - --state open \ - --base "$RELEASE_BASE_BRANCH" \ - --limit 50 \ - --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository \ - | jq -c \ + gh api --paginate --method GET "repos/${GH_REPO}/pulls" \ + -f state=open \ + -f base="$RELEASE_BASE_BRANCH" \ + -f per_page=100 \ + | jq -s -c \ --arg prefix "$expected_prefix" \ --arg owner "$RELEASE_REPO_OWNER" \ --arg base "$RELEASE_BASE_BRANCH" ' [ - .[] + add[] | select( - .state == "OPEN" - and (.author.login == "app/github-actions" or .author.login == "github-actions[bot]") - and .baseRefName == $base - and (.headRefName | startswith($prefix)) - and (.headRefOid | length) > 0 - and .headRepositoryOwner.login == $owner - and .isCrossRepository == false + .state == "open" + and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]") + and .base.ref == $base + and (.head.ref | startswith($prefix)) + and (.head.sha | length) > 0 + and .head.repo.owner.login == $owner + and .head.repo.full_name == .base.repo.full_name ) + | { + number, + state: (.state | ascii_upcase), + isDraft: .draft, + author: { login: .user.login }, + baseRefName: .base.ref, + headRefName: .head.ref, + headRefOid: .head.sha, + headRepositoryOwner: { login: .head.repo.owner.login }, + isCrossRepository: false + } ] ' }