diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 6681826..1b8fa45 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -19,7 +19,7 @@ permissions: jobs: actionlint: - runs-on: gus-small + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v7.0.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13a3409..a7850cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ permissions: jobs: pytest: - runs-on: gus-small + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v7.0.1 diff --git a/README.md b/README.md index 776033a..5b2ad72 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Reusable GitHub Actions and workflows for Climate Resource repositories. | [bump-version](./bump-version) | Bump version with `uv version`, towncrier changelog, tag, dev-commit | | [draft-release](./draft-release) | Create a draft GitHub release with notes and artifacts | +[notify-flux](./notify-flux) notifies Flux after an image is published. + ## Reusable workflows | Workflow | Trigger | Description | diff --git a/changelog/31.feature.md b/changelog/31.feature.md new file mode 100644 index 0000000..388ddeb --- /dev/null +++ b/changelog/31.feature.md @@ -0,0 +1 @@ +Adds a shared action to notify Flux after publishing a service image. diff --git a/notify-flux/README.md b/notify-flux/README.md new file mode 100644 index 0000000..5b1271e --- /dev/null +++ b/notify-flux/README.md @@ -0,0 +1,20 @@ +# Notify Flux + +Calls a Flux `generic-hmac` Receiver after an image has been pushed. +The request body contains the published commit SHA. +The action needs Python 3 on the runner. + +```yaml +- name: Notify Flux + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: climate-resource/github-actions/notify-flux@v1 + with: + receiver-url: ${{ secrets.FLUX_RECEIVER_URL }} + receiver-token: ${{ secrets.FLUX_RECEIVER_TOKEN }} + sha: ${{ github.event.pull_request.head.sha || github.sha }} +``` + +Run this step only after a successful image push. +If both secrets are absent, the action reports that Flux will poll instead. +A partially configured receiver or a failed request fails the step. +The action does not print the URL, token, signature or response body. diff --git a/notify-flux/action.yml b/notify-flux/action.yml new file mode 100644 index 0000000..8f17bab --- /dev/null +++ b/notify-flux/action.yml @@ -0,0 +1,23 @@ +name: Notify Flux +description: Notify a generic-hmac Flux Receiver after an image is published. +inputs: + receiver-url: + description: Full Flux Receiver URL from FLUX_RECEIVER_URL. + required: false + receiver-token: + description: HMAC token from FLUX_RECEIVER_TOKEN. + required: false + sha: + description: Commit whose image was published. + required: true +runs: + using: composite + steps: + - name: Notify Flux + shell: bash + env: + FLUX_RECEIVER_URL: ${{ inputs.receiver-url }} + FLUX_RECEIVER_TOKEN: ${{ inputs.receiver-token }} + BUILD_SHA: ${{ inputs.sha }} + ACTION_PATH: ${{ github.action_path }} + run: python3 "$ACTION_PATH/notify.py" diff --git a/notify-flux/notify.py b/notify-flux/notify.py new file mode 100644 index 0000000..f0a968c --- /dev/null +++ b/notify-flux/notify.py @@ -0,0 +1,41 @@ +"""Send the published commit to a Flux generic-hmac Receiver.""" + +import hashlib +import hmac +import json +import os +import sys +import urllib.error +import urllib.request + + +def notify(url, token, sha): + if not url and not token: + print("::notice::Flux receiver secrets are absent. Flux will poll instead.") + return 0 + if not url or not token: + print("::error::Set both FLUX_RECEIVER_URL and FLUX_RECEIVER_TOKEN.") + return 1 + if not url.startswith("https://") or not sha: + print("::error::Flux notification requires an HTTPS receiver URL and a commit SHA.") + return 1 + body = json.dumps({"sha": sha}, separators=(",", ":")).encode() + signature = hmac.new(token.encode(), body, hashlib.sha256).hexdigest() + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json", "X-Signature": f"sha256={signature}"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30): + pass + except (urllib.error.URLError, TimeoutError): + print("::error::Flux notification failed. Check Receiver readiness and secret configuration.") + return 1 + print("Flux notified of the published image.") + return 0 + + +if __name__ == "__main__": + sys.exit(notify(os.getenv("FLUX_RECEIVER_URL", ""), os.getenv("FLUX_RECEIVER_TOKEN", ""), os.getenv("BUILD_SHA", ""))) diff --git a/tests/test_notify_flux.py b/tests/test_notify_flux.py new file mode 100644 index 0000000..b53f40e --- /dev/null +++ b/tests/test_notify_flux.py @@ -0,0 +1,52 @@ +import hashlib +import hmac +import importlib.util +import pathlib +import urllib.error +from unittest.mock import MagicMock + +import pytest + +spec = importlib.util.spec_from_file_location( + "notify_flux", pathlib.Path(__file__).resolve().parents[1] / "notify-flux/notify.py" +) +notify_flux = importlib.util.module_from_spec(spec) +spec.loader.exec_module(notify_flux) + + +def test_signs_exact_request_body(monkeypatch): + send = MagicMock() + monkeypatch.setattr(notify_flux.urllib.request, "urlopen", send) + assert notify_flux.notify("https://flux.example/hook", "test-token", "abc123") == 0 + request = send.call_args.args[0] + assert request.data == b'{"sha":"abc123"}' + assert request.method == "POST" + expected = hmac.new(b"test-token", request.data, hashlib.sha256).hexdigest() + assert request.get_header("X-signature") == f"sha256={expected}" + assert send.call_args.kwargs == {"timeout": 30} + + +@pytest.mark.parametrize("url,token,sha,result", [ + ("", "", "abc123", 0), + ("https://flux.example/hook", "", "abc123", 1), + ("", "test-token", "abc123", 1), + ("http://flux.example/hook", "test-token", "abc123", 1), + ("https://flux.example/hook", "test-token", "", 1), +]) +def test_invalid_or_absent_configuration_never_sends(monkeypatch, url, token, sha, result): + send = MagicMock() + monkeypatch.setattr(notify_flux.urllib.request, "urlopen", send) + assert notify_flux.notify(url, token, sha) == result + send.assert_not_called() + + +def test_failure_does_not_print_credentials(monkeypatch, capsys): + url = "https://flux.example/private-hook" + token = "test-private-token" + send = MagicMock(side_effect=urllib.error.URLError(url + token)) + monkeypatch.setattr(notify_flux.urllib.request, "urlopen", send) + assert notify_flux.notify(url, token, "abc123") == 1 + output = capsys.readouterr().out + assert url not in output + assert token not in output + assert "::error::" in output