|
| 1 | +#!/usr/bin/env -S uv run --script |
| 2 | +# |
| 3 | +# /// script |
| 4 | +# requires-python = ">=3.12" |
| 5 | +# /// |
| 6 | +""" |
| 7 | +Decide whether a scheduled nightly fill has new commits to fill. |
| 8 | +
|
| 9 | +Usage: `check_new_commits.py` (all inputs come from the environment). |
| 10 | +
|
| 11 | +Compare the current commit against the head SHA of the last scheduled |
| 12 | +run of the release workflow that actually filled: the newest successful |
| 13 | +*scheduled* run that uploaded artifacts. A quiet nightly skips its |
| 14 | +build jobs yet still concludes as a successful run, so plain success is |
| 15 | +no evidence of a fill. Anchoring on real fills means a nightly that |
| 16 | +fails or skips keeps re-running until a fill goes green and no commit |
| 17 | +slips through unfilled; filtering on scheduled runs means manual |
| 18 | +releases never advance the nightly baseline. Manual |
| 19 | +(`workflow_dispatch`) runs always run. |
| 20 | +
|
| 21 | +A quiet stretch with no new commits still refreshes: once the last |
| 22 | +fill is `REFRESH_AGE` old -- or its artifact is no longer live -- the |
| 23 | +nightly re-runs anyway, so a live artifact always exists within the |
| 24 | +workflow's five-day retention and the release pipeline keeps getting |
| 25 | +exercised. |
| 26 | +
|
| 27 | +Read `GITHUB_EVENT_NAME`, `GITHUB_REPOSITORY` and `GITHUB_SHA` from the |
| 28 | +environment and query the GitHub API via the `gh` CLI (authenticated by |
| 29 | +`GH_TOKEN`). Print `run=true|false` to stdout for `$GITHUB_OUTPUT` and |
| 30 | +append the new-commit list (or a skip notice) to the |
| 31 | +`$GITHUB_STEP_SUMMARY` file. |
| 32 | +""" |
| 33 | + |
| 34 | +import json |
| 35 | +import os |
| 36 | +import subprocess |
| 37 | +import sys |
| 38 | +from datetime import datetime, timedelta, timezone |
| 39 | + |
| 40 | +WORKFLOW_FILE = "release_fixtures.yaml" |
| 41 | + |
| 42 | +# Re-run a quiet nightly once the last successful fill is this old, so |
| 43 | +# a fresh artifact is uploaded before the previous one lapses (the |
| 44 | +# workflow retains scheduled tarballs for five days). |
| 45 | +REFRESH_AGE = timedelta(days=4) |
| 46 | + |
| 47 | + |
| 48 | +def gh_api(path: str) -> str: |
| 49 | + """Return the stdout of `gh api <path>`, exiting non-zero on error.""" |
| 50 | + result = subprocess.run( |
| 51 | + ["gh", "api", path], capture_output=True, text=True |
| 52 | + ) |
| 53 | + if result.returncode != 0: |
| 54 | + print(f"Error: gh api {path} failed:", file=sys.stderr) |
| 55 | + print(result.stderr, file=sys.stderr) |
| 56 | + sys.exit(1) |
| 57 | + return result.stdout |
| 58 | + |
| 59 | + |
| 60 | +def last_real_nightly(repository: str) -> tuple[str, str, bool]: |
| 61 | + """ |
| 62 | + Return the head SHA, creation time and artifact liveness of the |
| 63 | + last scheduled run that actually filled. |
| 64 | +
|
| 65 | + A skipped nightly still concludes as a successful scheduled run, |
| 66 | + so taking the newest success as the baseline would let skip-runs |
| 67 | + keep resetting the refresh clock while the last real artifact |
| 68 | + quietly expires. Walk the recent successful scheduled runs and |
| 69 | + take the newest one that uploaded artifacts, reporting whether any |
| 70 | + of them is still live. Return empty strings when none exists yet. |
| 71 | + """ |
| 72 | + runs = json.loads( |
| 73 | + gh_api( |
| 74 | + f"repos/{repository}/actions/workflows/{WORKFLOW_FILE}" |
| 75 | + "/runs?status=success&event=schedule&per_page=10" |
| 76 | + ) |
| 77 | + )["workflow_runs"] |
| 78 | + for run in runs: |
| 79 | + artifacts = json.loads( |
| 80 | + gh_api(f"repos/{repository}/actions/runs/{run['id']}/artifacts") |
| 81 | + )["artifacts"] |
| 82 | + if artifacts: |
| 83 | + live = any(not a["expired"] for a in artifacts) |
| 84 | + return str(run["head_sha"]), str(run["created_at"]), live |
| 85 | + return "", "", False |
| 86 | + |
| 87 | + |
| 88 | +def is_stale(created_at: str) -> bool: |
| 89 | + """Return whether a run created at *created_at* is due a refresh.""" |
| 90 | + created = datetime.fromisoformat(created_at) |
| 91 | + return datetime.now(timezone.utc) - created >= REFRESH_AGE |
| 92 | + |
| 93 | + |
| 94 | +def commits_since(repository: str, last_sha: str, head_sha: str) -> list[str]: |
| 95 | + """Return `- <sha> <subject>` lines for commits after *last_sha*.""" |
| 96 | + compare = json.loads( |
| 97 | + gh_api(f"repos/{repository}/compare/{last_sha}...{head_sha}") |
| 98 | + ) |
| 99 | + return [ |
| 100 | + f"- {c['sha'][:7]} {(c['commit']['message'].splitlines() or [''])[0]}" |
| 101 | + for c in compare["commits"] |
| 102 | + ] |
| 103 | + |
| 104 | + |
| 105 | +def append_summary(text: str) -> None: |
| 106 | + """Append *text* to the GitHub step summary, or stderr if unset.""" |
| 107 | + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") |
| 108 | + if summary_path: |
| 109 | + with open(summary_path, "a") as f: |
| 110 | + f.write(text + "\n") |
| 111 | + else: |
| 112 | + print(text, file=sys.stderr) |
| 113 | + |
| 114 | + |
| 115 | +def main() -> None: |
| 116 | + """Print `run=true|false` and write the step summary.""" |
| 117 | + if os.environ["GITHUB_EVENT_NAME"] != "schedule": |
| 118 | + # Manual releases always run. |
| 119 | + print("run=true") |
| 120 | + return |
| 121 | + |
| 122 | + repository = os.environ["GITHUB_REPOSITORY"] |
| 123 | + head_sha = os.environ["GITHUB_SHA"] |
| 124 | + |
| 125 | + last_sha, last_created, artifact_live = last_real_nightly(repository) |
| 126 | + if last_sha: |
| 127 | + commits = commits_since(repository, last_sha, head_sha) |
| 128 | + else: |
| 129 | + # No prior successful nightly recorded; fill to get a baseline. |
| 130 | + commits = ["- (no previous successful nightly fill found)"] |
| 131 | + |
| 132 | + if commits: |
| 133 | + print("run=true") |
| 134 | + append_summary( |
| 135 | + "### Commits since last successful nightly fill\n" |
| 136 | + + "\n".join(commits) |
| 137 | + ) |
| 138 | + elif not artifact_live: |
| 139 | + print("run=true") |
| 140 | + append_summary( |
| 141 | + "No new commits, but no live fixture artifact exists; refilling." |
| 142 | + ) |
| 143 | + elif is_stale(last_created): |
| 144 | + print("run=true") |
| 145 | + append_summary( |
| 146 | + "No new commits, but the last nightly fill is older than " |
| 147 | + f"{REFRESH_AGE.days} days; refreshing before its artifact " |
| 148 | + "retention lapses." |
| 149 | + ) |
| 150 | + else: |
| 151 | + print("run=false") |
| 152 | + append_summary( |
| 153 | + "No new commits since the last successful nightly fill; skipping." |
| 154 | + ) |
| 155 | + |
| 156 | + |
| 157 | +if __name__ == "__main__": |
| 158 | + main() |
0 commit comments