Skip to content

Commit f0de7bc

Browse files
authored
ci: inject PECU-Channel badge into GitHub release notes
Add a GitHub Actions workflow that reads the `PECU-Channel:` metadata from the release description and injects a standardized Shields.io badge. Key points: - Runs on release publish/edit events, on a scheduled interval, and via manual dispatch. - Inserts or updates a dedicated badge block between fixed markers to keep changes strictly scoped and idempotent. - Skips releases that do not declare `PECU-Channel:` (no metadata, no mutation). - Prevents self-trigger loops when the workflow updates the release body (guard conditions to avoid re-processing bot-driven edits). This keeps release notes consistent and makes channel status (Stable/Beta/Preview/ Experimental/Deprecated) visible at a glance without manual edits.
1 parent f906031 commit f0de7bc

File tree

1 file changed

+169
-0
lines changed

1 file changed

+169
-0
lines changed
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
name: PECU Release Channel Badge Sync
2+
3+
on:
4+
release:
5+
types: [published, edited]
6+
schedule:
7+
# Every 6 hours (adjust as needed)
8+
- cron: "0 */6 * * *"
9+
workflow_dispatch: {}
10+
11+
permissions:
12+
contents: write
13+
14+
concurrency:
15+
group: pecu-release-channel-badge-sync
16+
cancel-in-progress: true
17+
18+
jobs:
19+
sync:
20+
# Avoid loops only for release-triggered runs caused by the bot itself.
21+
if: ${{ github.event_name != 'release' || github.actor != 'github-actions[bot]' }}
22+
runs-on: ubuntu-latest
23+
24+
steps:
25+
- name: Sync channel badges in release notes
26+
uses: actions/github-script@v7
27+
with:
28+
script: |
29+
const core = require("@actions/core");
30+
31+
const owner = context.repo.owner;
32+
const repo = context.repo.repo;
33+
34+
const START = "<!-- PECU_CHANNEL_BADGE_START -->";
35+
const END = "<!-- PECU_CHANNEL_BADGE_END -->";
36+
37+
const palette = {
38+
stable: { label: "Stable", color: "2E8B57" },
39+
beta: { label: "Beta", color: "1E90FF" },
40+
preview: { label: "Preview", color: "F59E0B" },
41+
experimental: { label: "Experimental", color: "FF8C00" },
42+
nightly: { label: "Nightly", color: "8B5CF6" },
43+
deprecated: { label: "Deprecated", color: "6B7280" },
44+
};
45+
46+
function normalizeChannel(raw) {
47+
if (!raw) return null;
48+
let v = raw.trim();
49+
50+
// Common typos / variants
51+
const lower = v.toLowerCase();
52+
if (lower === "deprecates") v = "Deprecated";
53+
if (lower === "depreciated") v = "Deprecated";
54+
55+
return v;
56+
}
57+
58+
function buildBadgeBlock(channelRaw) {
59+
const channelKey = channelRaw.toLowerCase();
60+
const picked = palette[channelKey] || { label: channelRaw, color: "6B7280" };
61+
62+
const labelEnc = encodeURIComponent(picked.label.replace(/\s+/g, " "));
63+
const badgeUrl =
64+
`https://img.shields.io/badge/PECU%20Channel-${labelEnc}-${picked.color}` +
65+
`?style=for-the-badge&logo=github&logoColor=white`;
66+
67+
return [
68+
START,
69+
`<p align="center">`,
70+
` <img src="${badgeUrl}" alt="PECU Channel: ${picked.label}">`,
71+
`</p>`,
72+
END,
73+
].join("\n");
74+
}
75+
76+
function upsertBadge(body, badgeBlock) {
77+
const text = body || "";
78+
79+
const hasMarkers = text.includes(START) && text.includes(END);
80+
81+
if (hasMarkers) {
82+
const re = new RegExp(`${START}[\\s\\S]*?${END}`, "m");
83+
return text.replace(re, badgeBlock);
84+
}
85+
86+
// Append at bottom with a divider to keep it visually clean
87+
const trimmed = text.replace(/\s+$/g, "");
88+
return `${trimmed}\n\n---\n\n${badgeBlock}\n`;
89+
}
90+
91+
function extractChannel(body) {
92+
const text = body || "";
93+
// Read from anywhere in body (including inside <!-- --> blocks)
94+
const match = text.match(/PECU-Channel:\s*([^\n\r]+)/i);
95+
if (!match) return null;
96+
return normalizeChannel(match[1]);
97+
}
98+
99+
async function processRelease(release) {
100+
const body = release.body || "";
101+
const channel = extractChannel(body);
102+
103+
if (!channel) {
104+
core.info(`Release ${release.tag_name}: PECU-Channel not found. Skipping.`);
105+
return { updated: false, reason: "no-channel" };
106+
}
107+
108+
const badgeBlock = buildBadgeBlock(channel);
109+
const nextBody = upsertBadge(body, badgeBlock);
110+
111+
if (nextBody === body) {
112+
core.info(`Release ${release.tag_name}: already up to date.`);
113+
return { updated: false, reason: "no-change" };
114+
}
115+
116+
await github.rest.repos.updateRelease({
117+
owner,
118+
repo,
119+
release_id: release.id,
120+
body: nextBody,
121+
});
122+
123+
core.info(`Release ${release.tag_name}: updated badge -> ${channel}`);
124+
return { updated: true, reason: "updated" };
125+
}
126+
127+
async function listAllReleases() {
128+
const releases = [];
129+
let page = 1;
130+
131+
while (true) {
132+
const res = await github.rest.repos.listReleases({
133+
owner,
134+
repo,
135+
per_page: 100,
136+
page,
137+
});
138+
139+
if (!res.data || res.data.length === 0) break;
140+
141+
releases.push(...res.data);
142+
if (res.data.length < 100) break;
143+
page += 1;
144+
}
145+
146+
return releases;
147+
}
148+
149+
// Mode selection
150+
if (context.eventName === "release") {
151+
const release = context.payload.release;
152+
await processRelease(release);
153+
return;
154+
}
155+
156+
// Scheduled / manual: scan everything
157+
const releases = await listAllReleases();
158+
core.info(`Scanning ${releases.length} releases...`);
159+
160+
let updatedCount = 0;
161+
let skippedNoChannel = 0;
162+
163+
for (const r of releases) {
164+
const result = await processRelease(r);
165+
if (result.updated) updatedCount += 1;
166+
if (result.reason === "no-channel") skippedNoChannel += 1;
167+
}
168+
169+
core.info(`Done. Updated: ${updatedCount}. Skipped (no channel): ${skippedNoChannel}.`);

0 commit comments

Comments
 (0)