Skip to content

Commit 6ef33c9

Browse files
cnolanminichclaude
andcommitted
Add create-pinned-release workflow and standalone pin_actions script
Adds a workflow_dispatch workflow that creates a '-pinned' variant of an existing release tag: checks out the base tag, pins all third-party GitHub Action references to commit SHAs (scripts/pin_actions.py), rewrites dagster-cloud-action self-references to the new tag via the existing release.py update-action-version-references command, and pushes the result as a new tag (default: <base_tag>-pinned). The base tag is never modified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c1051c6 commit 6ef33c9

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Create Pinned Release
2+
# Creates a "-pinned" variant of an existing release tag: checks out the base
3+
# tag, pins all third-party action references to commit SHAs, rewrites
4+
# dagster-cloud-action self-references to the new tag, and pushes the result
5+
# as a new tag. The base tag is never modified.
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
base_tag:
10+
description: "Existing release tag to pin (e.g. v1.13.6)"
11+
required: true
12+
new_tag:
13+
description: "Tag to create (default: <base_tag>-pinned)"
14+
required: false
15+
16+
permissions:
17+
contents: write
18+
19+
jobs:
20+
create-pinned-release:
21+
runs-on: ubuntu-latest
22+
steps:
23+
# Check out the workflow's own ref first: base tags cut before this
24+
# workflow landed do not contain scripts/pin_actions.py.
25+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
26+
with:
27+
fetch-depth: 0
28+
29+
- name: Set up Python
30+
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4
31+
with:
32+
python-version: "3.11"
33+
34+
- name: Install dependencies
35+
run: |
36+
python -m pip install --upgrade pip
37+
pip install -r requirements.txt
38+
pip install typer rich
39+
40+
- name: Compute new tag
41+
id: tag
42+
run: |
43+
NEW_TAG="${{ inputs.new_tag }}"
44+
if [ -z "$NEW_TAG" ]; then
45+
NEW_TAG="${{ inputs.base_tag }}-pinned"
46+
fi
47+
if git rev-parse "refs/tags/$NEW_TAG" >/dev/null 2>&1; then
48+
echo "::error::Tag $NEW_TAG already exists"
49+
exit 1
50+
fi
51+
echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT"
52+
# release.py update-action-version-references prepends "v" itself
53+
echo "new_version=${NEW_TAG#v}" >> "$GITHUB_OUTPUT"
54+
55+
- name: Stash pin script and check out base tag
56+
run: |
57+
cp scripts/pin_actions.py /tmp/pin_actions.py
58+
git checkout "${{ inputs.base_tag }}"
59+
60+
- name: Pin third-party actions to SHAs
61+
env:
62+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63+
run: python /tmp/pin_actions.py --repo-dir "$GITHUB_WORKSPACE"
64+
65+
# Uses the base tag's own release.py, the same code path the release
66+
# pipeline runs (present in all recent releases).
67+
- name: Update self-references to new tag
68+
run: |
69+
python scripts/release.py update-action-version-references \
70+
"${{ steps.tag.outputs.new_version }}"
71+
72+
- name: Verify all third-party refs are pinned
73+
run: python /tmp/pin_actions.py --check --repo-dir "$GITHUB_WORKSPACE"
74+
75+
- name: Commit and push tag
76+
run: |
77+
git config user.name "github-actions[bot]"
78+
git config user.email "github-actions[bot]@users.noreply.github.com"
79+
git add .
80+
git diff --cached --quiet && { echo "::error::No changes produced"; exit 1; }
81+
git commit -m "Pinned release ${{ steps.tag.outputs.new_tag }} (base: ${{ inputs.base_tag }})"
82+
git tag -a "${{ steps.tag.outputs.new_tag }}" \
83+
-m "Pinned release ${{ steps.tag.outputs.new_tag }} (base: ${{ inputs.base_tag }})"
84+
git push origin "${{ steps.tag.outputs.new_tag }}"
85+
86+
- name: Summary
87+
run: |
88+
echo "Created tag ${{ steps.tag.outputs.new_tag }} from ${{ inputs.base_tag }}" >> "$GITHUB_STEP_SUMMARY"
89+
echo '```' >> "$GITHUB_STEP_SUMMARY"
90+
git show --stat HEAD >> "$GITHUB_STEP_SUMMARY"
91+
echo '```' >> "$GITHUB_STEP_SUMMARY"

scripts/pin_actions.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
"""Pin third-party GitHub Action references to commit SHAs.
3+
4+
Walks YAML files in the repo, finds `uses: owner/repo@tag` references,
5+
resolves each tag to a SHA via the GitHub API, and replaces with
6+
`uses: owner/repo@SHA # tag`.
7+
8+
Skips local path references (./), self-references (dagster-io/dagster-cloud-action),
9+
and references already pinned to a SHA. Re-running is a no-op.
10+
11+
Usage:
12+
python scripts/pin_actions.py [--repo-dir DIR] [--dry-run]
13+
python scripts/pin_actions.py --check [--repo-dir DIR]
14+
15+
--check does not modify anything: it parses every YAML file (rather than
16+
regex-scanning it) and exits non-zero if any third-party `uses:` reference is
17+
not pinned to a full commit SHA. Used as a verification gate after pinning.
18+
19+
Authentication uses GITHUB_TOKEN from the environment if set (recommended in CI
20+
to avoid rate limits); falls back to unauthenticated requests.
21+
"""
22+
23+
import argparse
24+
import os
25+
import re
26+
import sys
27+
from pathlib import Path
28+
29+
import requests
30+
import yaml
31+
32+
SELF_REPO = "dagster-io/dagster-cloud-action"
33+
34+
# Matches: uses: owner/repo@ref or uses: owner/repo/path@ref
35+
# Captures: (1) prefix "uses: ", (2) full action path, (3) owner/repo, (4) optional /subpath, (5) ref
36+
ACTION_USES_RE = re.compile(
37+
r"(uses:\s+)" # group 1: "uses:" + whitespace
38+
r"(([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)" # group 2 start + group 3: owner/repo
39+
r"(/[^\s@]*)?)" # group 4: optional /subpath; closes group 2
40+
r"@([^\s#]+)" # group 5: ref (everything after @ until whitespace or #)
41+
)
42+
43+
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
44+
45+
46+
def resolve_ref_to_sha(owner_repo: str, ref: str, github_token: str | None) -> str:
47+
"""Resolve a GitHub action ref (tag/branch) to its commit SHA."""
48+
headers = {"Accept": "application/vnd.github+json"}
49+
if github_token:
50+
headers["Authorization"] = f"Bearer {github_token}"
51+
response = requests.get(
52+
f"https://api.github.com/repos/{owner_repo}/commits/{ref}",
53+
headers=headers,
54+
)
55+
response.raise_for_status()
56+
return response.json()["sha"]
57+
58+
59+
def find_yaml_files(repo_dir: str) -> list:
60+
# Note: pathlib.rglob (unlike glob.glob) descends into hidden directories,
61+
# which matters for .github/workflows/.
62+
root = Path(repo_dir)
63+
return sorted(
64+
str(p)
65+
for p in set(root.rglob("*.yml")) | set(root.rglob("*.yaml"))
66+
if ".git" not in p.relative_to(root).parts # skip the git object dir itself
67+
)
68+
69+
70+
def collect_refs_to_resolve(yaml_files: list) -> dict:
71+
"""Collect unique (owner/repo, ref) pairs that need pinning."""
72+
refs_to_resolve = {}
73+
for filepath in yaml_files:
74+
with open(filepath, encoding="utf-8") as f:
75+
content = f.read()
76+
for match in ACTION_USES_RE.finditer(content):
77+
owner_repo = match.group(3)
78+
ref = match.group(5)
79+
if owner_repo == SELF_REPO:
80+
continue
81+
if SHA_RE.match(ref):
82+
continue
83+
refs_to_resolve.setdefault((owner_repo, ref), "")
84+
return refs_to_resolve
85+
86+
87+
def pin_third_party_actions_to_shas(
88+
repo_dir: str, github_token: str | None, dry_run: bool = False
89+
) -> int:
90+
"""Pin third-party action refs in repo_dir. Returns the number of files changed."""
91+
yaml_files = find_yaml_files(repo_dir)
92+
refs_to_resolve = collect_refs_to_resolve(yaml_files)
93+
94+
for owner_repo, ref in refs_to_resolve:
95+
sha = resolve_ref_to_sha(owner_repo, ref, github_token)
96+
refs_to_resolve[(owner_repo, ref)] = sha
97+
print(f"Resolved {owner_repo}@{ref} -> {sha}")
98+
99+
changed = 0
100+
for filepath in yaml_files:
101+
with open(filepath, encoding="utf-8") as f:
102+
content = f.read()
103+
104+
new_content = content
105+
for (owner_repo, ref), sha in refs_to_resolve.items():
106+
# Replace: owner/repo@ref and owner/repo/path@ref
107+
# With: owner/repo@SHA # ref
108+
new_content = re.sub(
109+
rf"(uses:\s+{re.escape(owner_repo)}(?:/[^\s@]*)?)@{re.escape(ref)}",
110+
rf"\1@{sha} # {ref}",
111+
new_content,
112+
)
113+
114+
if new_content != content:
115+
changed += 1
116+
if dry_run:
117+
print(f"Would pin action refs in {filepath}")
118+
else:
119+
with open(filepath, "w", encoding="utf-8") as f:
120+
f.write(new_content)
121+
print(f"Pinned action refs in {filepath}")
122+
123+
return changed
124+
125+
126+
class _LenientLoader(yaml.SafeLoader):
127+
"""SafeLoader that tolerates unknown tags (e.g. GitLab's !reference)."""
128+
129+
130+
_LenientLoader.add_multi_constructor("", lambda loader, suffix, node: None)
131+
132+
133+
def _iter_uses_values(node):
134+
"""Yield every value of a `uses` key anywhere in a parsed YAML tree.
135+
136+
Walking the whole tree (rather than assuming workflow/composite-action
137+
schemas) covers steps, reusable-workflow jobs, and composite actions alike.
138+
"""
139+
if isinstance(node, dict):
140+
for key, value in node.items():
141+
if key == "uses" and isinstance(value, str):
142+
yield value
143+
else:
144+
yield from _iter_uses_values(value)
145+
elif isinstance(node, list):
146+
for item in node:
147+
yield from _iter_uses_values(item)
148+
149+
150+
def check_all_actions_pinned(repo_dir: str) -> int:
151+
"""Verify every third-party `uses:` ref is SHA-pinned. Returns number of failures.
152+
153+
Parses YAML properly, so quoted refs, anchors, and odd formatting that a
154+
regex scan could miss are all covered. Local paths (./) and
155+
dagster-cloud-action self-references are allowed; docker:// refs without a
156+
digest are reported as warnings (they cannot be SHA-pinned by this script).
157+
"""
158+
failures = 0
159+
for filepath in find_yaml_files(repo_dir):
160+
with open(filepath, encoding="utf-8") as f:
161+
try:
162+
documents = list(yaml.load_all(f, Loader=_LenientLoader))
163+
except yaml.YAMLError as err:
164+
print(f"FAIL {filepath}: could not parse YAML: {err}")
165+
failures += 1
166+
continue
167+
168+
for document in documents:
169+
for uses in _iter_uses_values(document):
170+
if uses.startswith("./"):
171+
continue
172+
if uses.startswith("docker://"):
173+
if "@sha256:" not in uses:
174+
print(f"WARN {filepath}: docker ref without digest: {uses}")
175+
continue
176+
path_part, _, ref = uses.partition("@")
177+
owner_repo = "/".join(path_part.split("/")[:2])
178+
if owner_repo == SELF_REPO:
179+
continue
180+
if not SHA_RE.match(ref):
181+
print(f"FAIL {filepath}: unpinned third-party ref: {uses}")
182+
failures += 1
183+
return failures
184+
185+
186+
def main() -> int:
187+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
188+
parser.add_argument(
189+
"--repo-dir",
190+
default=os.path.join(os.path.dirname(__file__), ".."),
191+
help="Repository root to scan (default: this repo)",
192+
)
193+
parser.add_argument(
194+
"--dry-run",
195+
action="store_true",
196+
help="Resolve and report, but do not modify files",
197+
)
198+
parser.add_argument(
199+
"--check",
200+
action="store_true",
201+
help="Verify (via YAML parsing) that all third-party refs are SHA-pinned",
202+
)
203+
args = parser.parse_args()
204+
205+
if args.check:
206+
failures = check_all_actions_pinned(os.path.abspath(args.repo_dir))
207+
if failures:
208+
print(f"Check failed: {failures} unpinned third-party reference(s)")
209+
return 1
210+
print("Check passed: all third-party action refs are pinned to SHAs")
211+
return 0
212+
213+
github_token = os.environ.get("GITHUB_TOKEN")
214+
if not github_token:
215+
print("Warning: GITHUB_TOKEN not set; using unauthenticated API requests", file=sys.stderr)
216+
217+
changed = pin_third_party_actions_to_shas(
218+
os.path.abspath(args.repo_dir), github_token, dry_run=args.dry_run
219+
)
220+
print(f"{'Would change' if args.dry_run else 'Changed'} {changed} file(s)")
221+
return 0
222+
223+
224+
if __name__ == "__main__":
225+
sys.exit(main())

0 commit comments

Comments
 (0)