diff --git a/.github/release-blog-template.tsx.tmpl b/.github/release-blog-template.tsx.tmpl deleted file mode 100644 index d702998c..00000000 --- a/.github/release-blog-template.tsx.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -import { ChangelogSection } from '../../../components/blog/ChangelogSection' -import { Prose } from '../../../components/blog/Prose' -import type { BlogPost } from '../types' - -export const __EXPORT_NAME__: BlogPost = { - slug: '__SLUG__', - title: '__TITLE__', - description: '__DESCRIPTION__', - date: '__DATE__', - category: 'release', - version: '__VERSION__', - tags: ['release'], - readingTime: '__READING_TIME__', - content: ( - -

- This rollup covers __COVERED_VERSIONS_PROSE__. Head to the{' '} - Sencho docs for setup guides and - full feature walkthroughs. -

- -

What's in this release

-__ADDED_BLOCK__ -__FIXED_BLOCK__ -__CHANGED_BLOCK__ -
- ), -} diff --git a/.github/scripts/scaffold-release-post.mjs b/.github/scripts/scaffold-release-post.mjs deleted file mode 100644 index 5754add6..00000000 --- a/.github/scripts/scaffold-release-post.mjs +++ /dev/null @@ -1,408 +0,0 @@ -#!/usr/bin/env node -// Scaffolds a release blog post in the Sencho website repo when the tag that -// triggered this run completes a run of 5 releases since the last post's anchor. -// Invoked by .github/workflows/release-blog-scaffold.yml. See website/CLAUDE.md -// for the cadence rules this script enforces. -// -// Auto-publish mode: the script generates a complete, TODO-free post and the -// workflow commits it directly to website main. No PR is created or required. - -import { execFileSync } from 'node:child_process' -import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' - -const HERE = dirname(fileURLToPath(import.meta.url)) -const REPO_ROOT = join(HERE, '..', '..') -const TEMPLATE_PATH = join(REPO_ROOT, '.github', 'release-blog-template.tsx.tmpl') - -const WINDOW_SIZE = 5 -// Section names come from release-please-config.json's changelog-sections. -// Security gets its own bucket so hardening items do not get lost. -const SECTION_MAP = { - Added: 'added', - Fixed: 'fixed', - Changed: 'changed', - Security: 'fixed', -} - -function parseArgs(argv) { - const args = { dryRun: false } - for (let i = 0; i < argv.length; i++) { - const a = argv[i] - if (a === '--dry-run') args.dryRun = true - else if (a === '--tag') args.tag = argv[++i] - else if (a === '--changelog') args.changelog = argv[++i] - else if (a === '--website') args.website = argv[++i] - } - if (!args.tag || !args.changelog || !args.website) { - throw new Error('Usage: --tag vX.Y.Z --changelog --website [--dry-run]') - } - return args -} - -function stripV(tag) { - return tag.startsWith('v') ? tag.slice(1) : tag -} - -function semverTuple(v) { - return v.split('.').map((n) => { - const x = Number.parseInt(n, 10) - if (Number.isNaN(x)) throw new Error(`Bad semver segment: ${v}`) - return x - }) -} - -function cmpSemver(a, b) { - const ta = semverTuple(a) - const tb = semverTuple(b) - for (let i = 0; i < 3; i++) { - if ((ta[i] ?? 0) !== (tb[i] ?? 0)) return (ta[i] ?? 0) - (tb[i] ?? 0) - } - return 0 -} - -function findLastAnchor(websiteRoot) { - const postsDir = join(websiteRoot, 'src', 'data', 'blog', 'posts') - if (!existsSync(postsDir)) return null - const files = readdirSync(postsDir).filter((f) => f.endsWith('.tsx')) - const versions = [] - for (const f of files) { - const body = readFileSync(join(postsDir, f), 'utf8') - // Any post with a version: field is an anchor regardless of category. - // This lets narrative retrospective articles anchor the window too. - const m = body.match(/version:\s*['"]([0-9]+\.[0-9]+\.[0-9]+)['"]/) - if (m) versions.push(m[1]) - } - if (versions.length === 0) return null - versions.sort(cmpSemver) - return versions[versions.length - 1] -} - -function listTags() { - const out = execFileSync('git', ['tag', '--sort=creatordate', '--list', 'v*'], { - cwd: REPO_ROOT, - encoding: 'utf8', - }) - return out.split('\n').map((s) => s.trim()).filter((s) => /^v\d+\.\d+\.\d+$/.test(s)) -} - -function windowSinceAnchor(tags, anchor, tag) { - const tagIdx = tags.indexOf(tag) - if (tagIdx === -1) { - throw new Error(`Tag ${tag} not found in repo tag list`) - } - if (!anchor) { - // No prior release post: treat every tag up to and including this one as the window. - return tags.slice(0, tagIdx + 1) - } - const anchorTag = `v${anchor}` - const anchorIdx = tags.indexOf(anchorTag) - if (anchorIdx === -1) { - // Anchor tag was deleted or renamed. Fall back to bootstrap so the run does not hard-fail. - console.warn(`WARN: anchor tag ${anchorTag} not found; falling back to bootstrap window`) - return tags.slice(0, tagIdx + 1) - } - return tags.slice(anchorIdx + 1, tagIdx + 1) -} - -function parseChangelog(changelogPath, versionsInWindow) { - const body = readFileSync(changelogPath, 'utf8') - // Locate every version heading by anchored regex. Splitting on raw `## [` - // would also match occurrences inside prose or code fences. - const headingRe = /^## \[([0-9]+\.[0-9]+\.[0-9]+)\][^\n]*$/gm - const headings = [] - let h - while ((h = headingRe.exec(body)) !== null) { - headings.push({ version: h[1], start: h.index }) - } - const perVersion = new Map() - for (let i = 0; i < headings.length; i++) { - const end = i + 1 < headings.length ? headings[i + 1].start : body.length - perVersion.set(headings[i].version, body.slice(headings[i].start, end)) - } - - const grouped = { added: [], fixed: [], changed: [] } - for (const v of versionsInWindow) { - const block = perVersion.get(v) - if (!block) { - console.warn(`WARN: no CHANGELOG entry for ${v}`) - continue - } - const sections = block.split(/^### /m).slice(1) - for (const s of sections) { - const nameMatch = s.match(/^([^\n]+)\n/) - if (!nameMatch) continue - const sectionName = nameMatch[1].trim() - const bucket = SECTION_MAP[sectionName] - if (!bucket) continue - const lines = s.split('\n').slice(1) - for (const line of lines) { - if (!line.startsWith('* ')) continue - let item = line.slice(2).trim() - // Strip trailing "([...](url))" groups (PR link, commit SHA) repeatedly - // since release-please emits both. - const tail = /\s*\(\[[^\]]+\]\([^)]+\)\)\s*$/u - while (tail.test(item)) item = item.replace(tail, '').trim() - if (item.length > 0) grouped[bucket].push(item) - } - } - } - return grouped -} - -function escapeTsxString(s) { - return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") -} - -function renderChangelogBlock(type, items) { - if (items.length === 0) return '' - const rendered = items.map((it) => ` '${escapeTsxString(it)}',`).join('\n') - return ` -` -} - -function exportNameFor(version) { - // Underscore-separated so e.g. 0.54.0 and 0.5.40 never collide, and so the - // scheme stays unambiguous once we ship 1.0+. - return `v${version.replace(/\./g, '_')}Release` -} - -function slugFor(version) { - return `v${version.replace(/\./g, '-')}-release` -} - -function todayIso() { - return new Date().toISOString().slice(0, 10) -} - -// --------------------------------------------------------------------------- -// Auto-generation helpers -// --------------------------------------------------------------------------- - -function stripScope(item) { - return item.replace(/^\*\*[^*]+\*\*:\s*/, '') -} - -function stripLeadingVerb(s) { - return s.replace(/^(?:add|remove|update|change|replace|implement|introduce|redesign)\s+/i, '') -} - -function toFirstUpper(s) { - return s.charAt(0).toUpperCase() + s.slice(1) -} - -function summarize(item, maxLen) { - const len = maxLen ?? 44 - const plain = toFirstUpper(stripLeadingVerb(stripScope(item))) - return plain.length > len ? plain.slice(0, len - 3) + '...' : plain -} - -function buildHeadline(addedItems, totalCount, version) { - if (addedItems.length === 0) { - return `Sencho v${version}: ${totalCount} improvements` - } - const top = addedItems.slice(0, 2) - const extra = totalCount - top.length - if (top.length === 1) { - const a = summarize(top[0]) - return extra > 0 ? `Sencho v${version}: ${a} and ${extra} more` : `Sencho v${version}: ${a}` - } - const a = summarize(top[0]) - const b = summarize(top[1]) - return `Sencho v${version}: ${a}, ${b}, and ${extra} more` -} - -function buildDescription(vStart, vEnd, addedItems) { - const maxLen = 160 - const prefix = - vStart === vEnd - ? `Sencho v${vEnd}. Key additions: ` - : `Covers ${vStart} through ${vEnd}. Key additions: ` - const items = addedItems.slice(0, 3).map((s) => summarize(s, 50)) - let desc = prefix + items.join(', ') + '.' - if (desc.length > maxLen) desc = desc.slice(0, maxLen - 3) + '...' - return desc -} - -function calcReadingTime(grouped) { - const allItems = [...grouped.added, ...grouped.fixed, ...grouped.changed] - const wordCount = allItems.reduce((sum, s) => sum + s.split(/\s+/).length, 0) + 80 - const minutes = Math.max(1, Math.ceil(wordCount / 200)) - return `${minutes} min read` -} - -function coveredVersionsProse(versions) { - if (versions.length === 0) return '' - if (versions.length === 1) return `v${versions[0]}` - return `v${versions[0]} through v${versions[versions.length - 1]}` -} - -// --------------------------------------------------------------------------- - -function renderPost(version, coveredVersions, grouped) { - const template = readFileSync(TEMPLATE_PATH, 'utf8') - const totalCount = grouped.added.length + grouped.fixed.length + grouped.changed.length - const vStart = coveredVersions[0] - const vEnd = coveredVersions[coveredVersions.length - 1] - const replacements = { - __EXPORT_NAME__: exportNameFor(version), - __SLUG__: slugFor(version), - __TITLE__: buildHeadline(grouped.added, totalCount, version), - __DESCRIPTION__: buildDescription(vStart, vEnd, grouped.added), - __VERSION__: version, - __DATE__: todayIso(), - __READING_TIME__: calcReadingTime(grouped), - __COVERED_VERSIONS_PROSE__: coveredVersionsProse(coveredVersions), - __ADDED_BLOCK__: renderChangelogBlock('added', grouped.added), - __FIXED_BLOCK__: renderChangelogBlock('fixed', grouped.fixed), - __CHANGED_BLOCK__: renderChangelogBlock('changed', grouped.changed), - } - let out = template - for (const [k, v] of Object.entries(replacements)) { - out = out.split(k).join(v) - } - return out -} - -function updateIndex(websiteRoot, exportName, fileBase) { - const indexPath = join(websiteRoot, 'src', 'data', 'blog', 'index.ts') - const body = readFileSync(indexPath, 'utf8') - const importLine = `import { ${exportName} } from './posts/${fileBase}'` - if (body.includes(importLine)) return body - const lines = body.split('\n') - let lastImportIdx = -1 - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('import ')) lastImportIdx = i - } - if (lastImportIdx === -1) throw new Error('No imports found in index.ts') - lines.splice(lastImportIdx + 1, 0, importLine) - - const joined = lines.join('\n') - // The closing capture tolerates both multi-line (`,\n]`) and empty inline - // (`[]`) array shapes. Empty inline is the bootstrap state of a fresh - // sencho-website repo before any release post has been written. - const arrayRegex = /(export const blogPosts: BlogPost\[\] = \[)([\s\S]*?)(\s*\])/ - const m = joined.match(arrayRegex) - if (!m) throw new Error('Could not locate blogPosts array in index.ts') - const before = joined.slice(0, m.index) - const arrOpen = m[1] - const arrBody = m[2] - const after = joined.slice(m.index + m[0].length) - const trimmedBody = arrBody.replace(/\s*$/u, '') - return `${before}${arrOpen}${trimmedBody}\n ${exportName},\n]${after}` -} - -function updateMeta(websiteRoot, slug, title, description, date) { - const metaPath = join(websiteRoot, 'src', 'data', 'blog', 'meta.ts') - if (!existsSync(metaPath)) return null - const body = readFileSync(metaPath, 'utf8') - if (body.includes(`slug: '${slug}'`)) return body - const entry = [ - ' {', - ` slug: '${slug}',`, - ` title: '${escapeTsxString(title)}',`, - ` description:`, - ` '${escapeTsxString(description)}',`, - ` date: '${date}',`, - ' },', - ].join('\n') - // Insert before the closing bracket of the array. - const lastBracket = body.lastIndexOf(']') - return body.slice(0, lastBracket) + entry + '\n' + body.slice(lastBracket) -} - -function writeGithubOutput(kv) { - const out = process.env.GITHUB_OUTPUT - if (!out) return - const lines = Object.entries(kv).map(([k, v]) => `${k}=${v}`) - writeFileSync(out, lines.join('\n') + '\n', { flag: 'a' }) -} - -function main() { - const args = parseArgs(process.argv.slice(2)) - const newVersion = stripV(args.tag) - - const lastAnchor = findLastAnchor(args.website) - console.log(`Last anchor: ${lastAnchor ?? '(none, bootstrap)'}`) - - const tags = listTags() - const windowTags = windowSinceAnchor(tags, lastAnchor, args.tag) - const windowVersions = windowTags.map(stripV) - console.log(`Window (${windowTags.length}): ${windowTags.join(', ')}`) - - if (windowTags.length < WINDOW_SIZE) { - console.log(`scaffold=false (count ${windowTags.length} < ${WINDOW_SIZE})`) - writeGithubOutput({ scaffold: 'false' }) - return - } - if (windowTags.length % WINDOW_SIZE !== 0) { - console.log(`scaffold=false (count ${windowTags.length} not divisible by ${WINDOW_SIZE})`) - writeGithubOutput({ scaffold: 'false' }) - return - } - if (windowTags.length > WINDOW_SIZE) { - console.warn( - `WARN: window size ${windowTags.length} exceeds ${WINDOW_SIZE}. A prior scaffold was likely skipped; rolling up all ${windowTags.length} versions.`, - ) - } - - const grouped = parseChangelog(args.changelog, windowVersions) - console.log( - `Collected items: added=${grouped.added.length}, fixed=${grouped.fixed.length}, changed=${grouped.changed.length}`, - ) - - const exportName = exportNameFor(newVersion) - const slug = slugFor(newVersion) - const date = todayIso() - const fileBase = `${date}-${slug}` - const postPath = join(args.website, 'src', 'data', 'blog', 'posts', `${fileBase}.tsx`) - const indexPath = join(args.website, 'src', 'data', 'blog', 'index.ts') - const metaPath = join(args.website, 'src', 'data', 'blog', 'meta.ts') - - const totalCount = grouped.added.length + grouped.fixed.length + grouped.changed.length - const title = buildHeadline(grouped.added, totalCount, newVersion) - const description = buildDescription( - windowVersions[0], - windowVersions[windowVersions.length - 1], - grouped.added, - ) - - const post = renderPost(newVersion, windowVersions, grouped) - const newIndex = updateIndex(args.website, exportName, fileBase) - const newMeta = updateMeta(args.website, slug, title, description, date) - - if (args.dryRun) { - console.log('\n--- Post file (dry-run): ' + postPath) - console.log(post) - console.log('\n--- index.ts (dry-run): ' + indexPath) - console.log(newIndex) - if (newMeta) { - console.log('\n--- meta.ts (dry-run): ' + metaPath) - console.log(newMeta) - } - } else { - mkdirSync(dirname(postPath), { recursive: true }) - writeFileSync(postPath, post, 'utf8') - writeFileSync(indexPath, newIndex, 'utf8') - if (newMeta) writeFileSync(metaPath, newMeta, 'utf8') - console.log(`Wrote ${postPath}`) - console.log(`Updated ${indexPath}`) - if (newMeta) console.log(`Updated ${metaPath}`) - } - - writeGithubOutput({ - scaffold: 'true', - version: newVersion, - slug, - post_path: `src/data/blog/posts/${fileBase}.tsx`, - covered: windowVersions.join(','), - }) -} - -main() diff --git a/.github/workflows/release-blog-scaffold.yml b/.github/workflows/release-blog-scaffold.yml deleted file mode 100644 index 0fefd385..00000000 --- a/.github/workflows/release-blog-scaffold.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Release Blog Scaffold - -# Fires on every v* tag push. Computes whether this tag completes the next -# every-5th-release window (source of truth = the latest post with a version: -# field in the sencho-website repo). If yes, generates a complete, TODO-free -# blog post and commits it directly to sencho-website main. No PR is opened. -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: 'Tag to scaffold against (e.g. v0.58.0). Must already exist.' - required: true - type: string - -permissions: - contents: read - -jobs: - scaffold: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - # Same App-token pattern as release-please.yml. The app's installation - # must include both Sencho and sencho-website for the push + PR to work. - - name: Generate GitHub App installation token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - Sencho - sencho-website - permission-contents: write - - - name: Resolve target tag - id: target - run: | - set -euo pipefail - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" - else - tag="${GITHUB_REF_NAME}" - fi - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - echo "Resolved target tag: ${tag}" - - - name: Check out Sencho (full history for tags) - uses: actions/checkout@v6 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Check out sencho-website - uses: actions/checkout@v6 - with: - repository: ${{ github.repository_owner }}/sencho-website - token: ${{ steps.app-token.outputs.token }} - path: website-repo - fetch-depth: 1 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Run scaffold script - id: scaffold - run: | - set -euo pipefail - node .github/scripts/scaffold-release-post.mjs \ - --tag "${{ steps.target.outputs.tag }}" \ - --changelog CHANGELOG.md \ - --website website-repo - - - name: Commit and push release post to sencho-website - if: steps.scaffold.outputs.scaffold == 'true' - env: - TAG: ${{ steps.target.outputs.tag }} - POST_PATH: ${{ steps.scaffold.outputs.post_path }} - run: | - set -euo pipefail - cd website-repo - git config user.name "sencho-release-bot[bot]" - git config user.email "sencho-release-bot[bot]@users.noreply.github.com" - git add "${POST_PATH}" src/data/blog/index.ts src/data/blog/meta.ts - git commit -m "chore(blog): auto-publish release post for ${TAG}" - git push origin main - - - name: Report skip - if: steps.scaffold.outputs.scaffold != 'true' - run: | - echo "No scaffold this run. Window did not reach the next every-5th-release anchor."