feat(static-site-deploy): deploy with the workflow's OIDC identity
All checks were successful
Test / test (push) Successful in 9s

Without BUNNY_STORAGE_PASSWORD, the action now requests the running
workflow's OIDC identity token (Forgejo with enable-openid-connect,
GitHub with id-token: write) for the gateway's audience and starts the
deployment with it. It takes the Storage Zone password from the
gateway's answer, masks it in the log, plans and uploads with it, and
finalizes or aborts with a fresh token. A dry run in this mode stays
local. Password deployments work as before.

action.yml finds its script under FORGEJO_ACTION_PATH or
GITHUB_ACTION_PATH, so the action also runs on GitHub Actions.
This commit is contained in:
Eric Wendland 2026-10-02 23:29:58 +02:00
commit c242db40a1
4 changed files with 247 additions and 23 deletions

View file

@ -385,6 +385,38 @@ def deployment_gateway_request(
raise DeployError(f"Bunny deployment gateway failed: {exc.reason}") from exc
def request_identity_token(audience: str) -> 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 BUNNY_STORAGE_PASSWORD and no workflow identity: let the job "
"request an identity token (Forgejo: enable-openid-connect: true; "
"GitHub: permissions: id-token: write) or set the password"
)
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 urllib.request.urlopen(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 start_bunny_deployment(
endpoint: str,
zone: str,
@ -408,7 +440,12 @@ def start_bunny_deployment(
deployment = result.get("deployment")
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
raise DeployError("Bunny deployment gateway returned an invalid lease")
return deployment
# A deployment by repository identity receives the Storage Zone password.
return {
**deployment,
"storage_password": result.get("storage_password"),
"storage_endpoint": result.get("storage_endpoint"),
}
def finalize_bunny_deployment(
@ -464,6 +501,14 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
raise DeployError(
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
)
# Without the password, the workflow's identity token authenticates, and
# the gateway hands out the password for this deployment only.
identity = not args.password
if identity and not args.purge_endpoint:
raise DeployError(
"BUNNY_STORAGE_PASSWORD is required without the Gandalf gateway "
"(BUNNY_PURGE_ENDPOINT)"
)
edge_rules_path = getattr(args, "edge_rules", None)
if edge_rules_path and not args.purge_endpoint:
raise DeployError("edge rules can only be applied through BUNNY_PURGE_ENDPOINT")
@ -479,38 +524,73 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
f"{args.zone}\0{revision or ''}\0{manifest_sha256}\0{rules_sha256}".encode()
).hexdigest()
storage = BunnyStorage(args.zone, args.password, args.endpoint)
remote = parse_manifest(storage.get_manifest())
upload, delete = bunny_plan(files, remote)
result = {
"backend": "bunny",
"changed": bool(upload or delete),
"dry_run": args.dry_run,
"upload": upload,
"delete": delete,
"purged": False,
"deployment": None,
"edge_rules": [rule.get("name") for rule in edge_rules],
}
if args.dry_run:
return result
def report(upload: list[str] | None, delete: list[str] | None) -> dict[str, Any]:
return {
"backend": "bunny",
"changed": None if upload is None else bool(upload or delete),
"dry_run": args.dry_run,
"upload": upload,
"delete": delete,
"purged": False,
"deployment": None,
"edge_rules": [rule.get("name") for rule in edge_rules],
}
def plan(storage: BunnyStorage) -> tuple[list[str], list[str]]:
return bunny_plan(files, parse_manifest(storage.get_manifest()))
if identity:
if args.dry_run:
# Comparing with the deployed files needs the password.
return {**report(None, None), "files": len(files)}
storage = None
result = report(None, None)
else:
storage = BunnyStorage(args.zone, args.password, args.endpoint)
upload, delete = plan(storage)
result = report(upload, delete)
if args.dry_run:
return result
if args.purge_endpoint:
audience = gateway_origin(args.purge_endpoint)
def credential() -> str:
# Identity tokens are short-lived; each request gets a fresh one.
return args.password or request_identity_token(audience)
lease = start_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
credential(),
idempotency_key,
revision,
edge_rules,
)
try:
if identity:
password = str(lease.get("storage_password") or "")
if not password:
raise DeployError(
"the gateway handed out no storage credential for this deployment"
)
# Keeps the password out of the CI log.
print(f"::add-mask::{password}", flush=True)
storage = BunnyStorage(
args.zone,
password,
str(lease.get("storage_endpoint") or args.endpoint),
)
upload, delete = plan(storage)
result = report(upload, delete)
assert storage is not None
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
deployment = finalize_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
credential(),
str(lease["deployment_id"]),
idempotency_key,
revision,
@ -522,7 +602,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
abort_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
credential(),
str(lease["deployment_id"]),
idempotency_key,
)
@ -532,11 +612,13 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
result["deployment"] = deployment
result["purged"] = bool(deployment.get("purged"))
elif args.pull_zone_id:
assert storage is not None
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
result["purged"] = True
elif result["changed"]:
assert storage is not None
apply_bunny_plan(storage, files, upload, delete, args.workers)
return result