Skip to content

shaft-engine-release #109

shaft-engine-release

shaft-engine-release #109

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 }}"