feat(app_deploy): add a CI action that deploys a pushed digest through Gandalf

This commit is contained in:
Eric Wendland 2026-10-07 11:43:02 +02:00
commit 512afe26ca
5 changed files with 436 additions and 0 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Eric Wendland
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
README.md Normal file
View file

@ -0,0 +1,89 @@
# App Deploy
A small, dependency-free action for GitHub Actions and Forgejo Actions that
deploys a pushed container image digest through the Gandalf gateway.
**How it works:**
1. The job's OIDC identity token proves its repository and branch, so the job needs no
secret.
2. The gateway accepts only digests of the image repository declared for the app.
3. It records the deployment and notifies the node the app is placed on.
4. The node applies it: backup gate, pull, restart, health check, and rollback on
failure.
5. This action follows the deployment and prints the node's progress.
6. It fails the job when the deployment is rolled back, fails, or gets no result within
`timeout`.
A newer deployment of the same app that supersedes this one ends the job with a notice.
The runner needs Bash and Python 3.11 or newer. The action builds nothing: build and
push the image first, then pass the pushed digest.
## GitHub Actions
```yaml
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v7
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
- name: Deploy through Gandalf
if: github.ref == 'refs/heads/main'
uses: tionis/app-deploy@<commit-sha>
with:
service: notes
image: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
```
## Forgejo Actions
The job needs `enable-openid-connect: true`. The build steps depend on the runner;
with Docker available:
```yaml
jobs:
publish:
runs-on: docker
enable-openid-connect: true
steps:
- uses: https://data.forgejo.org/actions/checkout@v6
- name: Build and push
id: build
run: |
image=forge.tionis.dev/${{ github.repository }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login forge.tionis.dev -u "${{ github.actor }}" --password-stdin
docker build -t "$image:latest" .
docker push "$image:latest"
echo "image=$(docker inspect --format '{{index .RepoDigests 0}}' "$image:latest")" >> "$GITHUB_OUTPUT"
- name: Deploy through Gandalf
uses: https://forge.tionis.dev/actions/app-deploy@<commit-sha>
with:
service: notes
image: ${{ steps.build.outputs.image }}
```
## Inputs
| Input | Default | Purpose |
| --- | --- | --- |
| `service` | required | The app's service id in Gandalf (a `web_app` with a `deploy` block). |
| `image` | required | The pushed image as `repository@sha256:<digest>`. |
| `gateway` | `https://gandalf.tionis.dev` | The Gandalf gateway. |
| `timeout` | `900` | Seconds to wait for the node's result. |
The design is in Gandalf's wiki, `admin/Reference/app-platform.md`.

40
action.yml Normal file
View file

@ -0,0 +1,40 @@
---
name: Deploy an app through Gandalf
description: >-
Ask the Gandalf gateway to run a pushed image digest on the node an app is
placed on, and wait until the node reports it live or rolled back.
inputs:
service:
description: The app's service id in Gandalf (a web_app with a deploy block).
required: true
image:
description: >-
The pushed image as repository@sha256:digest, e.g. from the push step's
digest output. Only digests of the app's declared repository deploy.
required: true
gateway:
description: The Gandalf gateway.
required: false
default: https://gandalf.tionis.dev
timeout:
description: Seconds to wait for the node's result.
required: false
default: "900"
runs:
using: composite
steps:
- name: Deploy through Gandalf
shell: bash
env:
APP_DEPLOY_SERVICE: ${{ inputs.service }}
APP_DEPLOY_IMAGE: ${{ inputs.image }}
APP_DEPLOY_GATEWAY: ${{ inputs.gateway }}
APP_DEPLOY_TIMEOUT: ${{ inputs.timeout }}
APP_DEPLOY_REVISION: ${{ github.sha }}
APP_DEPLOY_RUN: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }}
run: |
set -euo pipefail
# Forgejo and GitHub name the action's checkout differently.
python3 "${FORGEJO_ACTION_PATH:-$GITHUB_ACTION_PATH}/app_deploy.py"

167
app_deploy.py Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Deploy a pushed image digest through the Gandalf gateway and wait for it.
The job's OIDC identity token proves the repository and branch; the gateway
accepts only digests of the app's declared image repository, records the
deployment and notifies the node, which applies it with a health check and
rolls back on failure. This waits for that result and prints the node's
progress. Dependency-free; Python 3.11 or newer.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
DIGEST = re.compile(r"^[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9._-]+)+@sha256:[0-9a-f]{64}$")
POLL_SECONDS = 3
FINAL = {"live", "rolled_back", "failed", "superseded"}
class DeployError(Exception):
pass
def request_identity_token(audience: str, opener=urllib.request.urlopen) -> str:
"""The running workflow's OIDC identity token, from Forgejo or GitHub."""
url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "")
token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "")
if not url or not token:
raise DeployError(
"no workflow identity: let the job request an identity token "
"(Forgejo: enable-openid-connect: true; GitHub: permissions: id-token: write)"
)
separator = "&" if "?" in url else "?"
request = urllib.request.Request(
f"{url}{separator}audience={urllib.parse.quote(audience, safe='')}",
headers={"Authorization": f"bearer {token}", "Accept": "application/json"},
)
try:
with opener(request, timeout=30) as response:
value = json.loads(response.read()).get("value")
except urllib.error.HTTPError as exc:
exc.read()
raise DeployError(f"the workflow identity token could not be requested: HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, ValueError, AttributeError) as exc:
raise DeployError(f"the workflow identity token could not be requested: {exc}") from exc
if not isinstance(value, str) or not value:
raise DeployError("the CI provider returned no identity token")
return value
def gateway_request(
gateway: str,
path: str,
token: str,
body: dict[str, Any] | None = None,
opener=urllib.request.urlopen,
) -> dict[str, Any]:
request = urllib.request.Request(
f"{gateway.rstrip('/')}{path}",
data=None if body is None else json.dumps(body).encode(),
method="GET" if body is None else "POST",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
**({} if body is None else {"Content-Type": "application/json"}),
},
)
try:
with opener(request, timeout=60) as response:
return json.loads(response.read() or b"{}")
except urllib.error.HTTPError as exc:
detail = exc.read()
try:
message = str(json.loads(detail).get("error") or "")
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
message = ""
raise DeployError(f"Gandalf {request.method} {path} failed: HTTP {exc.code}{': ' + message if message else ''}") from exc
except urllib.error.URLError as exc:
raise DeployError(f"Gandalf could not be reached: {exc.reason}") from exc
def deploy(
service: str,
image: str,
gateway: str,
revision: str | None,
run: str,
timeout: float,
identity: Callable[[str], str],
call: Callable[..., dict[str, Any]],
log: Callable[[str], None] = print,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> int:
"""0 when live (or superseded by a newer deployment), 1 otherwise."""
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", service):
raise DeployError(f"{service!r} is not a service id")
if not DIGEST.match(image):
raise DeployError(f"{image!r} is not repository@sha256:<digest>; deploy the pushed digest, not a tag")
audience = urllib.parse.urlsplit(gateway)._replace(path="", query="", fragment="").geturl()
if revision is not None and not re.fullmatch(r"[A-Za-z0-9._-]{1,80}", revision):
revision = None
started = call(
gateway,
f"/v1/apps/{service}/deployments",
identity(audience),
{"image": image, "revision": revision, "idempotency_key": run[:200]},
)["deployment"]
deployment_id = started["deployment_id"]
log(
f"deployment {deployment_id}: {image} for {service} on {started['node']}"
+ ("" if started.get("notified") else " (the node finds it on its next poll)")
)
deadline = clock() + timeout
seen = None
current = started
while True:
status, detail = current["status"], current.get("detail")
if (status, detail) != seen:
seen = (status, detail)
log(f"{status}{': ' + detail if detail else ''}")
if status in FINAL:
break
if clock() >= deadline:
log(f"::error::no result from {started['node']} after {int(timeout)} s (status {status})")
return 1
sleep(POLL_SECONDS)
# A fresh identity token for each request: they expire within minutes.
current = call(
gateway, f"/v1/apps/{service}/deployments/{deployment_id}", identity(audience)
)["deployment"]
if status == "live":
return 0
if status == "superseded":
log("::notice::a newer deployment of this app superseded this one")
return 0
log(f"::error::{service} was {status.replace('_', ' ')} on {started['node']}")
return 1
def main() -> int:
try:
return deploy(
os.environ.get("APP_DEPLOY_SERVICE", "").strip(),
os.environ.get("APP_DEPLOY_IMAGE", "").strip(),
os.environ.get("APP_DEPLOY_GATEWAY", "https://gandalf.tionis.dev").strip(),
os.environ.get("APP_DEPLOY_REVISION", "").strip()[:40] or None,
os.environ.get("APP_DEPLOY_RUN", "manual").strip(),
float(os.environ.get("APP_DEPLOY_TIMEOUT", "900")),
request_identity_token,
gateway_request,
)
except (DeployError, KeyError, ValueError) as exc:
print(f"app-deploy: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

119
tests/test_app_deploy.py Normal file
View file

@ -0,0 +1,119 @@
import importlib.util
from pathlib import Path
import sys
import pytest
MODULE_PATH = Path(__file__).resolve().parents[1] / "app_deploy.py"
SPEC = importlib.util.spec_from_file_location("app_deploy_action", MODULE_PATH)
assert SPEC and SPEC.loader
app_deploy = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = app_deploy
SPEC.loader.exec_module(app_deploy)
IMAGE = f"forge.example/eric/notes@sha256:{'a' * 64}"
class FakeGateway:
"""Answers the start and then the given statuses, one per poll."""
def __init__(self, statuses, notified=True):
self.statuses = list(statuses)
self.requests = []
self.notified = notified
def __call__(self, gateway, path, token, body=None):
self.requests.append((path, token, body))
status = self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0]
detail = {"applying": "pulling", "rolled_back": "not healthy after 120 s"}.get(status)
return {
"deployment": {
"deployment_id": "d-1",
"node": "citadel",
"status": status,
"detail": detail,
"notified": self.notified,
}
}
class Clock:
def __init__(self):
self.now = 0.0
def sleep(self, seconds):
self.now += seconds
def run(statuses, timeout=60, **kwargs):
gateway, clock, lines, tokens = FakeGateway(statuses, **kwargs), Clock(), [], iter(range(1000))
code = app_deploy.deploy(
"notes",
IMAGE,
"https://gandalf.example/",
"abc1234",
"eric/notes:42:1",
timeout,
lambda audience: f"token-{next(tokens)}-{audience}",
gateway,
lines.append,
clock.sleep,
lambda: clock.now,
)
return code, gateway, lines
def test_a_live_deployment_succeeds_and_prints_the_progress():
code, gateway, lines = run(["pending", "applying", "applying", "live"])
assert code == 0
path, token, body = gateway.requests[0]
assert path == "/v1/apps/notes/deployments"
assert body == {"image": IMAGE, "revision": "abc1234", "idempotency_key": "eric/notes:42:1"}
assert token.endswith("-https://gandalf.example")
# Each request has its own identity token; every status is printed once.
assert len({token for _path, token, _body in gateway.requests}) == len(gateway.requests)
assert gateway.requests[1][0] == "/v1/apps/notes/deployments/d-1"
assert lines[1:] == ["pending", "applying: pulling", "live"]
def test_a_rollback_fails_the_job_with_the_reason():
code, _gateway, lines = run(["pending", "rolled_back"])
assert code == 1
assert "rolled_back: not healthy after 120 s" in lines
assert lines[-1] == "::error::notes was rolled back on citadel"
def test_a_superseded_deployment_ends_with_a_notice():
code, _gateway, lines = run(["pending", "superseded"])
assert code == 0
assert lines[-1].startswith("::notice::")
def test_waiting_ends_with_the_timeout():
code, _gateway, lines = run(["pending"], timeout=10)
assert code == 1
assert lines[-1].startswith("::error::no result from citadel after 10 s")
def test_an_unnotified_node_is_mentioned():
_code, _gateway, lines = run(["live"], notified=False)
assert "next poll" in lines[0]
@pytest.mark.parametrize(
"image",
[
"forge.example/eric/notes:latest",
"forge.example/eric/notes@sha256:" + "a" * 63,
"notes@sha256:" + "a" * 64,
],
)
def test_only_pushed_digests_deploy(image):
with pytest.raises(app_deploy.DeployError, match="digest"):
app_deploy.deploy("notes", image, "https://g", None, "r", 1, str, None)
def test_the_identity_token_needs_the_job_permission(monkeypatch):
monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_URL", raising=False)
with pytest.raises(app_deploy.DeployError, match="id-token: write"):
app_deploy.request_identity_token("https://g")