From 04bb5bebe7d2eaae1e0b15af53840110a5c5eba4 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 05:01:59 +0000 Subject: [PATCH 1/5] fix(ci): serialize concurrent gh-pages deploys Two master pushes nine seconds apart raced JamesIves' force-push (`cannot lock ref` on gh-pages). The later SHA already deployed; this stops the next pair from failing the same way. Git-Session-Id: a7931123-aeaf-5b22-8f8c-14ffd00e1b70 --- .github/workflows/pages.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0fcdfee719..f93130cf15 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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. +# Do not cancel an in-progress master deploy; skip intermediate queued +# runs. Feature-branch builds may cancel superseded in-progress jobs. +concurrency: + 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 }}) From 7ebd2f1e02810993a98d0d66ba419ce815d8a5ae Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 20 Sep 2026 12:20:29 +0000 Subject: [PATCH 2/5] fix(ci): skip stale queued page deploys Git-Session-Id: c9063c51-a7c6-5f88-900d-6f733728b631 --- .github/workflows/pages.yml | 19 +++++++++-- scripts/check_deploy_is_current.py | 49 +++++++++++++++++++++++++++ tests/test_check_deploy_is_current.py | 21 ++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 scripts/check_deploy_is_current.py create mode 100644 tests/test_check_deploy_is_current.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f93130cf15..db1556c5a6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -13,8 +13,8 @@ permissions: # 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. -# Do not cancel an in-progress master deploy; skip intermediate queued -# runs. Feature-branch builds may cancel superseded in-progress jobs. +# 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: group: "${{ github.workflow }}-${{ github.ref }}" cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} @@ -47,9 +47,22 @@ jobs: run: | make build + - name: Check deployment is current + id: deploy-check + if: github.ref == 'refs/heads/master' + continue-on-error: true + run: | + python3 scripts/check_deploy_is_current.py \ + --repository "${{ github.repository }}" \ + --ref "heads/master" \ + --run-sha "${{ github.sha }}" \ + --token "${{ github.token }}" + - name: Deploy 🚀 uses: JamesIves/github-pages-deploy-action@v4 - if: github.ref == 'refs/heads/master' + if: >- + github.ref == 'refs/heads/master' && + steps.deploy-check.outcome == 'success' with: branch: gh-pages folder: _site diff --git a/scripts/check_deploy_is_current.py b/scripts/check_deploy_is_current.py new file mode 100644 index 0000000000..148fa673d8 --- /dev/null +++ b/scripts/check_deploy_is_current.py @@ -0,0 +1,49 @@ +#!/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 + + +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) + args = parser.parse_args() + + ref_sha = fetch_ref_sha(args.repository, args.ref, args.token) + if should_deploy(args.run_sha, ref_sha): + print(f"Current run {args.run_sha} still matches {args.ref}; deploying.") + return 0 + + print( + f"Skipping stale run {args.run_sha}; {args.ref} now points to {ref_sha}." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_deploy_is_current.py b/tests/test_check_deploy_is_current.py new file mode 100644 index 0000000000..9cbd7e38ab --- /dev/null +++ b/tests/test_check_deploy_is_current.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +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 From e8cfa560973b65253498f693d1d7a4a1998f2574 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 20 Sep 2026 12:21:10 +0000 Subject: [PATCH 3/5] style(ci): format deployment guard Git-Session-Id: c9063c51-a7c6-5f88-900d-6f733728b631 --- scripts/check_deploy_is_current.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/check_deploy_is_current.py b/scripts/check_deploy_is_current.py index 148fa673d8..97e71f4395 100644 --- a/scripts/check_deploy_is_current.py +++ b/scripts/check_deploy_is_current.py @@ -39,9 +39,7 @@ def main() -> int: print(f"Current run {args.run_sha} still matches {args.ref}; deploying.") return 0 - print( - f"Skipping stale run {args.run_sha}; {args.ref} now points to {ref_sha}." - ) + print(f"Skipping stale run {args.run_sha}; {args.ref} now points to {ref_sha}.") return 1 From 28f873a5399140b50c153bea5f7b7b866a1024d7 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 20 Sep 2026 13:21:39 +0000 Subject: [PATCH 4/5] fix(ci): fail deploy guard on operational errors Git-Session-Id: 67c03aff-1994-5132-ace7-1596a65310ad --- .github/workflows/pages.yml | 6 +-- scripts/check_deploy_is_current.py | 15 ++++--- tests/test_check_deploy_is_current.py | 56 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index db1556c5a6..1cc205031e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -50,19 +50,19 @@ jobs: - name: Check deployment is current id: deploy-check if: github.ref == 'refs/heads/master' - continue-on-error: true run: | python3 scripts/check_deploy_is_current.py \ --repository "${{ github.repository }}" \ --ref "heads/master" \ --run-sha "${{ github.sha }}" \ - --token "${{ github.token }}" + --token "${{ github.token }}" \ + --github-output "${{ github.output }}" - name: Deploy 🚀 uses: JamesIves/github-pages-deploy-action@v4 if: >- github.ref == 'refs/heads/master' && - steps.deploy-check.outcome == 'success' + steps.deploy-check.outputs.is-current == 'true' with: branch: gh-pages folder: _site diff --git a/scripts/check_deploy_is_current.py b/scripts/check_deploy_is_current.py index 97e71f4395..7411726888 100644 --- a/scripts/check_deploy_is_current.py +++ b/scripts/check_deploy_is_current.py @@ -6,6 +6,7 @@ import argparse import json import urllib.request +from pathlib import Path def fetch_ref_sha(repository: str, ref: str, token: str) -> str: @@ -32,15 +33,19 @@ def main() -> int: 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) - if should_deploy(args.run_sha, ref_sha): - print(f"Current run {args.run_sha} still matches {args.ref}; deploying.") - return 0 + 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") - print(f"Skipping stale run {args.run_sha}; {args.ref} now points to {ref_sha}.") - return 1 + 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__": diff --git a/tests/test_check_deploy_is_current.py b/tests/test_check_deploy_is_current.py index 9cbd7e38ab..a0214f454a 100644 --- a/tests/test_check_deploy_is_current.py +++ b/tests/test_check_deploy_is_current.py @@ -1,7 +1,9 @@ from __future__ import annotations import importlib.util +import json from pathlib import Path +from unittest.mock import patch MODULE_PATH = ( @@ -19,3 +21,57 @@ def test_deploys_when_run_is_still_ref_head() -> None: 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() From 12ce1de452b08c39308151456e721bd10947a8e3 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 20 Sep 2026 13:38:10 +0000 Subject: [PATCH 5/5] fix(ci): write deploy status to step output file Git-Session-Id: 589ba7eb-1021-5e40-bd2d-26bc761e1159 --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1cc205031e..6ff3bdf0f7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -56,7 +56,7 @@ jobs: --ref "heads/master" \ --run-sha "${{ github.sha }}" \ --token "${{ github.token }}" \ - --github-output "${{ github.output }}" + --github-output "$GITHUB_OUTPUT" - name: Deploy 🚀 uses: JamesIves/github-pages-deploy-action@v4