shaft-engine-release #109
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Automated Release Blog Post | |
| on: | |
| repository_dispatch: | |
| types: [shaft-engine-release] | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: 'Optional SHAFT_ENGINE tag to generate a post for (example: v8.8.1)' | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: automated-release-blog-post-${{ github.event.client_payload.tag_name || github.event.client_payload.version || github.event.inputs.release_tag || format('manual-{0}', github.run_id) }} | |
| cancel-in-progress: false | |
| jobs: | |
| generate-release-blog-post: | |
| name: Generate Release Blog Post | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 20 | |
| - name: Fetch release metadata and contributors | |
| id: release_data | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| RELEASE_TAG_INPUT: ${{ github.event.inputs.release_tag }} | |
| with: | |
| script: | | |
| // Resolve SHAFT_ENGINE release metadata and contributor details. | |
| const SOURCE_OWNER = 'ShaftHQ'; | |
| const SOURCE_REPO = 'SHAFT_ENGINE'; | |
| const sourceRepoFullName = `${SOURCE_OWNER}/${SOURCE_REPO}`; | |
| const releaseTagInput = process.env.RELEASE_TAG_INPUT; | |
| let release; | |
| const fetchReleaseByTag = async (tagName) => { | |
| const { data } = await github.rest.repos.getReleaseByTag({ | |
| owner: SOURCE_OWNER, | |
| repo: SOURCE_REPO, | |
| tag: tagName | |
| }); | |
| return data; | |
| }; | |
| // 1) repository_dispatch path: prefer tag from client_payload. | |
| if (!release && context.eventName === 'repository_dispatch') { | |
| const dispatchedTag = | |
| context.payload.client_payload?.tag_name || context.payload.client_payload?.version; | |
| if (dispatchedTag) { | |
| try { | |
| release = await fetchReleaseByTag(dispatchedTag); | |
| } catch (error) { | |
| core.warning( | |
| `Could not resolve dispatched tag "${dispatchedTag}" from ${sourceRepoFullName}. Falling back to latest release.` | |
| ); | |
| } | |
| } | |
| } | |
| // 2) Manual path (workflow_dispatch): optional tag input. | |
| if (!release && context.eventName === 'workflow_dispatch' && releaseTagInput) { | |
| try { | |
| release = await fetchReleaseByTag(releaseTagInput); | |
| } catch (error) { | |
| core.warning( | |
| `Could not resolve requested tag "${releaseTagInput}" from ${sourceRepoFullName}. Falling back to latest release.` | |
| ); | |
| } | |
| } | |
| // 3) Fallback path: latest official SHAFT_ENGINE release. | |
| if (!release) { | |
| const latest = await github.rest.repos.getLatestRelease({ | |
| owner: SOURCE_OWNER, | |
| repo: SOURCE_REPO | |
| }); | |
| release = latest.data; | |
| } | |
| if (!release) { | |
| core.setFailed('No release payload found.'); | |
| return; | |
| } | |
| const owner = SOURCE_OWNER; | |
| const repo = SOURCE_REPO; | |
| const releaseAuthor = release.author?.login || 'unknown'; | |
| const tagName = release.tag_name || 'untagged-release'; | |
| const releaseBody = release.body || ''; | |
| const releaseId = release.id; | |
| const releasePublishedAt = release.published_at || new Date().toISOString(); | |
| const releaseUrl = release.html_url || `https://github.com/${owner}/${repo}/releases/tag/${tagName}`; | |
| const contributorsData = await github.paginate(github.rest.repos.listContributors, { | |
| owner, | |
| repo, | |
| per_page: 100 | |
| }); | |
| const releasesData = await github.paginate(github.rest.repos.listReleases, { | |
| owner, | |
| repo, | |
| per_page: 100 | |
| }); | |
| const sortedReleases = releasesData | |
| .filter((item) => item?.tag_name && !item.draft) | |
| .sort((a, b) => new Date(b.published_at || 0) - new Date(a.published_at || 0)); | |
| const currentReleaseIndex = sortedReleases.findIndex((item) => item.id === releaseId); | |
| const previousRelease = currentReleaseIndex >= 0 ? sortedReleases[currentReleaseIndex + 1] : null; | |
| const releaseCommitCounts = {}; | |
| if (previousRelease?.tag_name) { | |
| try { | |
| const compareResp = await github.rest.repos.compareCommits({ | |
| owner, | |
| repo, | |
| base: previousRelease.tag_name, | |
| head: tagName | |
| }); | |
| for (const commit of compareResp.data.commits || []) { | |
| const login = commit.author?.login; | |
| if (!login) continue; | |
| releaseCommitCounts[login] = (releaseCommitCounts[login] || 0) + 1; | |
| } | |
| } catch (error) { | |
| core.warning( | |
| `Could not compare ${previousRelease.tag_name}...${tagName}: ${error.message}. Contributor commit counts will be unavailable.` | |
| ); | |
| } | |
| } | |
| const contributors = contributorsData | |
| .filter((contributor) => contributor?.type === 'User' && contributor?.login) | |
| .map((contributor) => ({ | |
| login: contributor.login, | |
| avatarUrl: `https://github.com/${contributor.login}.png`, | |
| contributions: contributor.contributions || 0, | |
| commitsInThisRelease: releaseCommitCounts[contributor.login] || 0, | |
| isFirstTimer: | |
| Boolean(releaseCommitCounts[contributor.login]) && | |
| (contributor.contributions || 0) === (releaseCommitCounts[contributor.login] || 0) | |
| })); | |
| core.setOutput('tag', tagName); | |
| core.setOutput('published_at', releasePublishedAt); | |
| core.setOutput('author', releaseAuthor); | |
| core.setOutput('release_body', releaseBody); | |
| core.setOutput('release_url', releaseUrl); | |
| core.setOutput('contributors_json', JSON.stringify(contributors)); | |
| - name: Check if blog post already exists on master | |
| id: blog_post_exists | |
| env: | |
| RELEASE_TAG: ${{ steps.release_data.outputs.tag }} | |
| run: | | |
| git fetch origin master | |
| safe_tag="$(echo "${RELEASE_TAG:-untagged-release}" | sed -E 's/^[vV]//; s/[^a-zA-Z0-9._-]+/-/g; s/-+/-/g' | tr '[:upper:]' '[:lower:]')" | |
| echo "safe_tag=$safe_tag" >> "$GITHUB_OUTPUT" | |
| existing_path="$(git ls-tree -r --name-only origin/master "blog/*-release-${safe_tag}.md" | head -n 1)" | |
| if [ -n "$existing_path" ]; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| echo "path=$existing_path" >> "$GITHUB_OUTPUT" | |
| echo "::notice title=Release blog post exists::Skipping PR creation because ${existing_path} already exists on master." | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Generate release blog post markdown | |
| if: steps.blog_post_exists.outputs.exists != 'true' | |
| env: | |
| RELEASE_TAG: ${{ steps.release_data.outputs.tag }} | |
| RELEASE_PUBLISHED_AT: ${{ steps.release_data.outputs.published_at }} | |
| RELEASE_AUTHOR: ${{ steps.release_data.outputs.author }} | |
| RELEASE_BODY: ${{ steps.release_data.outputs.release_body }} | |
| RELEASE_URL: ${{ steps.release_data.outputs.release_url }} | |
| CONTRIBUTORS_JSON: ${{ steps.release_data.outputs.contributors_json }} | |
| run: | | |
| node <<'NODE' | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const tag = process.env.RELEASE_TAG || 'untagged-release'; | |
| const publishedAt = process.env.RELEASE_PUBLISHED_AT || new Date().toISOString(); | |
| const author = process.env.RELEASE_AUTHOR || 'unknown'; | |
| const releaseBody = process.env.RELEASE_BODY || '_No changelog provided._'; | |
| const releaseUrl = process.env.RELEASE_URL || ''; | |
| const contributors = JSON.parse(process.env.CONTRIBUTORS_JSON || '[]'); | |
| const engineVersion = tag.replace(/^v/i, ''); | |
| const releaseDataPath = path.join(process.cwd(), 'src', 'data', 'releases.json'); | |
| const currentReleaseData = fs.existsSync(releaseDataPath) | |
| ? JSON.parse(fs.readFileSync(releaseDataPath, 'utf8')) | |
| : { archetypeVersion: '10.1.20260331', javaVersion: '25', mavenVersion: '3.9' }; | |
| fs.mkdirSync(path.dirname(releaseDataPath), { recursive: true }); | |
| fs.writeFileSync( | |
| releaseDataPath, | |
| `${JSON.stringify({...currentReleaseData, engineVersion}, null, 2)}\n`, | |
| 'utf8' | |
| ); | |
| const date = new Date(publishedAt); | |
| const yyyy = date.getUTCFullYear(); | |
| const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); | |
| const dd = String(date.getUTCDate()).padStart(2, '0'); | |
| const safeTag = tag | |
| .replace(/^v/i, '') | |
| .replace(/[^a-zA-Z0-9._-]/g, '-') | |
| .replace(/-+/g, '-') | |
| .toLowerCase(); | |
| const fileName = `${yyyy}-${mm}-${dd}-release-${safeTag}.md`; | |
| const filePath = path.join(process.cwd(), 'blog', fileName); | |
| const firstTimers = contributors.filter((contributor) => contributor.isFirstTimer); | |
| const contributorCount = contributors.length; | |
| const firstTimerCount = firstTimers.length; | |
| const contributorGrid = contributorCount | |
| ? contributors | |
| .map((contributor) => { | |
| const firstTimerBadge = contributor.isFirstTimer ? ' 🌟 **First-time contributor!**' : ''; | |
| return `- <img src="${contributor.avatarUrl}" width="32" height="32" alt="@${contributor.login}" /> [@${contributor.login}](https://github.com/${contributor.login})${firstTimerBadge}`; | |
| }) | |
| .join('\n') | |
| : '- No contributor data was available for this release.'; | |
| const firstTimerHighlight = firstTimerCount | |
| ? `\n🎉 **A warm welcome to our first-time contributors!** ${firstTimers.map((c) => `[@${c.login}](https://github.com/${c.login})`).join(', ')} — your first contribution is now part of SHAFT's story. We're thrilled to have you on board!` | |
| : ''; | |
| const extractScopeSummary = (markdownText) => { | |
| const sanitizedMarkdownText = markdownText.replace(/<!--[\s\S]*?-->/g, ''); | |
| const candidateLines = sanitizedMarkdownText | |
| .split('\n') | |
| .map((line) => line.trim()) | |
| .filter((line) => line && !line.startsWith('#')) | |
| .map((line) => | |
| line | |
| .replace(/<[^>]+>/g, ' ') | |
| .replace(/!\[[^\]]*\]\([^)]+\)/g, '') | |
| .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') | |
| .replace(/^[-*+]\s*/, '') | |
| .replace(/^>\s*/, '') | |
| .replace(/[*_`#]/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| ) | |
| // Guard against incomplete HTML fragments that can break MDX (e.g., "<div ..." or "...>"). | |
| .filter((line) => line && !/^<[^>]*$/.test(line) && !/^[^<]*>$/.test(line)) | |
| .filter(Boolean); | |
| if (!candidateLines.length) { | |
| return 'This version focuses on improving developer productivity, strengthening runtime reliability, and making test design more expressive across common automation scenarios.'; | |
| } | |
| return candidateLines.slice(0, 5).map((line) => `- ${line}`).join('\n'); | |
| }; | |
| const scopeSummary = extractScopeSummary(releaseBody); | |
| const communityStats = contributorCount | |
| ? `This release was made possible by **${contributorCount} amazing contributors** who have poured their time and expertise into making SHAFT better for everyone.${firstTimerCount ? ` Among them, **${firstTimerCount}** contributed for the very first time!` : ''}` | |
| : 'This release was crafted with care by the SHAFT community.'; | |
| const blogPost = ` | |
| --- | |
| slug: release-${safeTag} | |
| title: "🚀 SHAFT ${tag}: What's New and Why You Should Upgrade Today" | |
| authors: [autobot] | |
| tags: [shaft_engine, release, changelog, test-automation, open-source] | |
| --- | |
| Hey there, SHAFT community! 👋 | |
| A fresh release just dropped and it's packed with improvements you don't want to miss. **SHAFT ${tag}** is here, and whether you're a seasoned SHAFT user or just getting started, there's something in this release for you. | |
| <!-- truncate --> | |
| ## ⚡ What Changed? | |
| ${scopeSummary} | |
| ${releaseUrl ? `👉 [View the full release on GitHub](${releaseUrl})` : ''} | |
| ## 📋 Changelog | |
| ${releaseBody} | |
| ## 🏆 Community Spotlight | |
| ${communityStats} | |
| Open source thrives because of people like these. Every bug fix, every feature, every review — it all counts. | |
| ${contributorGrid} | |
| ${firstTimerHighlight} | |
| ## 🚀 Get Started in Seconds | |
| Import the SHAFT BOM once, then use the modular engine: | |
| \`\`\`xml | |
| <dependencyManagement> | |
| <dependencies> | |
| <dependency> | |
| <groupId>io.github.shafthq</groupId> | |
| <artifactId>shaft-bom</artifactId> | |
| <version>${engineVersion}</version> | |
| <type>pom</type> | |
| <scope>import</scope> | |
| </dependency> | |
| </dependencies> | |
| </dependencyManagement> | |
| <dependencies> | |
| <dependency> | |
| <groupId>io.github.shafthq</groupId> | |
| <artifactId>shaft-engine</artifactId> | |
| </dependency> | |
| </dependencies> | |
| \`\`\` | |
| ## 💬 Join the Conversation | |
| We'd love to hear what you think! Found a bug? Have an idea? Just want to say hi? | |
| - ⭐ [Star SHAFT on GitHub](https://github.com/ShaftHQ/SHAFT_ENGINE) — it helps more than you think | |
| - 💡 [Start a Discussion](https://github.com/ShaftHQ/SHAFT_ENGINE/discussions) — share feedback or ideas | |
| - 🐛 [Report an Issue](https://github.com/ShaftHQ/SHAFT_ENGINE/issues/new) — help us squash bugs faster | |
| - 📖 [Read the Docs](https://shafthq.github.io/docs/start/overview) — dive deeper into SHAFT | |
| Thanks for being part of the SHAFT journey. Until the next release — happy testing! 🎯 | |
| ` | |
| .replace(/^ {10}/gm, '') | |
| .trimStart(); | |
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | |
| if (fs.existsSync(filePath)) { | |
| const existingPost = fs.readFileSync(filePath, 'utf8'); | |
| if (existingPost === blogPost) { | |
| console.log(`Blog post already up to date for ${tag}: ${filePath}`); | |
| process.exit(0); | |
| } | |
| fs.writeFileSync(filePath, blogPost, 'utf8'); | |
| console.log(`Updated existing blog post at ${filePath}`); | |
| process.exit(0); | |
| } | |
| fs.writeFileSync(filePath, blogPost, 'utf8'); | |
| console.log(`Created blog post at ${filePath}`); | |
| NODE | |
| - name: Create pull request for generated release blog post | |
| if: steps.blog_post_exists.outputs.exists != 'true' | |
| id: cpr | |
| uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 | |
| with: | |
| commit-message: 'docs(blog): add automated release blog post for ${{ steps.release_data.outputs.tag }}' | |
| title: 'docs(blog): add automated release blog post for ${{ steps.release_data.outputs.tag }}' | |
| branch: 'automation/release-blog-post-${{ steps.blog_post_exists.outputs.safe_tag }}' | |
| delete-branch: true | |
| labels: | | |
| automated-release | |
| blog | |
| dependencies | |
| body: | | |
| ## 🤖 Automated SHAFT_ENGINE Release Blog Post | |
| This PR was automatically generated by the `automated-release-blog-post` workflow. | |
| | Field | Value | | |
| |---|---| | |
| | Release Tag | `${{ steps.release_data.outputs.tag }}` | | |
| | Published At | `${{ steps.release_data.outputs.published_at }}` | | |
| | Release URL | ${{ steps.release_data.outputs.release_url }} | | |
| | Trigger | `${{ github.event_name }}` | | |
| ### Notes | |
| - This workflow creates a PR instead of pushing directly to `master` (branch protection compatible). | |
| - If a blog post for this tag already exists on `master`, PR creation is skipped. | |
| - name: Log created pull request URL | |
| if: steps.cpr.outputs.pull-request-url != '' | |
| run: echo "::notice title=Release blog post PR created::${{ steps.cpr.outputs.pull-request-url }}" |