Skip to content

Commit 28cc958

Browse files
authored
ci(release): sync PECU channel badge into release notes (fix github-script scope)
Add a GitHub Actions workflow that reads `PECU-Channel:` metadata from the release description and injects a standardized Shields.io badge. Corrections included: - Fix `SyntaxError: Identifier 'core' has already been declared` by relying on github-script provided globals (core/github/context) instead of re-importing. - Runs on release publish/edit events, plus scheduled scans and manual dispatch. - Inserts or updates the badge block between fixed markers. - Skips releases without `PECU-Channel` metadata. - Avoids self-trigger loops by ignoring release events authored by github-actions[bot] and by performing no-op updates when the body is unchanged. - Supports `workflow_dispatch` input to process a single tag or all releases.
1 parent f0de7bc commit 28cc958

File tree

1 file changed

+110
-94
lines changed

1 file changed

+110
-94
lines changed

.github/workflows/pecu-release-channel-badge.yml

Lines changed: 110 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -4,166 +4,182 @@ on:
44
release:
55
types: [published, edited]
66
schedule:
7-
# Every 6 hours (adjust as needed)
8-
- cron: "0 */6 * * *"
9-
workflow_dispatch: {}
7+
- cron: "17 */6 * * *"
8+
workflow_dispatch:
9+
inputs:
10+
tag:
11+
description: "Optional: process only this tag (e.g. v2026.01.05). Leave empty to scan all releases."
12+
required: false
13+
type: string
1014

1115
permissions:
1216
contents: write
1317

1418
concurrency:
15-
group: pecu-release-channel-badge-sync
16-
cancel-in-progress: true
19+
group: pecu-release-channel-badge
20+
cancel-in-progress: false
1721

1822
jobs:
1923
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]' }}
2224
runs-on: ubuntu-latest
2325

26+
# Prevent self-trigger loops for release events caused by github-actions[bot] updating the body
27+
if: ${{ !(github.event_name == 'release' && github.actor == 'github-actions[bot]') }}
28+
2429
steps:
25-
- name: Sync channel badges in release notes
30+
- name: Sync PECU channel badge into release body
2631
uses: actions/github-script@v7
2732
with:
2833
script: |
29-
const core = require("@actions/core");
30-
34+
// NOTE: github-script provides `core`, `github`, `context` already. Do NOT redeclare them.
3135
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;
36+
const repo = context.repo.repo;
37+
38+
const START = '<!-- PECU-CHANNEL-BADGE:START -->';
39+
const END = '<!-- PECU-CHANNEL-BADGE:END -->';
40+
41+
function channelStyle(channelRaw) {
42+
const channel = String(channelRaw || '').trim();
43+
const key = channel.toLowerCase();
44+
45+
const map = {
46+
stable: { label: 'Stable', color: '2E8B57' },
47+
beta: { label: 'Beta', color: 'F59E0B' },
48+
preview: { label: 'Preview', color: '1E90FF' },
49+
experimental: { label: 'Experimental', color: 'B91C1C' },
50+
nightly: { label: 'Nightly', color: '111827' },
51+
deprecated: { label: 'Deprecated', color: '6A737D' },
52+
};
53+
54+
return map[key] || { label: channel || 'Unknown', color: '6A737D' };
5655
}
5756
5857
function buildBadgeBlock(channelRaw) {
59-
const channelKey = channelRaw.toLowerCase();
60-
const picked = palette[channelKey] || { label: channelRaw, color: "6B7280" };
58+
const { label, color } = channelStyle(channelRaw);
59+
60+
const subject = encodeURIComponent('PECU Channel');
61+
const status = encodeURIComponent(label);
6162
62-
const labelEnc = encodeURIComponent(picked.label.replace(/\s+/g, " "));
6363
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");
64+
`https://img.shields.io/badge/${subject}-${status}-${color}?style=for-the-badge`;
65+
66+
return `${START}
67+
<p align="center">
68+
<img src="${badgeUrl}" alt="PECU Channel: ${label}">
69+
</p>
70+
${END}`;
7471
}
7572
76-
function upsertBadge(body, badgeBlock) {
77-
const text = body || "";
73+
function extractChannel(body) {
74+
if (!body) return null;
75+
76+
// Prefer parsing from the metadata comment block
77+
// <!-- ... PECU-Channel: Stable ... -->
78+
const m1 = body.match(/^\s*<!--[\s\S]*?\bPECU-Channel:\s*([^\r\n]+)[\s\S]*?-->/m);
79+
if (m1) return m1[1].trim();
80+
81+
// Fallback: anywhere in body
82+
const m2 = body.match(/\bPECU-Channel:\s*([^\r\n]+)/i);
83+
return m2 ? m2[1].trim() : null;
84+
}
7885
79-
const hasMarkers = text.includes(START) && text.includes(END);
86+
function injectOrUpdate(body, channel) {
87+
const block = buildBadgeBlock(channel);
8088
89+
if (!body) body = '';
90+
91+
const hasMarkers = body.includes(START) && body.includes(END);
8192
if (hasMarkers) {
82-
const re = new RegExp(`${START}[\\s\\S]*?${END}`, "m");
83-
return text.replace(re, badgeBlock);
93+
const re = new RegExp(`${START}[\\s\\S]*?${END}`, 'm');
94+
return body.replace(re, block);
8495
}
8596
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-
}
97+
// Insert right after the leading metadata comment block (if present)
98+
const metaRe = /^\s*<!--[\s\S]*?PECU-Channel:[\s\S]*?-->\s*/m;
99+
if (metaRe.test(body)) {
100+
return body.replace(metaRe, (m) => `${m}\n${block}\n\n`);
101+
}
90102
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]);
103+
// Fallback: prepend at top
104+
return `${block}\n\n${body}`;
97105
}
98106
99-
async function processRelease(release) {
100-
const body = release.body || "";
101-
const channel = extractChannel(body);
107+
async function updateRelease(release) {
108+
const currentBody = release.body || '';
109+
const channel = extractChannel(currentBody);
102110
103111
if (!channel) {
104-
core.info(`Release ${release.tag_name}: PECU-Channel not found. Skipping.`);
105-
return { updated: false, reason: "no-channel" };
112+
core.info(`Skip ${release.tag_name}: no PECU-Channel metadata`);
113+
return { updated: false, reason: 'no-channel' };
106114
}
107115
108-
const badgeBlock = buildBadgeBlock(channel);
109-
const nextBody = upsertBadge(body, badgeBlock);
116+
const nextBody = injectOrUpdate(currentBody, channel);
110117
111-
if (nextBody === body) {
112-
core.info(`Release ${release.tag_name}: already up to date.`);
113-
return { updated: false, reason: "no-change" };
118+
if (nextBody === currentBody) {
119+
core.info(`No change for ${release.tag_name}`);
120+
return { updated: false, reason: 'no-change' };
114121
}
115122
123+
core.info(`Updating ${release.tag_name} (channel: ${channel})`);
124+
116125
await github.rest.repos.updateRelease({
117126
owner,
118127
repo,
119128
release_id: release.id,
120129
body: nextBody,
121130
});
122131
123-
core.info(`Release ${release.tag_name}: updated badge -> ${channel}`);
124-
return { updated: true, reason: "updated" };
132+
return { updated: true, reason: 'updated' };
125133
}
126134
127135
async function listAllReleases() {
128-
const releases = [];
136+
const perPage = 100;
137+
const all = [];
129138
let page = 1;
130139
131-
while (true) {
140+
for (;;) {
132141
const res = await github.rest.repos.listReleases({
133142
owner,
134143
repo,
135-
per_page: 100,
144+
per_page: perPage,
136145
page,
137146
});
138147
139-
if (!res.data || res.data.length === 0) break;
148+
if (!res.data.length) break;
149+
150+
all.push(...res.data);
151+
if (res.data.length < perPage) break;
140152
141-
releases.push(...res.data);
142-
if (res.data.length < 100) break;
143153
page += 1;
144154
}
145155
146-
return releases;
156+
return all;
147157
}
148158
149-
// Mode selection
150-
if (context.eventName === "release") {
151-
const release = context.payload.release;
152-
await processRelease(release);
159+
// Case 1: release event => process only that release
160+
if (context.eventName === 'release' && context.payload && context.payload.release) {
161+
await updateRelease(context.payload.release);
153162
return;
154163
}
155164
156-
// Scheduled / manual: scan everything
157-
const releases = await listAllReleases();
158-
core.info(`Scanning ${releases.length} releases...`);
165+
// Case 2: workflow_dispatch => optional single tag, otherwise scan all
166+
const inputs = (context.payload && context.payload.inputs) ? context.payload.inputs : {};
167+
const tag = (inputs.tag || '').trim();
159168
160-
let updatedCount = 0;
161-
let skippedNoChannel = 0;
169+
if (tag) {
170+
const rel = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
171+
await updateRelease(rel.data);
172+
return;
173+
}
174+
175+
// Case 3: schedule / dispatch w/o tag => scan all releases
176+
const releases = await listAllReleases();
177+
core.info(`Scanning ${releases.length} release(s)…`);
162178
179+
let updated = 0;
163180
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;
181+
const res = await updateRelease(r);
182+
if (res.updated) updated += 1;
167183
}
168184
169-
core.info(`Done. Updated: ${updatedCount}. Skipped (no channel): ${skippedNoChannel}.`);
185+
core.info(`Done. Updated: ${updated}/${releases.length}`);

0 commit comments

Comments
 (0)