Skip to content

Commit c110f27

Browse files
committed
fix: adhering to new script convention
1 parent ddafa3c commit c110f27

18 files changed

+723
-36
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.idea/
22
.DS_Store
33
.venv/
4+
*.pyc

actions/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Update GitHub Actions :arrows_counterclockwise:
2+
3+
This GitHub Action scans `.github` workflows, finds `uses:` entries that match configured prefixes, compares them to the
4+
latest GitHub releases, and updates them when newer versions exist.
5+
6+
## :rocket: Usage
7+
8+
```yaml
9+
name: Update GitHub Actions
10+
on:
11+
schedule:
12+
- cron: '0 2 * * 1'
13+
workflow_dispatch:
14+
15+
jobs:
16+
update-actions:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Update GitHub Actions
20+
uses: alchemaxinc/update-deps/actions@v1
21+
with:
22+
token: ${{ github.token }}
23+
base-branch: 'main'
24+
branch-prefix: 'update-actions'
25+
pr-title: 'Update GitHub Actions'
26+
commit-message: 'Update GitHub Actions'
27+
file-glob: '.github/**/*.yml'
28+
prefixes: 'actions'
29+
```
30+
31+
## :computer: Local CLI
32+
33+
```bash
34+
python cli.py --root /path/to/repo --file-glob '.github/**/*.yml' --prefixes 'actions'
35+
```
36+
37+
## :gear: Inputs
38+
39+
| Input | Description | Required | Default |
40+
|------------------|-----------------------------------------------------|--------------------|-------------------------|
41+
| `base-branch` | Base branch for the pull request | :white_check_mark: | `main` |
42+
| `token` | GitHub token for authentication | :x: | `${{ github.token }}` |
43+
| `branch-prefix` | Prefix for the update branch | :x: | `update-actions` |
44+
| `pr-title` | Title for the pull request | :x: | `Update GitHub Actions` |
45+
| `commit-message` | Commit message for the update | :x: | `Update GitHub Actions` |
46+
| `file-glob` | Glob for workflow files (relative to repo root) | :x: | `.github/**/*.yml` |
47+
| `prefixes` | Comma-separated list of action prefixes to include | :x: | `actions` |
48+
| `auto-merge` | Wether automatic merge should be enabled for the PR | :x: | `false` |
49+
50+
## :warning: Prerequisites
51+
52+
- Workflow files must be under `.github` and match the configured `file-glob`
53+
- The action requires write permissions to create branches and pull requests
54+
- GitHub CLI must be available in the runner environment
55+
56+
## :test_tube: Tests
57+
58+
```bash
59+
python -m unittest discover -s tests
60+
```

actions/action.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
name: 'Update GitHub Actions'
2+
3+
description: 'Update GitHub Actions used in .github workflow files'
4+
5+
inputs:
6+
token:
7+
required: false
8+
description: 'GitHub token'
9+
default: ${{ github.token }}
10+
11+
base-branch:
12+
required: true
13+
description: 'Base branch for PR'
14+
default: 'main'
15+
16+
branch-prefix:
17+
required: false
18+
description: 'Branch prefix'
19+
default: 'update-actions'
20+
21+
pr-title:
22+
required: false
23+
description: 'Title for the pull request'
24+
default: 'Update GitHub Actions'
25+
26+
commit-message:
27+
required: false
28+
description: 'The commit message for the update'
29+
default: 'Update GitHub Actions'
30+
31+
file-glob:
32+
required: false
33+
description: 'Glob for workflow files (relative to repo root)'
34+
default: '.github/**/*.yml'
35+
36+
prefixes:
37+
required: false
38+
description: 'Comma-separated list of action prefixes to include'
39+
default: 'actions'
40+
41+
auto-merge:
42+
required: false
43+
description: 'Whether a successful PR should be automatically merged'
44+
default: 'false'
45+
46+
runs:
47+
using: 'composite'
48+
steps:
49+
- name: Checkout and setup
50+
uses: alchemaxinc/composite-toolbox/checkout-and-setup@v1
51+
with:
52+
token: ${{ inputs.token }}
53+
54+
- name: Setup Python
55+
uses: actions/setup-python@v5
56+
env:
57+
PYTHON_VERSION: '3.14'
58+
with:
59+
python-version: ${{ env.PYTHON_VERSION }}
60+
cache-dependency-path: ${{ github.action_path }}/requirements.txt
61+
62+
- name: Install dependencies
63+
shell: bash
64+
run: python -m pip install -r ${{ github.action_path }}/requirements.txt
65+
66+
- name: Update action versions
67+
shell: bash
68+
env:
69+
GH_TOKEN: ${{ inputs.token }}
70+
run: |
71+
python ${{ github.action_path }}/cli.py \
72+
--root "$GITHUB_WORKSPACE" \
73+
--file-glob "${{ inputs.file-glob }}" \
74+
--prefixes "${{ inputs.prefixes }}"
75+
76+
- name: Check for changes
77+
id: check_changes
78+
uses: alchemaxinc/composite-toolbox/check-changes@v1
79+
with:
80+
files: '.github'
81+
82+
- name: Create Pull Request
83+
id: open-pr
84+
if: steps.check_changes.outputs.has_changes == 'true'
85+
uses: alchemaxinc/composite-toolbox/create-pr@v1
86+
with:
87+
token: ${{ inputs.token }}
88+
base-branch: ${{ inputs.base-branch }}
89+
branch-prefix: ${{ inputs.branch-prefix }}
90+
files: '.github'
91+
commit-message: ${{ inputs.commit-message }}
92+
pr-title: ${{ inputs.pr-title }}
93+
pr-body: |
94+
# :arrows_counterclockwise: GitHub Actions Update
95+
96+
This pull request automatically updates GitHub Actions used in `.github` workflows.
97+
98+
## :warning: Important Notes
99+
100+
- :robot: This PR was **automatically generated**
101+
- :mag: Please **review all changes** carefully before merging
102+
- :test_tube: Ensure all tests pass and functionality works as expected
103+
- :books: Check for any breaking changes or release notes for updated actions
104+
105+
## :rocket: Next Steps
106+
107+
1. Review the action changes above
108+
2. Run tests locally or wait for CI/CD to complete
109+
3. Merge when everything looks good!
110+
111+
---
112+
113+
*Generated by the GitHub Actions Update action* :sparkles:
114+
115+
- name: Auto-merge Pull Request
116+
if: inputs.auto-merge == 'true' && steps.open-pr.outcome == 'success'
117+
uses: alchemaxinc/composite-toolbox/merge-pr@v1
118+
with:
119+
token: ${{ inputs.token }}
120+
merge-method: 'squash'
121+
pull-request-number: ${{ steps.open-pr.outputs.pr_number }}

actions/cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import os
4+
from pathlib import Path
5+
6+
from update_actions.updater import update_actions
7+
8+
9+
def parse_args() -> argparse.Namespace:
10+
parser = argparse.ArgumentParser(
11+
description="Update GitHub Action uses entries to latest releases."
12+
)
13+
parser.add_argument(
14+
"--root",
15+
default=os.environ.get("GITHUB_WORKSPACE", "."),
16+
help="Repository root to scan (default: GITHUB_WORKSPACE or .)",
17+
)
18+
parser.add_argument(
19+
"--file-glob",
20+
default=".github/**/*.yml",
21+
help="Glob (relative to root) for workflow files.",
22+
)
23+
parser.add_argument(
24+
"--prefixes",
25+
default="actions",
26+
help="Comma-separated list of action prefixes to include.",
27+
)
28+
parser.add_argument(
29+
"--dry-run",
30+
action="store_true",
31+
help="Print planned updates without modifying files.",
32+
)
33+
return parser.parse_args()
34+
35+
36+
def main() -> int:
37+
args = parse_args()
38+
root = Path(args.root).resolve()
39+
prefixes = [p.strip() for p in args.prefixes.split(",") if p.strip()]
40+
return update_actions(
41+
root=root,
42+
file_glob=args.file_glob,
43+
prefixes=prefixes,
44+
dry_run=args.dry_run,
45+
)
46+
47+
48+
if __name__ == "__main__":
49+
raise SystemExit(main())

actions/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
PyYAML==6.0.1
2+
semver==3.0.2

actions/tests/test_github_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import unittest
2+
from unittest import mock
3+
4+
from update_actions import github_api
5+
6+
7+
class TestGithubApi(unittest.TestCase):
8+
def test_fetch_release_tags_filters_prerelease(self):
9+
completed = mock.Mock()
10+
completed.returncode = 0
11+
completed.stdout = "v1\tfalse\nv2\ttrue\n1.2.3\tfalse\n"
12+
completed.stderr = ""
13+
with mock.patch("subprocess.run", return_value=completed) as run:
14+
tags = github_api.fetch_release_tags("actions/checkout")
15+
run.assert_called_once()
16+
self.assertEqual(tags, ["v1", "1.2.3"])
17+
18+
def test_fetch_release_tags_handles_error(self):
19+
completed = mock.Mock()
20+
completed.returncode = 1
21+
completed.stdout = ""
22+
completed.stderr = "boom"
23+
with mock.patch("subprocess.run", return_value=completed):
24+
self.assertEqual(github_api.fetch_release_tags("actions/checkout"), [])
25+
26+
27+
if __name__ == "__main__":
28+
unittest.main()

actions/tests/test_scanner.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import tempfile
2+
import unittest
3+
from pathlib import Path
4+
5+
from update_actions import scanner
6+
7+
8+
class TestScanner(unittest.TestCase):
9+
def test_find_uses_nested(self):
10+
data = {
11+
"jobs": {
12+
"build": {
13+
"steps": [
14+
{"uses": "actions/checkout@v4"},
15+
{"run": "echo hi"},
16+
],
17+
"other": [{"steps": [{"uses": "actions/cache@v3"}]}],
18+
}
19+
}
20+
}
21+
self.assertEqual(
22+
scanner.find_uses(data),
23+
["actions/checkout@v4", "actions/cache@v3"],
24+
)
25+
26+
def test_find_uses_in_file_invalid_yaml(self):
27+
with tempfile.TemporaryDirectory() as tmpdir:
28+
path = Path(tmpdir) / "bad.yml"
29+
path.write_text(":::not yaml", encoding="utf-8")
30+
uses, text = scanner.find_uses_in_file(path)
31+
self.assertEqual(uses, [])
32+
self.assertEqual(text, ":::not yaml")
33+
34+
def test_apply_updates(self):
35+
text = """
36+
steps:
37+
- uses: actions/checkout@v3
38+
- uses: org/tool@1.2.3 # comment
39+
"""
40+
upgrades = {("actions/checkout", "v3"): "v4"}
41+
updated = scanner.apply_updates(text, upgrades)
42+
self.assertIn("actions/checkout@v4", updated)
43+
self.assertIn("org/tool@1.2.3", updated)
44+
45+
def test_collect_workflow_files(self):
46+
with tempfile.TemporaryDirectory() as tmpdir:
47+
root = Path(tmpdir)
48+
(root / ".github/workflows").mkdir(parents=True)
49+
50+
target = root / ".github/workflows" / "ci.yml"
51+
target.write_text("name: ci", encoding="utf-8")
52+
files = scanner.collect_workflow_files(root, ".github/**/*.yml")
53+
self.assertEqual(files, [target])
54+
55+
56+
if __name__ == "__main__":
57+
unittest.main()

actions/tests/test_updater.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import tempfile
2+
import unittest
3+
from pathlib import Path
4+
from unittest import mock
5+
6+
from update_actions import updater
7+
8+
9+
class TestUpdater(unittest.TestCase):
10+
def test_update_actions_writes_updates(self):
11+
with tempfile.TemporaryDirectory() as tmpdir:
12+
root = Path(tmpdir)
13+
workflow_dir = root / ".github/workflows"
14+
workflow_dir.mkdir(parents=True)
15+
workflow = workflow_dir / "ci.yml"
16+
workflow.write_text(
17+
"""
18+
jobs:
19+
build:
20+
steps:
21+
- uses: actions/checkout@v3
22+
""",
23+
encoding="utf-8",
24+
)
25+
26+
with mock.patch(
27+
"update_actions.updater.fetch_release_tags",
28+
return_value=["v2", "v4"],
29+
):
30+
updater.update_actions(
31+
root=root,
32+
file_glob=".github/**/*.yml",
33+
prefixes=["actions"],
34+
dry_run=False,
35+
)
36+
37+
updated = workflow.read_text(encoding="utf-8")
38+
self.assertIn("actions/checkout@v4", updated)
39+
40+
def test_update_actions_dry_run_no_write(self):
41+
with tempfile.TemporaryDirectory() as tmpdir:
42+
root = Path(tmpdir)
43+
workflow_dir = root / ".github/workflows"
44+
workflow_dir.mkdir(parents=True)
45+
workflow = workflow_dir / "ci.yml"
46+
original = """
47+
jobs:
48+
build:
49+
steps:
50+
- uses: actions/checkout@v3
51+
"""
52+
workflow.write_text(original, encoding="utf-8")
53+
54+
with mock.patch(
55+
"update_actions.updater.fetch_release_tags",
56+
return_value=["v4"],
57+
):
58+
updater.update_actions(
59+
root=root,
60+
file_glob=".github/**/*.yml",
61+
prefixes=["actions"],
62+
dry_run=True,
63+
)
64+
65+
self.assertEqual(workflow.read_text(encoding="utf-8"), original)
66+
67+
68+
if __name__ == "__main__":
69+
unittest.main()

0 commit comments

Comments
 (0)