From b5acfd8f58008164c3af1f7253f8cb61347c99c3 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 27 Apr 2026 15:38:01 -0400 Subject: [PATCH] feat(scaffold): fully automate release blog post publishing (#810) * feat(scaffold): fully automate release blog post publishing Eliminates the manual PR-review step from the release blog scaffold pipeline. The workflow now commits the generated post directly to sencho-website main. scaffold-release-post.mjs changes: - Remove renderPrBody and all body_file handling - Update findLastAnchor to accept any post with a version: field, not just category: release posts. This allows narrative retrospectives to anchor the 5-release window. - Add buildHeadline: generates a post title from the top two added items and the total item count, e.g. "Sencho v0.69: Custom OIDC provider, Trivy scanning, and 12 more" - Add buildDescription: auto-generates a ~160-char SEO description from the version range and top three added items - Add calcReadingTime: estimates reading time from changelog word count - Add coveredVersionsProse: formats "v0.65.0 through v0.69.0" - Add updateMeta: inserts the new post into src/data/blog/meta.ts for Worker-side SEO meta injection - Remove the tmpdir/os import (no longer needed) release-blog-template.tsx.tmpl changes: - Replace all TODO placeholders with template variables (__TITLE__, __DESCRIPTION__, __READING_TIME__, __COVERED_VERSIONS_PROSE__) - Remove the screenshot placeholder section (auto-generated posts have no screenshot; the intro links to docs.sencho.io instead) release-blog-scaffold.yml changes: - Remove permission-pull-requests: write from app token (no PR created) - Replace "Commit and open draft PR" step with a simple git commit + push to sencho-website main. No duplicate-check step needed. - Include src/data/blog/meta.ts in the git add so the Worker SEO mapping stays in sync automatically. * fix(ci): use escapeTsxString in updateMeta to handle backslashes in blog post title and description CodeQL flagged two high-severity alerts: title and description written into meta.ts via updateMeta were only single-quote-escaped, leaving raw backslashes unescaped. A value containing a backslash followed by a special character would produce a malformed escape sequence in the generated TypeScript source. The escapeTsxString helper already handles both cases; use it consistently instead of an open-coded replace. --- .github/release-blog-template.tsx.tmpl | 24 +--- .github/scripts/scaffold-release-post.mjs | 148 +++++++++++++++----- .github/workflows/release-blog-scaffold.yml | 39 +----- 3 files changed, 128 insertions(+), 83 deletions(-) diff --git a/.github/release-blog-template.tsx.tmpl b/.github/release-blog-template.tsx.tmpl index 10779520..d702998c 100644 --- a/.github/release-blog-template.tsx.tmpl +++ b/.github/release-blog-template.tsx.tmpl @@ -4,33 +4,21 @@ import type { BlogPost } from '../types' export const __EXPORT_NAME__: BlogPost = { slug: '__SLUG__', - title: 'v__VERSION__: TODO headline', - description: - 'TODO: SEO description, about 160 characters, covering the headline feature of this rollup.', + title: '__TITLE__', + description: '__DESCRIPTION__', date: '__DATE__', category: 'release', version: '__VERSION__', tags: ['release'], - readingTime: 'TODO min read', + readingTime: '__READING_TIME__', content: (

- TODO: write a short narrative introduction that highlights the most - user-visible change across versions __COVERED_VERSIONS__. Link the - relevant docs section at{' '} - https://docs.sencho.io. + This rollup covers __COVERED_VERSIONS_PROSE__. Head to the{' '} + Sencho docs for setup guides and + full feature walkthroughs.

-

Highlight

-

- TODO: embed at least one screenshot of the headline feature. -

- TODO describe the screenshot -

What's in this release

__ADDED_BLOCK__ __FIXED_BLOCK__ diff --git a/.github/scripts/scaffold-release-post.mjs b/.github/scripts/scaffold-release-post.mjs index 33b50433..77c525c9 100644 --- a/.github/scripts/scaffold-release-post.mjs +++ b/.github/scripts/scaffold-release-post.mjs @@ -3,10 +3,12 @@ // 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 } from 'node:fs' -import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' @@ -67,7 +69,8 @@ function findLastAnchor(websiteRoot) { const versions = [] for (const f of files) { const body = readFileSync(join(postsDir, f), 'utf8') - if (!/category:\s*['"]release['"]/m.test(body)) continue + // 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]) } @@ -96,8 +99,7 @@ function windowSinceAnchor(tags, anchor, tag) { 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; a human will still review the resulting PR. + // 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) } @@ -179,14 +181,84 @@ 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(), - __COVERED_VERSIONS__: coveredVersions.join(', '), + __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), @@ -224,23 +296,23 @@ function updateIndex(websiteRoot, exportName, fileBase) { return `${before}${arrOpen}${newBody}${arrClose}${after}` } -function renderPrBody(tag, version, coveredVersions) { - const screenshotName = `${slugFor(version)}.png` - return [ - `Scaffolded automatically from the \`${tag}\` tag on the Sencho repo.`, - `Covers versions: \`${coveredVersions.join(', ')}\`.`, - '', - '## Before marking ready', - '', - '- [ ] Write the narrative intro (replace the TODO paragraph)', - '- [ ] Set the title to something user-facing (replace `TODO headline`)', - '- [ ] Fill in the SEO description (~160 chars)', - `- [ ] Capture a headline screenshot and drop it at \`public/screenshots/${screenshotName}\``, - '- [ ] Add at least one link to https://docs.sencho.io/', - '- [ ] Finalize `readingTime`', - '- [ ] Review the grouped changelog items, trim or rephrase any that read too much like commit messages', - '', +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) { @@ -283,39 +355,49 @@ function main() { `Collected items: added=${grouped.added.length}, fixed=${grouped.fixed.length}, changed=${grouped.changed.length}`, ) - const post = renderPost(newVersion, windowVersions, grouped) + const exportName = exportNameFor(newVersion) + const slug = slugFor(newVersion) const date = todayIso() - const fileBase = `${date}-${slugFor(newVersion)}` + 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 newIndex = updateIndex(args.website, exportNameFor(newVersion), fileBase) + const metaPath = join(args.website, 'src', 'data', 'blog', 'meta.ts') - const prBody = renderPrBody(args.tag, newVersion, windowVersions) - const bodyFile = join(process.env.RUNNER_TEMP ?? tmpdir(), 'release-post-pr-body.md') + 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) - console.log('\n--- PR body (dry-run): ' + bodyFile) - console.log(prBody) + if (newMeta) { + console.log('\n--- meta.ts (dry-run): ' + metaPath) + console.log(newMeta) + } } else { writeFileSync(postPath, post, 'utf8') writeFileSync(indexPath, newIndex, 'utf8') - writeFileSync(bodyFile, prBody, 'utf8') + if (newMeta) writeFileSync(metaPath, newMeta, 'utf8') console.log(`Wrote ${postPath}`) console.log(`Updated ${indexPath}`) - console.log(`Wrote PR body: ${bodyFile}`) + if (newMeta) console.log(`Updated ${metaPath}`) } writeGithubOutput({ scaffold: 'true', version: newVersion, - slug: slugFor(newVersion), - branch: `release-post/${args.tag}`, + slug, post_path: `src/data/blog/posts/${fileBase}.tsx`, - body_file: bodyFile, covered: windowVersions.join(','), }) } diff --git a/.github/workflows/release-blog-scaffold.yml b/.github/workflows/release-blog-scaffold.yml index a076326f..00de955f 100644 --- a/.github/workflows/release-blog-scaffold.yml +++ b/.github/workflows/release-blog-scaffold.yml @@ -1,10 +1,9 @@ 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 release post's -# `version` field in the sencho-website repo). If yes, opens a DRAFT PR in -# sencho-website containing a pre-filled blog post scaffold; a human writes -# the narrative, adds the screenshot, and marks the PR ready. +# 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: @@ -37,7 +36,6 @@ jobs: Sencho sencho-website permission-contents: write - permission-pull-requests: write - name: Resolve target tag id: target @@ -79,42 +77,19 @@ jobs: --changelog CHANGELOG.md \ --website website-repo - - name: Commit and open draft PR in sencho-website + - name: Commit and push release post to sencho-website if: steps.scaffold.outputs.scaffold == 'true' env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.target.outputs.tag }} - BRANCH: ${{ steps.scaffold.outputs.branch }} POST_PATH: ${{ steps.scaffold.outputs.post_path }} - BODY_FILE: ${{ steps.scaffold.outputs.body_file }} - OWNER: ${{ github.repository_owner }} run: | set -euo pipefail cd website-repo - # Short-circuit if a scaffold PR for this branch already exists (e.g., - # a manual workflow_dispatch retry against the same tag). - existing=$(gh pr list \ - --repo "${OWNER}/sencho-website" \ - --head "${BRANCH}" \ - --state open \ - --json number --jq '.[0].number // empty') - if [ -n "${existing}" ]; then - echo "PR already exists for ${BRANCH} (#${existing}); nothing to do." - exit 0 - fi git config user.name "sencho-release-bot[bot]" git config user.email "sencho-release-bot[bot]@users.noreply.github.com" - git checkout -B "${BRANCH}" - git add "${POST_PATH}" src/data/blog/index.ts - git commit -m "chore: scaffold release post for ${TAG}" - git push -u origin "${BRANCH}" --force-with-lease - gh pr create \ - --repo "${OWNER}/sencho-website" \ - --base main \ - --head "${BRANCH}" \ - --title "chore: scaffold release post for ${TAG}" \ - --body-file "${BODY_FILE}" \ - --draft + 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'