Skip to content

Commit 188c81e

Browse files
committed
ci: add script to check and update action versions
Scan .github/workflows for uses: references, compare against the latest stable release of each upstream action, and optionally rewrite pins to the full commit SHA (owner/repo@<sha> # vX.Y.Z). Usage: update_actions.py [--check|--update] [--dry-run] [--path ...]
1 parent cb5ce9f commit 188c81e

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

.github/scripts/update_actions.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env python3
2+
"""Check and update GitHub Action versions in workflow files.
3+
4+
Scans every `uses:` step that references a third-party action
5+
(owner/repo@ref) and compares it with the latest stable release of the
6+
upstream repository. In --update mode it rewrites the reference to the
7+
full commit SHA of the latest version, keeping the version number as a
8+
trailing comment, following this repo's convention:
9+
10+
uses: owner/repo@<40-hex-sha> # vX.Y.Z
11+
12+
Version tags are resolved with `git ls-remote` (no authentication
13+
needed). Annotated tags are resolved to their peeled commit SHA.
14+
15+
Usage:
16+
update_actions.py [--check | --update] [--dry-run] [--path PATH]
17+
18+
Modes:
19+
--check (default) Report outdated or unpinned actions.
20+
Exit 1 if any are found.
21+
--update Rewrite the workflow files in place.
22+
--dry-run With --update, print the changes without writing files.
23+
--path Scan a specific file or directory instead of
24+
.github/workflows (default).
25+
"""
26+
27+
import argparse
28+
import os
29+
import re
30+
import subprocess
31+
import sys
32+
33+
USES_RE = re.compile(r'^(\s*(?:-\s+)?)(uses:\s*)([^\s#]+)(.*)$')
34+
SHA_RE = re.compile(r'^[0-9a-f]{40}$')
35+
VERSION_RE = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)$')
36+
VERSION_COMMENT_RE = re.compile(r'#\s*(v?\d+\.\d+\.\d+)\s*$')
37+
SKIP_PREFIXES = ('docker://', './', '../')
38+
39+
40+
def version_key(tag):
41+
"""Sort key for stable vX.Y.Z tags (pre-releases are filtered out)."""
42+
m = VERSION_RE.match(tag)
43+
return None if not m else tuple(int(g) for g in m.groups())
44+
45+
46+
def ls_remote_tags(repo):
47+
"""Return {version_tag: commit_sha} for all stable tags of `repo`,
48+
preferring the peeled commit of annotated tags."""
49+
url = f'https://github.com/{repo}.git'
50+
try:
51+
proc = subprocess.run(
52+
['git', 'ls-remote', '--tags', url],
53+
capture_output=True, text=True, check=True, timeout=60,
54+
)
55+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
56+
print(f' error: could not fetch tags for {repo}: {e}', file=sys.stderr)
57+
return {}
58+
tags = {}
59+
for line in proc.stdout.splitlines():
60+
sha, _, ref = line.partition('\t')
61+
if not ref.startswith('refs/tags/'):
62+
continue
63+
name = ref[len('refs/tags/'):]
64+
peeled = name.endswith('^{}')
65+
if peeled:
66+
name = name[:-3]
67+
if version_key(name) is None:
68+
continue
69+
# For annotated tags keep the peeled commit; for lightweight tags
70+
# keep the tag ref itself (which already points at the commit).
71+
if peeled or name not in tags:
72+
tags[name] = sha
73+
return tags
74+
75+
76+
def latest_release(repo):
77+
"""Return (version, commit_sha) of the latest stable release, or
78+
(None, None) if it could not be determined."""
79+
tags = ls_remote_tags(repo)
80+
if not tags:
81+
return None, None
82+
best = max(tags, key=version_key)
83+
return best, tags[best]
84+
85+
86+
def parse_uses(line):
87+
"""Parse a `uses:` line into its parts, or None if not a third-party
88+
action reference."""
89+
m = USES_RE.match(line)
90+
if not m:
91+
return None
92+
indent, _, ref, rest = m.groups()
93+
if ref.startswith(SKIP_PREFIXES) or '@' not in ref or '/' not in ref:
94+
return None
95+
action, _, current_ref = ref.partition('@')
96+
comment = VERSION_COMMENT_RE.search(rest)
97+
return {
98+
'indent': indent,
99+
'action': action,
100+
'ref': current_ref,
101+
'version': comment.group(1) if comment else None,
102+
}
103+
104+
105+
def collect(paths):
106+
"""Yield (path, lineno, parsed) for every action use found."""
107+
files = []
108+
for p in paths:
109+
if os.path.isdir(p):
110+
for root, _, names in os.walk(p):
111+
for name in sorted(names):
112+
if name.endswith(('.yml', '.yaml')):
113+
files.append(os.path.join(root, name))
114+
else:
115+
files.append(p)
116+
for f in sorted(set(files)):
117+
with open(f, encoding='utf-8') as fh:
118+
for i, line in enumerate(fh, 1):
119+
parsed = parse_uses(line.rstrip('\n'))
120+
if parsed:
121+
yield f, i, parsed
122+
123+
124+
def main():
125+
ap = argparse.ArgumentParser(description=__doc__)
126+
ap.add_argument('--check', action='store_true', help='report only (default)')
127+
ap.add_argument('--update', action='store_true', help='rewrite files in place')
128+
ap.add_argument('--dry-run', action='store_true',
129+
help='with --update, show changes without writing')
130+
ap.add_argument('--path', nargs='+', default=['.github/workflows'],
131+
help='file or directory to scan (default: .github/workflows)')
132+
args = ap.parse_args()
133+
if args.update:
134+
args.check = False
135+
if args.dry_run and not args.update:
136+
ap.error('--dry-run requires --update')
137+
138+
entries = list(collect(args.path))
139+
if not entries:
140+
print('no action uses found')
141+
return 1
142+
143+
cache = {}
144+
outdated = []
145+
errors = []
146+
print(f'{"action":<34} {"current":<12} {"latest":<12} status')
147+
print('-' * 76)
148+
149+
# Group entries by file so each file is only rewritten once.
150+
by_file = set()
151+
for f, lineno, parsed in entries:
152+
by_file.add(f)
153+
action = parsed['action']
154+
if action not in cache:
155+
cache[action] = latest_release(action)
156+
latest_version, latest_sha = cache[action]
157+
158+
pinned = SHA_RE.match(parsed['ref']) is not None
159+
if latest_sha is None:
160+
errors.append((f, lineno, action, 'could not determine latest release'))
161+
status = 'ERROR'
162+
elif pinned and parsed['ref'] == latest_sha:
163+
status = 'up to date'
164+
else:
165+
outdated.append((f, lineno, parsed, latest_version, latest_sha))
166+
status = 'OUTDATED' if pinned else 'UNPINNED'
167+
print(f'{action:<34} {parsed["version"] or parsed["ref"][:7]:<12} '
168+
f'{latest_version or "-":<12} {status}')
169+
170+
print()
171+
if not outdated and not errors:
172+
print('all actions are up to date and pinned to a commit SHA')
173+
return 0
174+
175+
if errors:
176+
print(f'{len(errors)} error(s):')
177+
for f, lineno, action, msg in errors:
178+
print(f' {f}:{lineno} {action}: {msg}')
179+
180+
if args.check:
181+
print(f'{len(outdated)} action(s) outdated or unpinned')
182+
return 1
183+
184+
# Apply updates.
185+
for f in sorted(by_file):
186+
updates = {(ln, p['action'], p['ref']): (v, s)
187+
for (ff, ln, p, v, s) in outdated if ff == f}
188+
with open(f, encoding='utf-8') as fh:
189+
original = fh.readlines()
190+
rewritten = []
191+
changed = False
192+
for lineno, line in enumerate(original, 1):
193+
stripped = line.rstrip('\n')
194+
parsed = parse_uses(stripped)
195+
key = (lineno, parsed['action'], parsed['ref']) if parsed else None
196+
if key in updates:
197+
version, sha = updates[key]
198+
new_line = f'{parsed["indent"]}uses: {parsed["action"]}@{sha} # {version}\n'
199+
changed = changed or new_line != line
200+
if not args.dry_run:
201+
line = new_line
202+
elif new_line != line:
203+
print(f'{f}:{lineno}')
204+
print(f' - {stripped}')
205+
print(f' + {new_line.rstrip()}')
206+
rewritten.append(line)
207+
if changed and not args.dry_run:
208+
with open(f, 'w', encoding='utf-8') as fh:
209+
fh.writelines(rewritten)
210+
print(f'updated {f}')
211+
212+
if args.dry_run:
213+
print(f'\n{len(outdated)} action(s) would be updated')
214+
else:
215+
print(f'\nupdated {len(outdated)} action(s)')
216+
return 0
217+
218+
219+
if __name__ == '__main__':
220+
sys.exit(main())

0 commit comments

Comments
 (0)