Skip to content

Commit 74cd08b

Browse files
authored
Add release notification workflow (#2884)
* Add release notification workflow * Run notification after Publish workflow completes * Add contents:read permission and tag resolution fallback * Use pagination for tag resolution fallback * Replace core.setFailed with throw Error
1 parent 3f06fa4 commit 74cd08b

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
name: Release Notification
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Publish"]
6+
types: [completed]
7+
8+
jobs:
9+
notify:
10+
runs-on: ubuntu-latest
11+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
12+
permissions:
13+
contents: read
14+
issues: write
15+
pull-requests: write
16+
env:
17+
TARGET_OWNER: koxudaxi
18+
TARGET_REPO: datamodel-code-generator
19+
steps:
20+
- name: Post release notifications
21+
uses: actions/github-script@v7
22+
with:
23+
github-token: ${{ secrets.GITHUB_TOKEN }}
24+
script: |
25+
// Get tag from the workflow run that triggered this
26+
let tag = context.payload.workflow_run.head_branch;
27+
28+
// Fallback: if head_branch is empty, resolve tag from head_sha
29+
if (!tag) {
30+
const headSha = context.payload.workflow_run.head_sha;
31+
console.log(`head_branch is empty, resolving tag from head_sha: ${headSha}`);
32+
33+
try {
34+
for await (const response of github.paginate.iterator(
35+
github.rest.repos.listTags,
36+
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
37+
)) {
38+
const matchingTag = response.data.find(t => t.commit.sha === headSha);
39+
if (matchingTag) {
40+
tag = matchingTag.name;
41+
console.log(`Resolved tag from sha: ${tag}`);
42+
break;
43+
}
44+
}
45+
} catch (e) {
46+
console.log(`Failed to resolve tag from sha: ${e.message}`);
47+
}
48+
}
49+
50+
if (!tag) {
51+
console.log('Could not determine tag, skipping notification');
52+
return;
53+
}
54+
55+
// Fetch release info for this tag
56+
let release;
57+
try {
58+
const { data } = await github.rest.repos.getReleaseByTag({
59+
owner: context.repo.owner,
60+
repo: context.repo.repo,
61+
tag: tag
62+
});
63+
release = data;
64+
} catch (e) {
65+
console.log(`No release found for tag ${tag}: ${e.message}`);
66+
return;
67+
}
68+
69+
const version = release.tag_name;
70+
const releaseUrl = release.html_url;
71+
const releaseBody = release.body || '';
72+
73+
// Target repo from environment variables (set in jobs.notify.env)
74+
const repoOwner = process.env.TARGET_OWNER;
75+
const repoName = process.env.TARGET_REPO;
76+
77+
// Validate environment variables
78+
if (!repoOwner || !repoName) {
79+
throw new Error('TARGET_OWNER and TARGET_REPO are required');
80+
}
81+
82+
// Verify we're running in the correct repo
83+
if (context.repo.owner !== repoOwner || context.repo.repo !== repoName) {
84+
console.log(`Skipping: running in ${context.repo.owner}/${context.repo.repo}, not ${repoOwner}/${repoName}`);
85+
return;
86+
}
87+
88+
// Helper: Get all comments with pagination
89+
async function getAllComments(issueNumber) {
90+
const comments = [];
91+
for await (const response of github.paginate.iterator(
92+
github.rest.issues.listComments,
93+
{ owner: repoOwner, repo: repoName, issue_number: issueNumber, per_page: 100 }
94+
)) {
95+
comments.push(...response.data);
96+
}
97+
return comments;
98+
}
99+
100+
// Extract PR numbers from release notes (this repo only, PRs only)
101+
const prNumbers = new Set();
102+
103+
// Pattern 1: Full PR URL (this repo only)
104+
const prUrlRegex = new RegExp(
105+
'https://github\\.com/' + repoOwner + '/' + repoName + '/pull/(\\d+)', 'gi'
106+
);
107+
let m;
108+
while ((m = prUrlRegex.exec(releaseBody)) !== null) {
109+
prNumbers.add(parseInt(m[1]));
110+
}
111+
112+
// Pattern 2: owner/repo#123 (this repo only) - verify it's a PR later
113+
const repoRefRegex = new RegExp(
114+
'(?:^|[\\s({\\[-])' + repoOwner + '/' + repoName + '#(\\d+)', 'gi'
115+
);
116+
while ((m = repoRefRegex.exec(releaseBody)) !== null) {
117+
prNumbers.add(parseInt(m[1]));
118+
}
119+
120+
// Pattern 3: Standalone #123 - verify it's a PR later
121+
const standaloneRefRegex = /(?:^|[\s({\[-])#(\d+)(?=[\s,.)\]}\]:;]|$)/g;
122+
while ((m = standaloneRefRegex.exec(releaseBody)) !== null) {
123+
prNumbers.add(parseInt(m[1]));
124+
}
125+
126+
console.log(`Found ${prNumbers.size} potential PRs in release ${version}`);
127+
128+
// Helper: Extract issue numbers with word boundary
129+
function extractIssueNumbers(text, keywordPattern) {
130+
const issues = new Set();
131+
let m;
132+
133+
// Pattern 1: keyword #123 (with word boundary)
134+
const simplePattern = new RegExp(
135+
'(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*#(\\d+)', 'gi'
136+
);
137+
while ((m = simplePattern.exec(text)) !== null) {
138+
issues.add(parseInt(m[1]));
139+
}
140+
141+
// Pattern 2: keyword owner/repo#123 (same repo only)
142+
const crossRepoPattern = new RegExp(
143+
'(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*' +
144+
repoOwner + '/' + repoName + '#(\\d+)', 'gi'
145+
);
146+
while ((m = crossRepoPattern.exec(text)) !== null) {
147+
issues.add(parseInt(m[1]));
148+
}
149+
150+
// Pattern 3: keyword URL (same repo only)
151+
const urlPattern = new RegExp(
152+
'(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*https://github\\.com/' +
153+
repoOwner + '/' + repoName + '/issues/(\\d+)', 'gi'
154+
);
155+
while ((m = urlPattern.exec(text)) !== null) {
156+
issues.add(parseInt(m[1]));
157+
}
158+
159+
return issues;
160+
}
161+
162+
// Word boundary patterns for keywords
163+
const closingKeywordBase = '(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)';
164+
const relatedKeywordBase = '(?:related(?:\\s+to)?|see|ref(?:s|erences)?)';
165+
166+
const processedIssues = new Set();
167+
const processedPRs = new Set();
168+
169+
for (const prNumber of prNumbers) {
170+
try {
171+
// Get PR details (skip if not a PR)
172+
let prBody = '';
173+
try {
174+
const { data: pr } = await github.rest.pulls.get({
175+
owner: repoOwner,
176+
repo: repoName,
177+
pull_number: prNumber
178+
});
179+
prBody = pr.body || '';
180+
} catch (prError) {
181+
if (prError.status === 404) {
182+
console.log(`#${prNumber} is not a PR (likely an Issue), skipping - only PRs are notified`);
183+
continue;
184+
}
185+
throw prError;
186+
}
187+
188+
// Post comment to PR
189+
if (!processedPRs.has(prNumber)) {
190+
const prComment = `🎉 **Released in [${version}](${releaseUrl})**\n\nThis PR is now available in the latest release. See the [release notes](${releaseUrl}) for details.`;
191+
192+
const prComments = await getAllComments(prNumber);
193+
const hasExistingComment = prComments.some(c =>
194+
c.body && c.body.includes(`Released in [${version}]`)
195+
);
196+
197+
if (!hasExistingComment) {
198+
await github.rest.issues.createComment({
199+
owner: repoOwner,
200+
repo: repoName,
201+
issue_number: prNumber,
202+
body: prComment
203+
});
204+
console.log(`Posted release comment to PR #${prNumber}`);
205+
}
206+
processedPRs.add(prNumber);
207+
}
208+
209+
// Find closing issues (Fixes/Closes/Resolves)
210+
const closingIssues = extractIssueNumbers(prBody, closingKeywordBase);
211+
212+
// Find related issues (Related/See/Ref)
213+
const relatedIssues = extractIssueNumbers(prBody, relatedKeywordBase);
214+
215+
// Remove closing issues from related issues to prevent duplicate comments
216+
for (const issueNum of closingIssues) {
217+
relatedIssues.delete(issueNum);
218+
}
219+
220+
// Find referenced PRs in PR body
221+
const referencedPRs = new Set();
222+
const prRefUrlPattern = new RegExp(
223+
'https://github\\.com/' + repoOwner + '/' + repoName + '/pull/(\\d+)', 'gi'
224+
);
225+
let prRefMatch;
226+
while ((prRefMatch = prRefUrlPattern.exec(prBody)) !== null) {
227+
const refPrNum = parseInt(prRefMatch[1]);
228+
if (refPrNum !== prNumber) {
229+
referencedPRs.add(refPrNum);
230+
}
231+
}
232+
233+
// Find standalone issue/PR references not in closing/related
234+
const standalonePattern = /(?:^|[\s({\[-])#(\d+)(?=[\s,.)\]}\]:;]|$)/g;
235+
const issueUrlPattern = new RegExp(
236+
'https://github\\.com/' + repoOwner + '/' + repoName + '/issues/(\\d+)', 'gi'
237+
);
238+
let standaloneMatch;
239+
while ((standaloneMatch = standalonePattern.exec(prBody)) !== null) {
240+
const refNum = parseInt(standaloneMatch[1]);
241+
if (!closingIssues.has(refNum) && refNum !== prNumber) {
242+
relatedIssues.add(refNum);
243+
}
244+
}
245+
while ((standaloneMatch = issueUrlPattern.exec(prBody)) !== null) {
246+
const issueNum = parseInt(standaloneMatch[1]);
247+
if (!closingIssues.has(issueNum) && !relatedIssues.has(issueNum)) {
248+
relatedIssues.add(issueNum);
249+
}
250+
}
251+
252+
// Post to closing issues
253+
for (const issueNumber of closingIssues) {
254+
const key = `closing-${issueNumber}`;
255+
if (processedIssues.has(key)) continue;
256+
processedIssues.add(key);
257+
258+
try {
259+
const issueComments = await getAllComments(issueNumber);
260+
const hasExistingComment = issueComments.some(c =>
261+
c.body && c.body.includes(`Released in [${version}]`)
262+
);
263+
264+
if (!hasExistingComment) {
265+
const comment = `🎉 **Released in [${version}](${releaseUrl})**\n\nThe fix/feature from PR #${prNumber} has been included in this release. See the [release notes](${releaseUrl}) for details.\n\nThank you for your contribution!`;
266+
await github.rest.issues.createComment({
267+
owner: repoOwner,
268+
repo: repoName,
269+
issue_number: issueNumber,
270+
body: comment
271+
});
272+
console.log(`Posted closing comment to issue #${issueNumber}`);
273+
}
274+
} catch (e) {
275+
console.log(`Failed to comment on issue #${issueNumber}: ${e.message}`);
276+
}
277+
}
278+
279+
// Post to related issues (not closing)
280+
for (const issueNumber of relatedIssues) {
281+
const key = `related-${issueNumber}`;
282+
if (processedIssues.has(key)) continue;
283+
processedIssues.add(key);
284+
285+
try {
286+
// Check if it's actually an issue (not a PR)
287+
try {
288+
await github.rest.pulls.get({
289+
owner: repoOwner,
290+
repo: repoName,
291+
pull_number: issueNumber
292+
});
293+
// It's a PR, add to referencedPRs instead
294+
referencedPRs.add(issueNumber);
295+
continue;
296+
} catch (checkError) {
297+
if (checkError.status !== 404) throw checkError;
298+
// It's an issue, continue
299+
}
300+
301+
const issueComments = await getAllComments(issueNumber);
302+
const hasExistingComment = issueComments.some(c =>
303+
c.body && (c.body.includes(`Released in [${version}]`) ||
304+
c.body.includes(`Related PR Released`))
305+
);
306+
307+
if (!hasExistingComment) {
308+
const comment = `📢 **Related PR Released: [${version}](${releaseUrl})**\n\nPR #${prNumber}, which references this issue, has been released. See the [release notes](${releaseUrl}) for details.\n\nNote: This issue was not explicitly closed by the PR.`;
309+
await github.rest.issues.createComment({
310+
owner: repoOwner,
311+
repo: repoName,
312+
issue_number: issueNumber,
313+
body: comment
314+
});
315+
console.log(`Posted related comment to issue #${issueNumber}`);
316+
}
317+
} catch (e) {
318+
console.log(`Failed to comment on issue #${issueNumber}: ${e.message}`);
319+
}
320+
}
321+
322+
// Post to referenced PRs
323+
for (const refPrNumber of referencedPRs) {
324+
const key = `refpr-${refPrNumber}`;
325+
if (processedPRs.has(refPrNumber) || processedIssues.has(key)) continue;
326+
processedIssues.add(key);
327+
328+
try {
329+
const refPrComments = await getAllComments(refPrNumber);
330+
const hasExistingComment = refPrComments.some(c =>
331+
c.body && c.body.includes(`Released in [${version}]`)
332+
);
333+
334+
if (!hasExistingComment) {
335+
const comment = `📢 **Related PR Released: [${version}](${releaseUrl})**\n\nPR #${prNumber}, which references this PR, has been released. See the [release notes](${releaseUrl}) for details.`;
336+
await github.rest.issues.createComment({
337+
owner: repoOwner,
338+
repo: repoName,
339+
issue_number: refPrNumber,
340+
body: comment
341+
});
342+
console.log(`Posted related comment to PR #${refPrNumber}`);
343+
}
344+
} catch (e) {
345+
console.log(`Failed to comment on PR #${refPrNumber}: ${e.message}`);
346+
}
347+
}
348+
349+
} catch (e) {
350+
console.log(`Failed to process PR #${prNumber}: ${e.message}`);
351+
}
352+
}
353+
354+
console.log(`Release notification complete for ${version}`);

0 commit comments

Comments
 (0)