Skip to content

Commit c14cb2a

Browse files
committed
implement script which updates actions, update actions
1 parent 6f40394 commit c14cb2a

File tree

3 files changed

+368
-3
lines changed

3 files changed

+368
-3
lines changed

.github/workflows/build-and-release.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,6 @@ jobs:
211211
path: dist/
212212

213213
- name: Release
214-
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # 2.4.0
214+
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
215215
with:
216-
files: dist/**
216+
files: dist/**

devshell.nix

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
{ pkgs }:
22
pkgs.mkShell {
33
# Add build dependencies
4-
packages = with pkgs; [ go ];
4+
packages = with pkgs; [
5+
go
6+
nodejs
7+
];
58

69
# Add environment variables
710
env = { };

scripts/update-action-pins.js

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Update GitHub Actions in .github/workflows/*.ya?ml to the latest suitable release:
5+
* - Finds lines with `uses: owner/repo[/path]@ref`
6+
* - Queries GitHub Releases (first page only) for `owner/repo`
7+
* - Picks the most advanced semver whose major >= currently-installed major (if known)
8+
* - Resolves the selected release tag to a commit SHA
9+
* - Replaces the ref with the commit SHA and appends `# vX[.Y[.Z]]` comment
10+
* - Handles release names/tags like `codeql-bundle-v2.23.2` by extracting `v2.23.2`.
11+
* - No third-party modules; Node.js 22 standard library only.
12+
*
13+
* Notes:
14+
* - Supports optional GITHUB_TOKEN to raise rate limit: grant classic `public_repo` permission
15+
*/
16+
17+
const fs = require('fs');
18+
const fsp = require('fs/promises');
19+
const path = require('path');
20+
const https = require('https');
21+
22+
const WORKFLOWS_DIR = path.join(process.cwd(), '.github', 'workflows');
23+
const RELEASES_PER_PAGE = 100;
24+
25+
const TOKEN = process.env.GITHUB_TOKEN || null;
26+
27+
// Simple cache maps
28+
const releasesCache = new Map(); // key: 'owner/repo' => array of release objects with extracted versions
29+
const commitShaCache = new Map(); // key: 'owner/repo@tag' => sha
30+
31+
async function main() {
32+
const files = await findWorkflowFiles(WORKFLOWS_DIR);
33+
if (files.length === 0) {
34+
console.log('No workflow files found.');
35+
return;
36+
}
37+
38+
let totalUpdates = 0;
39+
40+
for (const file of files) {
41+
const original = await fsp.readFile(file, 'utf8');
42+
const lines = original.split(/\r?\n/);
43+
44+
let updated = false;
45+
for (let i = 0; i < lines.length; i++) {
46+
const parsed = parseUsesLine(lines[i]);
47+
if (!parsed) continue;
48+
49+
// Ignore local or docker actions
50+
const value = parsed.valueStr;
51+
if (!value || !value.includes('@')) continue;
52+
if (value.startsWith('.') || value.startsWith('docker://')) continue;
53+
54+
const { beforeAt, ref, baseRepo, subPath, quote } = parseUsesValue(value);
55+
if (!baseRepo) continue;
56+
console.log(`${file}:${i + 1} checking ${beforeAt}...`)
57+
58+
try {
59+
const currentVersion = extractVersionFromCommentOrRef(parsed.comment, ref);
60+
const releases = await getReleasesForRepo(baseRepo);
61+
if (!releases || releases.length === 0) continue;
62+
63+
const candidate = pickBestRelease(releases, currentVersion);
64+
if (!candidate) continue;
65+
66+
const tag = candidate.tag;
67+
const sha = await getCommitShaForTag(baseRepo, tag);
68+
69+
if (!sha) continue;
70+
71+
const newValueStr = `${beforeAt}@${sha}`;
72+
const newComment = `# ${candidate.versionText}`;
73+
74+
// Only update if change is needed
75+
const currentShaPinned = isSha(ref) ? ref : null;
76+
const needsChange = currentShaPinned !== sha || !commentContainsVersion(parsed.comment, candidate.versionText);
77+
if (needsChange) {
78+
const newLine = rebuildUsesLine(parsed, newValueStr, newComment, quote);
79+
lines[i] = newLine;
80+
updated = true;
81+
totalUpdates++;
82+
console.log(`${file}:${i + 1} -> ${baseRepo}${subPath || ''} @ ${sha} (${candidate.versionText})`);
83+
}
84+
} catch (err) {
85+
console.warn(`Warning: Failed to update ${baseRepo} in ${file}: ${err.message}`);
86+
}
87+
}
88+
89+
if (updated) {
90+
const content = lines.join('\n');
91+
if (content !== original) {
92+
await fsp.writeFile(file, content, 'utf8');
93+
}
94+
}
95+
}
96+
97+
console.log(`Done. ${totalUpdates} update(s) applied.`);
98+
}
99+
100+
function commentContainsVersion(comment, versionText) {
101+
if (!comment || !versionText) return false;
102+
return comment.includes(versionText);
103+
}
104+
105+
function isSha(s) {
106+
return /^[0-9a-f]{40}$/i.test(s);
107+
}
108+
109+
function rebuildUsesLine(parsed, newValueStr, newComment, quote) {
110+
const { indent, beforeKey, keyAndSep, trailing } = parsed;
111+
const quotedValue = quote ? `${quote}${newValueStr}${quote}` : newValueStr;
112+
// Always replace any existing comment with our version comment
113+
return `${indent}${beforeKey}${keyAndSep}${quotedValue} ${newComment}${trailing ? '' : ''}`;
114+
}
115+
116+
function parseUsesValue(valueStr) {
117+
// valueStr like: owner/repo[/path]@ref
118+
const atIndex = valueStr.lastIndexOf('@');
119+
if (atIndex <= 0) return {};
120+
const beforeAt = valueStr.slice(0, atIndex);
121+
const ref = valueStr.slice(atIndex + 1);
122+
123+
const parts = beforeAt.split('/');
124+
if (parts.length < 2) return {};
125+
126+
const baseRepo = `${parts[0]}/${parts[1]}`;
127+
const subPath = parts.length > 2 ? `/${parts.slice(2).join('/')}` : '';
128+
return { beforeAt, ref, baseRepo, subPath, quote: detectQuoteChar(valueStr) };
129+
}
130+
131+
function detectQuoteChar(s) {
132+
if (!s) return null;
133+
const first = s[0];
134+
if (first === '"' || first === "'") return first;
135+
return null;
136+
}
137+
138+
function extractVersionFromCommentOrRef(comment, ref) {
139+
// Prefer version from inline comment like '# v2.3.4'
140+
const fromComment = extractVersionInfo(comment);
141+
if (fromComment) return fromComment;
142+
143+
// Then try the ref itself if it is a version tag like 'v2.3.4' or 'v3'
144+
const fromRef = extractVersionInfo(ref);
145+
if (fromRef) return fromRef;
146+
147+
return null; // Unknown currently installed version
148+
}
149+
150+
function extractVersionInfo(str) {
151+
if (!str) return null;
152+
// Find first occurrence of v<major>[.<minor>][.<patch>], ignoring letters/digits before v
153+
const re = /(?:^|[^A-Za-z0-9])v(\d+)(?:\.(\d+))?(?:\.(\d+))?/i;
154+
const m = re.exec(str);
155+
if (!m) return null;
156+
const major = parseInt(m[1], 10);
157+
const minor = m[2] != null ? parseInt(m[2], 10) : 0;
158+
const patch = m[3] != null ? parseInt(m[3], 10) : 0;
159+
const versionText = `v${major}${m[2] != null ? `.${minor}` : ''}${m[3] != null ? `.${patch}` : ''}`;
160+
return { major, minor, patch, versionText };
161+
}
162+
163+
function compareSemver(a, b) {
164+
if (a.major !== b.major) return a.major - b.major;
165+
if (a.minor !== b.minor) return a.minor - b.minor;
166+
if (a.patch !== b.patch) return a.patch - b.patch;
167+
return 0;
168+
}
169+
170+
function pickBestRelease(releases, currentVersion) {
171+
// Filter out drafts/prereleases and releases without detectable version
172+
const candidates = releases
173+
.filter(r => !r.draft && !r.prerelease && r.versionInfo)
174+
.map(r => ({ tag: r.tag_name, versionInfo: r.versionInfo, versionText: r.versionInfo.versionText }));
175+
176+
let filtered = candidates;
177+
if (currentVersion && Number.isFinite(currentVersion.major)) {
178+
filtered = candidates.filter(c => c.versionInfo.major >= currentVersion.major);
179+
}
180+
if (filtered.length === 0) {
181+
return null;
182+
}
183+
// Pick the highest by semver
184+
filtered.sort((a, b) => {
185+
const cmp = compareSemver(a.versionInfo, b.versionInfo);
186+
return cmp !== 0 ? cmp : 0;
187+
});
188+
return filtered[filtered.length - 1];
189+
}
190+
191+
async function getReleasesForRepo(repo) {
192+
if (releasesCache.has(repo)) {
193+
return releasesCache.get(repo);
194+
}
195+
const [owner, name] = repo.split('/');
196+
const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases?per_page=${RELEASES_PER_PAGE}`;
197+
const releases = await ghGetJson(path);
198+
if (!Array.isArray(releases)) {
199+
releasesCache.set(repo, []);
200+
return [];
201+
}
202+
// Attach extracted version info, try tag_name then name
203+
const withVersions = releases.map(r => {
204+
const vi = extractVersionInfo(r.tag_name) || extractVersionInfo(r.name);
205+
return { ...r, versionInfo: vi };
206+
});
207+
releasesCache.set(repo, withVersions);
208+
return withVersions;
209+
}
210+
211+
async function getCommitShaForTag(repo, tag) {
212+
const key = `${repo}@${tag}`;
213+
if (commitShaCache.has(key)) return commitShaCache.get(key);
214+
215+
const [owner, name] = repo.split('/');
216+
// Use the commits endpoint which resolves tag -> commit
217+
const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${encodeURIComponent(tag)}`;
218+
const commit = await ghGetJson(path);
219+
const sha = commit && typeof commit.sha === 'string' ? commit.sha : null;
220+
if (sha) commitShaCache.set(key, sha);
221+
return sha;
222+
}
223+
224+
function ghGetJson(apiPath) {
225+
const options = {
226+
hostname: 'api.github.com',
227+
path: apiPath,
228+
method: 'GET',
229+
headers: {
230+
'Accept': 'application/vnd.github+json',
231+
'User-Agent': 'gh-actions-updater-script',
232+
},
233+
};
234+
if (TOKEN) {
235+
options.headers.Authorization = `Bearer ${TOKEN}`;
236+
}
237+
238+
return new Promise((resolve, reject) => {
239+
const req = https.request(options, (res) => {
240+
let data = '';
241+
res.setEncoding('utf8');
242+
res.on('data', chunk => { data += chunk });
243+
res.on('end', () => {
244+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
245+
try {
246+
resolve(JSON.parse(data));
247+
} catch (e) {
248+
reject(new Error(`Failed to parse JSON from ${apiPath}: ${e.message}`));
249+
}
250+
} else {
251+
// Return empty array for 404 on releases to avoid throwing on repos without releases
252+
if (res.statusCode === 404 && apiPath.includes('/releases')) {
253+
resolve([]);
254+
return;
255+
}
256+
reject(new Error(`GitHub API ${apiPath} failed: ${res.statusCode} ${res.statusMessage} - ${data.slice(0, 200)}`));
257+
}
258+
});
259+
});
260+
req.on('error', reject);
261+
req.end();
262+
});
263+
}
264+
265+
async function findWorkflowFiles(dir) {
266+
let entries;
267+
try {
268+
entries = await fsp.readdir(dir, { withFileTypes: true });
269+
} catch (e) {
270+
return [];
271+
}
272+
const files = [];
273+
for (const ent of entries) {
274+
if (ent.isFile()) {
275+
if (/\.ya?ml$/i.test(ent.name)) {
276+
files.push(path.join(dir, ent.name));
277+
}
278+
}
279+
}
280+
return files;
281+
}
282+
283+
function parseUsesLine(line) {
284+
// Capture indentation and the entirety after 'uses:'
285+
// Supports lines like:
286+
// uses: owner/repo@ref # comment
287+
// uses: "owner/repo@ref" # comment
288+
// uses: 'owner/repo@ref' # comment
289+
const m = /^(\s*)(-?\s*)?(uses:\s*)(.*)$/.exec(line);
290+
if (!m) return null;
291+
292+
const indent = m[1] || '';
293+
const beforeKey = m[2] || '';
294+
const keyAndSep = m[3];
295+
const rest = m[4] || '';
296+
297+
// Parse value and comment from rest while respecting optional quotes
298+
let i = 0;
299+
while (i < rest.length && /\s/.test(rest[i])) i++;
300+
301+
if (i >= rest.length) {
302+
return {
303+
indent,
304+
beforeKey,
305+
keyAndSep,
306+
valueStr: '',
307+
comment: '',
308+
trailing: '',
309+
originalLine: line,
310+
};
311+
}
312+
313+
let quote = null;
314+
let value = '';
315+
let j = i;
316+
if (rest[j] === '"' || rest[j] === "'") {
317+
quote = rest[j];
318+
j++;
319+
const start = j;
320+
while (j < rest.length) {
321+
if (rest[j] === quote && rest[j - 1] !== '\\') break;
322+
j++;
323+
}
324+
value = rest.slice(start, j);
325+
// Move past closing quote if present
326+
if (j < rest.length && rest[j] === quote) j++;
327+
} else {
328+
const start = j;
329+
while (j < rest.length && !/\s/.test(rest[j]) && rest[j] !== '#') {
330+
j++;
331+
}
332+
value = rest.slice(start, j);
333+
}
334+
335+
// Skip spaces
336+
while (j < rest.length && /\s/.test(rest[j])) j++;
337+
338+
let comment = '';
339+
if (j < rest.length && rest[j] === '#') {
340+
comment = rest.slice(j).replace(/^\s*#\s?/, '').trim();
341+
}
342+
343+
return {
344+
indent,
345+
beforeKey,
346+
keyAndSep,
347+
valueStr: value,
348+
comment,
349+
quote,
350+
trailing: '',
351+
originalLine: line,
352+
};
353+
}
354+
355+
(async () => {
356+
try {
357+
await main();
358+
} catch (e) {
359+
console.error('Error:', e);
360+
process.exit(1);
361+
}
362+
})();

0 commit comments

Comments
 (0)