Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ permissions:
contents: write
pages: write

# Serialize deploys to gh-pages. Concurrent master pushes race the
# JamesIves force-push (cannot lock ref) — run 35056225053 failed at
# 2026-09-16T04:38Z while the next SHA's deploy succeeded 9s later.
# GitHub does not guarantee FIFO ordering within a concurrency group, so
# the deploy step also verifies that this run still targets the ref head.
concurrency:
Comment thread
TimeToBuildBob marked this conversation as resolved.
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

jobs:
build-and-deploy:
name: Build and deploy (ruby-${{ matrix.ruby_version }})
Expand Down Expand Up @@ -38,9 +47,22 @@ jobs:
run: |
make build

- name: Check deployment is current
id: deploy-check
if: github.ref == 'refs/heads/master'
run: |
python3 scripts/check_deploy_is_current.py \
--repository "${{ github.repository }}" \
--ref "heads/master" \
--run-sha "${{ github.sha }}" \
--token "${{ github.token }}" \
--github-output "$GITHUB_OUTPUT"
Comment thread
TimeToBuildBob marked this conversation as resolved.

- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4
if: github.ref == 'refs/heads/master'
if: >-
github.ref == 'refs/heads/master' &&
steps.deploy-check.outputs.is-current == 'true'
with:
branch: gh-pages
folder: _site
Expand Down
52 changes: 52 additions & 0 deletions scripts/check_deploy_is_current.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Exit successfully only when this workflow run still targets the ref head."""

from __future__ import annotations

import argparse
import json
import urllib.request
from pathlib import Path


def fetch_ref_sha(repository: str, ref: str, token: str) -> str:
request = urllib.request.Request(
f"https://api.github.com/repos/{repository}/git/ref/{ref}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request) as response:
payload = json.load(response)
return str(payload["object"]["sha"])


def should_deploy(run_sha: str, ref_sha: str) -> bool:
return run_sha == ref_sha


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repository", required=True)
parser.add_argument("--ref", required=True)
parser.add_argument("--run-sha", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--github-output", type=Path, required=True)
args = parser.parse_args()

ref_sha = fetch_ref_sha(args.repository, args.ref, args.token)
is_current = should_deploy(args.run_sha, ref_sha)
with args.github_output.open("a", encoding="utf-8") as output:
output.write(f"is-current={str(is_current).lower()}\n")

if is_current:
print(f"Current run {args.run_sha} still matches {args.ref}; deploying.")
else:
print(f"Skipping stale run {args.run_sha}; {args.ref} now points to {ref_sha}.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
77 changes: 77 additions & 0 deletions tests/test_check_deploy_is_current.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from __future__ import annotations

import importlib.util
import json
from pathlib import Path
from unittest.mock import patch


MODULE_PATH = (
Path(__file__).resolve().parents[1] / "scripts" / "check_deploy_is_current.py"
)
spec = importlib.util.spec_from_file_location("check_deploy_is_current", MODULE_PATH)
assert spec and spec.loader
check = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check)


def test_deploys_when_run_is_still_ref_head() -> None:
assert check.should_deploy("new-sha", "new-sha") is True


def test_skips_older_run_after_newer_push() -> None:
assert check.should_deploy("old-sha", "new-sha") is False


def test_stale_run_sets_false_output_without_failing(tmp_path: Path) -> None:
output = tmp_path / "github-output"
argv = [
"check_deploy_is_current.py",
"--repository",
"owner/repo",
"--ref",
"heads/master",
"--run-sha",
"old-sha",
"--token",
"token",
"--github-output",
str(output),
]

with patch.object(check, "fetch_ref_sha", return_value="new-sha"), patch(
"sys.argv", argv
):
assert check.main() == 0

assert output.read_text(encoding="utf-8") == "is-current=false\n"


def test_api_error_propagates_and_does_not_write_output(tmp_path: Path) -> None:
output = tmp_path / "github-output"
argv = [
"check_deploy_is_current.py",
"--repository",
"owner/repo",
"--ref",
"heads/master",
"--run-sha",
"run-sha",
"--token",
"token",
"--github-output",
str(output),
]

malformed_response = json.JSONDecodeError("bad", "", 0)
with patch.object(
check, "fetch_ref_sha", side_effect=malformed_response
), patch("sys.argv", argv):
try:
check.main()
except json.JSONDecodeError:
pass
else:
raise AssertionError("malformed API responses must fail the guard")

assert not output.exists()
Loading