From 3f620591c84440264a98fb59ae28f283026e23b2 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 17 May 2026 05:24:40 -0400 Subject: [PATCH] chore(ci): retire release-blog-scaffold workflow (#1084) Removes the auto-publish workflow that fired on every v* tag and generated a release post in the sencho-website repo on every 5th release. Release posts are no longer the desired blog content for the project, so the workflow, its helper script, and its template are all removed together. Deletes: - .github/workflows/release-blog-scaffold.yml - .github/scripts/scaffold-release-post.mjs - .github/release-blog-template.tsx.tmpl The 7 historical release posts are removed from the sencho-website repo in a separate PR. --- .github/release-blog-template.tsx.tmpl | 28 -- .github/scripts/scaffold-release-post.mjs | 408 -------------------- .github/workflows/release-blog-scaffold.yml | 97 ----- 3 files changed, 533 deletions(-) delete mode 100644 .github/release-blog-template.tsx.tmpl delete mode 100644 .github/scripts/scaffold-release-post.mjs delete mode 100644 .github/workflows/release-blog-scaffold.yml 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."