Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/actionlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ permissions:

jobs:
actionlint:
runs-on: gus-small
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/[email protected]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ permissions:

jobs:
pytest:
runs-on: gus-small
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/[email protected]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions changelog/31.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds a shared action to notify Flux after publishing a service image.
20 changes: 20 additions & 0 deletions notify-flux/README.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions notify-flux/action.yml
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions notify-flux/notify.py
Original file line number Diff line number Diff line change
@@ -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", "")))
52 changes: 52 additions & 0 deletions tests/test_notify_flux.py
Original file line number Diff line number Diff line change
@@ -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