Skip to content

Commit cc992e2

Browse files
committed
Add EoL and new release reminder
1 parent 7294546 commit cc992e2

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed
+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// @ts-check
2+
3+
/**
4+
* @typedef {object} ReleaseCycle
5+
* @property {string|number} cycle - Release cycle (e.g. "1.20", 8.1, etc.)
6+
* @property {string} [releaseDate] - YYYY-MM-DD string for the first release in this cycle
7+
* @property {string|boolean} [eol] - End of Life date (YYYY-MM-DD) or false if not EoL
8+
* @property {string} [latest] - Latest release in this cycle
9+
* @property {string|null} [link] - Link to changelog or similar, if available
10+
* @property {boolean|string} [lts] - Whether this cycle is non-LTS (false), or LTS starting on a given date
11+
* @property {string|boolean} [support] - Active support date (YYYY-MM-DD) or boolean
12+
* @property {string|boolean} [discontinued] - Discontinued date (YYYY-MM-DD) or boolean
13+
*/
14+
15+
/**
16+
* @typedef {object} EolNewReleaseConfig
17+
* @property {string} languageName
18+
* @property {string} eolJsonUrl
19+
* @property {string} eolViewUrl
20+
* @property {number} eolLookbackDays
21+
* @property {number} newReleaseThresholdDays
22+
* @property {boolean} ltsOnly
23+
* @property {number} retryCount
24+
* @property {number} retryIntervalSec
25+
*/
26+
27+
/**
28+
* This script checks EoL and new releases from endoflife.date JSON.
29+
* It creates Issues for:
30+
* - EoL reached within eolLookbackDays
31+
* - New releases within newReleaseThresholdDays
32+
* If fetching fails after multiple retries, an error Issue is created once per week.
33+
*
34+
* Note this script is used in a GitHub Action workflow, and some line/line-bot-sdk-* repositories.
35+
* If you modify this script, please consider syncing the changes to other repositories.
36+
*
37+
* @param {import('@actions/github-script').AsyncFunctionArguments} actionCtx
38+
* @param {EolNewReleaseConfig} config
39+
*/
40+
module.exports = async function checkEolAndNewReleases(actionCtx, config) {
41+
const { github, context, core } = actionCtx;
42+
const {
43+
languageName,
44+
eolJsonUrl,
45+
eolViewUrl,
46+
eolLookbackDays,
47+
newReleaseThresholdDays,
48+
ltsOnly,
49+
retryCount,
50+
retryIntervalSec,
51+
} = config;
52+
53+
/**
54+
* Returns a simple "year-week" string like "2025-W09".
55+
* This is a rough calculation (not strictly ISO-8601).
56+
* @param {Date} date
57+
* @returns {string}
58+
*/
59+
const getYearWeek = (date) => {
60+
const startOfYear = new Date(date.getFullYear(), 0, 1);
61+
const dayOfYear = Math.floor((date - startOfYear) / 86400000) + 1;
62+
const weekNum = Math.ceil(dayOfYear / 7);
63+
return `${date.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
64+
};
65+
66+
/**
67+
* Simple dedent function.
68+
* Removes common leading indentation based on the minimum indent across all lines.
69+
* Also trims empty lines at the start/end.
70+
* @param {string} str
71+
* @returns {string}
72+
*/
73+
const dedent = (str) => {
74+
const lines = str.split('\n');
75+
while (lines.length && lines[0].trim() === '') lines.shift();
76+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
77+
78+
/** @type {number[]} */
79+
const indents = lines
80+
.filter(line => line.trim() !== '')
81+
.map(line => (line.match(/^(\s+)/)?.[1].length) ?? 0);
82+
83+
const minIndent = indents.length > 0 ? Math.min(...indents) : 0;
84+
return lines.map(line => line.slice(minIndent)).join('\n');
85+
};
86+
87+
/**
88+
* Creates an Issue if an Issue with the same title does not exist (state=all).
89+
* @param {string} title
90+
* @param {string} body
91+
* @param {string[]} [labels]
92+
* @returns {Promise<boolean>} true if created, false if an Issue with same title already exists
93+
*/
94+
const createIssueIfNotExists = async (title, body, labels = []) => {
95+
const issues = await github.rest.issues.listForRepo({
96+
owner: context.repo.owner,
97+
repo: context.repo.repo,
98+
state: 'all',
99+
per_page: 100,
100+
});
101+
const found = issues.data.find(i => i.title === title);
102+
if (found) {
103+
core.info(`Issue already exists: "${title}"`);
104+
return false;
105+
}
106+
await github.rest.issues.create({
107+
owner: context.repo.owner,
108+
repo: context.repo.repo,
109+
title,
110+
body,
111+
labels,
112+
});
113+
core.notice(`Created Issue: "${title}"`);
114+
return true;
115+
};
116+
117+
/**
118+
* Fetch with retry, returning an array of ReleaseCycle objects.
119+
* @param {string} url
120+
* @returns {Promise<ReleaseCycle[]>}
121+
*/
122+
const fetchWithRetry = async (url) => {
123+
let lastErr = null;
124+
for (let i = 1; i <= retryCount; i++) {
125+
try {
126+
const response = await fetch(url);
127+
if (!response.ok) {
128+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
129+
}
130+
/** @type {ReleaseCycle[]} */
131+
const jsonData = await response.json();
132+
return jsonData;
133+
} catch (err) {
134+
lastErr = err;
135+
core.warning(`Fetch failed (attempt ${i}/${retryCount}): ${err.message}`);
136+
if (i < retryCount) {
137+
await new Promise(r => setTimeout(r, retryIntervalSec * 1000));
138+
}
139+
}
140+
}
141+
throw new Error(`Failed to fetch after ${retryCount} attempts: ${lastErr?.message}`);
142+
};
143+
144+
/**
145+
* Check EoL for a single release.
146+
* @param {ReleaseCycle} release
147+
* @param {Date} now
148+
* @param {Date} eolLookbackDate
149+
*/
150+
const checkEoL = async (release, now, eolLookbackDate) => {
151+
if (ltsOnly && release.lts === false) {
152+
core.info(`Skipping non-LTS release: ${release.cycle}`);
153+
return;
154+
}
155+
if (typeof release.eol === 'string') {
156+
const eolDate = new Date(release.eol);
157+
if (!isNaN(eolDate.getTime())) {
158+
// Check if it reached EoL within the last eolLookbackDays
159+
if (eolDate <= now && eolDate >= eolLookbackDate) {
160+
if (!release.cycle) return;
161+
const title = `[EoL] ${languageName} ${release.cycle} reached End of Life`;
162+
const body = dedent(`
163+
**EoL date**: ${release.eol}
164+
endoflife.date for ${languageName}: ${eolViewUrl}
165+
166+
This version(${languageName} ${release.cycle}) has reached End of Life.
167+
Please consider drop support or update as needed.
168+
`);
169+
await createIssueIfNotExists(title, body, ['keep']);
170+
}
171+
}
172+
}
173+
};
174+
175+
/**
176+
* Check new release for a single release.
177+
* @param {ReleaseCycle} release
178+
* @param {Date} now
179+
* @param {Date} newReleaseSince
180+
*/
181+
const checkNewRelease = async (release, now, newReleaseSince) => {
182+
if (ltsOnly && release.lts === false) {
183+
core.info(`Skipping non-LTS release: ${release.cycle}`);
184+
return;
185+
}
186+
if (typeof release.releaseDate === 'string') {
187+
const rDate = new Date(release.releaseDate);
188+
if (!isNaN(rDate.getTime())) {
189+
// Check if releaseDate is within newReleaseThresholdDays
190+
if (rDate >= newReleaseSince && rDate <= now) {
191+
if (!release.cycle) return;
192+
const ltsTag = ltsOnly ? ' (LTS)' : '';
193+
const title = `[New Release] ${languageName} ${release.cycle}${ltsTag} is now available`;
194+
const body = dedent(`
195+
**Release date**: ${release.releaseDate}
196+
endoflife.date for ${languageName}: ${eolViewUrl}
197+
198+
A new version(${languageName} ${release.cycle}) has been released.
199+
Please consider updating or testing as needed.
200+
`);
201+
await createIssueIfNotExists(title, body, ['keep']);
202+
}
203+
}
204+
}
205+
};
206+
207+
core.info(`Starting EoL & NewRelease check for ${languageName} ...`);
208+
const now = new Date();
209+
const eolLookbackDate = new Date(now);
210+
eolLookbackDate.setDate(eolLookbackDate.getDate() - eolLookbackDays);
211+
212+
const newReleaseSince = new Date(now);
213+
newReleaseSince.setDate(newReleaseSince.getDate() - newReleaseThresholdDays);
214+
215+
try {
216+
const data = await fetchWithRetry(eolJsonUrl);
217+
for (const release of data) {
218+
core.info(`Checking release: ${JSON.stringify(release)}`);
219+
await checkEoL(release, now, eolLookbackDate);
220+
await checkNewRelease(release, now, newReleaseSince);
221+
}
222+
} catch (err) {
223+
core.error(`Error checking EoL/NewReleases for ${languageName}: ${err.message}`);
224+
const yw = getYearWeek(new Date());
225+
const errorTitle = `[CI ERROR] EoL/NewRelease check for ${languageName} in ${yw}`;
226+
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
227+
const body = dedent(`
228+
The automated check for EoL and new releases failed (retried ${retryCount} times).
229+
**Error**: ${err.message}
230+
**Action URL**: [View job log here](${runUrl})
231+
Please investigate (network issues, invalid JSON, etc.) and fix it to monitor EOL site automatically.
232+
`);
233+
await createIssueIfNotExists(errorTitle, body, ['keep']);
234+
core.setFailed(err.message);
235+
}
236+
};
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: "Check EoL & New Releases"
2+
3+
on:
4+
schedule:
5+
# Every day at 22:00 UTC -> 07:00 JST
6+
- cron: '0 22 * * *'
7+
workflow_dispatch:
8+
9+
jobs:
10+
check-go:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Check out code
14+
uses: actions/checkout@v4
15+
16+
- name: Run EoL & NewRelease check
17+
uses: actions/github-script@v7
18+
with:
19+
script: |
20+
const checkEolAndNewReleases = require('.github/scripts/check-eol-newrelease.cjs');
21+
await checkEolAndNewReleases({ github, context, core }, {
22+
languageName: 'Node.js',
23+
eolJsonUrl: 'https://endoflife.date/api/nodejs.json',
24+
eolViewUrl: 'https://endoflife.date/nodejs',
25+
eolLookbackDays: 100,
26+
newReleaseThresholdDays: 100,
27+
ltsOnly: true,
28+
retryCount: 3,
29+
retryIntervalSec: 30
30+
});
31+
github-token: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)