mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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: (
|
||||
<Prose>
|
||||
<p>
|
||||
TODO: write a short narrative introduction that highlights the most
|
||||
user-visible change across versions __COVERED_VERSIONS__. Link the
|
||||
relevant docs section at{' '}
|
||||
<a href="https://docs.sencho.io/">https://docs.sencho.io</a>.
|
||||
This rollup covers __COVERED_VERSIONS_PROSE__. Head to the{' '}
|
||||
<a href="https://docs.sencho.io/">Sencho docs</a> for setup guides and
|
||||
full feature walkthroughs.
|
||||
</p>
|
||||
|
||||
<h2>Highlight</h2>
|
||||
<p>
|
||||
TODO: embed at least one screenshot of the headline feature.
|
||||
</p>
|
||||
<img
|
||||
src="/screenshots/__SLUG__.png"
|
||||
alt="TODO describe the screenshot"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<h2>What's in this release</h2>
|
||||
__ADDED_BLOCK__
|
||||
__FIXED_BLOCK__
|
||||
|
||||
@@ -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(','),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user