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

@ -1,5 +1,6 @@
from contextlib import redirect_stdout
import importlib.util
from io import BytesIO
from io import BytesIO, StringIO
import json
from pathlib import Path
import sys
@ -407,3 +408,95 @@ class EdgeRuleTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
def identity_args(root: Path) -> Any:
args = gateway_args(root, None)
args.password = ""
args.idempotency_key = "test-run-identity"
return args
class IdentityTests(unittest.TestCase):
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage")
@mock.patch.object(static_site, "request_identity_token")
def test_identity_deploys_with_the_password_the_gateway_hands_out(
self,
request_token: mock.Mock,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None:
request_token.side_effect = ["jwt-start", "jwt-finalize"]
start_deployment.return_value = {
"deployment_id": "deployment-id",
"storage_password": "zone-password",
"storage_endpoint": "la.storage.bunnycdn.com",
}
storage_class.return_value.get_manifest.return_value = None
finalize_deployment.return_value = {"purged": True}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
output = StringIO()
with redirect_stdout(output), mock.patch.object(static_site, "apply_bunny_plan"):
result = static_site.deploy_bunny(identity_args(root))
request_token.assert_called_with("https://purge.example")
self.assertEqual(start_deployment.call_args.args[2], "jwt-start")
self.assertEqual(finalize_deployment.call_args.args[2], "jwt-finalize")
storage_class.assert_called_once_with(
"example-org", "zone-password", "la.storage.bunnycdn.com"
)
self.assertIn("::add-mask::zone-password", output.getvalue())
self.assertNotIn("zone-password", json.dumps(result))
self.assertTrue(result["purged"])
def test_identity_needs_the_gateway(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
args = identity_args(root)
args.purge_endpoint = ""
with self.assertRaisesRegex(DeployError, "required without the Gandalf gateway"):
static_site.deploy_bunny(args)
@mock.patch.object(static_site, "start_bunny_deployment")
def test_identity_dry_run_stays_local(self, start_deployment: mock.Mock) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
args = identity_args(root)
args.dry_run = True
result = static_site.deploy_bunny(args)
start_deployment.assert_not_called()
self.assertEqual(result["files"], 2)
self.assertIsNone(result["changed"])
def test_identity_token_request_explains_missing_permissions(self) -> None:
with mock.patch.dict(static_site.os.environ, {}, clear=True):
with self.assertRaisesRegex(DeployError, "id-token: write"):
static_site.request_identity_token("https://gandalf.example")
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_identity_token_request_asks_for_the_gateway_audience(
self, urlopen: mock.Mock
) -> None:
urlopen.return_value.__enter__.return_value.read.return_value = (
b'{"count": 1, "value": "jwt"}'
)
environment = {
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://ci.example/token?api-version=2",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token",
}
with mock.patch.dict(static_site.os.environ, environment, clear=True):
token = static_site.request_identity_token("https://gandalf.example")
self.assertEqual(token, "jwt")
request = urlopen.call_args.args[0]
self.assertEqual(
request.full_url,
"https://ci.example/token?api-version=2&audience=https%3A%2F%2Fgandalf.example",
)
self.assertEqual(request.get_header("Authorization"), "bearer request-token")