feat(forgejo): set Actions secrets at user/org scope

- Add --scope {user,org,repo} (default user) to upsert Actions secrets\n- Keep repo support and add --org for org scope\n- Include security caveat in CLI help and warning output
This commit is contained in:
Jeremie Fraeys 2026-01-21 23:07:02 -05:00
parent 0c6d09abcd
commit 35796b1069
No known key found for this signature in database

View file

@ -8,7 +8,6 @@ import re
import subprocess
import sys
import tempfile
from typing import Optional, Tuple
from urllib.parse import urljoin
import requests
@ -38,8 +37,38 @@ def _api_headers(token: str) -> dict:
}
def get_repo_public_key(base_url: str, token: str, owner: str, repo: str) -> dict:
url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/public-key")
def _public_key_url(base_url: str, scope: str, owner: str | None, repo: str | None, org: str | None) -> str:
base = base_url.rstrip("/") + "/"
if scope == "repo":
if not owner or not repo:
raise ValueError("owner/repo required for repo scope")
return urljoin(base, f"api/v1/repos/{owner}/{repo}/actions/secrets/public-key")
if scope == "org":
if not org:
raise ValueError("org required for org scope")
return urljoin(base, f"api/v1/orgs/{org}/actions/secrets/public-key")
if scope == "user":
return urljoin(base, "api/v1/user/actions/secrets/public-key")
raise ValueError(f"unsupported scope: {scope}")
def _secret_url(base_url: str, scope: str, owner: str | None, repo: str | None, org: str | None, name: str) -> str:
base = base_url.rstrip("/") + "/"
if scope == "repo":
if not owner or not repo:
raise ValueError("owner/repo required for repo scope")
return urljoin(base, f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}")
if scope == "org":
if not org:
raise ValueError("org required for org scope")
return urljoin(base, f"api/v1/orgs/{org}/actions/secrets/{name}")
if scope == "user":
return urljoin(base, f"api/v1/user/actions/secrets/{name}")
raise ValueError(f"unsupported scope: {scope}")
def get_public_key(base_url: str, token: str, scope: str, owner: str | None, repo: str | None, org: str | None) -> dict:
url = _public_key_url(base_url, scope, owner, repo, org)
r = requests.get(url, headers=_api_headers(token), timeout=30)
# Some Forgejo/Gitea versions do not expose a public-key endpoint.
@ -56,13 +85,22 @@ def get_repo_public_key(base_url: str, token: str, owner: str, repo: str) -> dic
return data
def _put_repo_secret_auto(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None:
def _put_secret_auto(
base_url: str,
token: str,
scope: str,
owner: str | None,
repo: str | None,
org: str | None,
name: str,
value: str,
) -> None:
try:
public = get_repo_public_key(base_url, token, owner, repo)
public = get_public_key(base_url, token, scope, owner, repo, org)
encrypted = encrypt_secret(public["key"], value)
put_repo_secret(base_url, token, owner, repo, name, encrypted, public["key_id"])
put_secret(base_url, token, scope, owner, repo, org, name, encrypted, public["key_id"])
except NotImplementedError:
put_repo_secret_plain(base_url, token, owner, repo, name, value)
put_secret_plain(base_url, token, scope, owner, repo, org, name, value)
def encrypt_secret(public_key_b64: str, secret_value: str) -> str:
@ -110,8 +148,18 @@ def _ensure_ssh_keypair(private_key_path: str, comment: str, force: bool) -> Non
raise RuntimeError("ssh-keygen failed to generate keypair") from e
def put_repo_secret(base_url: str, token: str, owner: str, repo: str, name: str, encrypted_value_b64: str, key_id: str) -> None:
url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}")
def put_secret(
base_url: str,
token: str,
scope: str,
owner: str | None,
repo: str | None,
org: str | None,
name: str,
encrypted_value_b64: str,
key_id: str,
) -> None:
url = _secret_url(base_url, scope, owner, repo, org, name)
payload = {
"encrypted_value": encrypted_value_b64,
"key_id": key_id,
@ -120,15 +168,24 @@ def put_repo_secret(base_url: str, token: str, owner: str, repo: str, name: str,
r.raise_for_status()
def put_repo_secret_plain(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None:
url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}")
def put_secret_plain(
base_url: str,
token: str,
scope: str,
owner: str | None,
repo: str | None,
org: str | None,
name: str,
value: str,
) -> None:
url = _secret_url(base_url, scope, owner, repo, org, name)
# Some Forgejo/Gitea instances accept a plaintext secret value and encrypt server-side.
payload = {"data": value}
r = requests.put(url, headers=_api_headers(token), json=payload, timeout=30)
r.raise_for_status()
def _read_ansible_vault_yaml(vault_file: str, vault_password_file: Optional[str]) -> dict:
def _read_ansible_vault_yaml(vault_file: str, vault_password_file: str | None) -> dict:
if yaml is None:
raise RuntimeError("PyYAML is required to read tokens from ansible-vault. Install with: pip install pyyaml")
@ -150,7 +207,7 @@ def _read_ansible_vault_yaml(vault_file: str, vault_password_file: Optional[str]
return data
def _read_ansible_vault_text(vault_file: str, vault_password_file: Optional[str]) -> str:
def _read_ansible_vault_text(vault_file: str, vault_password_file: str | None) -> str:
cmd = ["ansible-vault", "view", vault_file]
if vault_password_file:
cmd.extend(["--vault-password-file", vault_password_file])
@ -164,7 +221,7 @@ def _read_ansible_vault_text(vault_file: str, vault_password_file: Optional[str]
return p.stdout
def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_file: Optional[str]) -> None:
def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_file: str | None) -> None:
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8", prefix="vault_", suffix=".yml") as tf:
tmp_path = tf.name
tf.write(plaintext)
@ -185,7 +242,7 @@ def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_fi
pass
def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vault_password_file: Optional[str]) -> None:
def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vault_password_file: str | None) -> None:
plaintext = _read_ansible_vault_text(vault_file, vault_password_file)
if plaintext and not plaintext.endswith("\n"):
plaintext += "\n"
@ -205,7 +262,7 @@ def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vau
_write_ansible_vault_text(vault_file, plaintext, vault_password_file)
def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_password_file: Optional[str]) -> Tuple[Optional[str], list[str]]:
def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_password_file: str | None) -> tuple[str | None, list[str]]:
data = _read_ansible_vault_yaml(vault_file, vault_password_file)
keys = sorted([str(k) for k in data.keys()])
val = data.get(token_var)
@ -215,13 +272,21 @@ def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_passwo
def args_parse():
p = argparse.ArgumentParser(description="Create/update a Forgejo Actions secret in a repository.")
p = argparse.ArgumentParser(
description="Create/update Forgejo Actions secrets for app_ssh_access (SSH register/deregister keys).",
epilog=(
"Security caveat: user/org scoped secrets are available to workflows in all repos under that user/org. "
"Only store deploy keys here if you trust the workflows/repositories that can access them."
),
)
p.add_argument("--base-url", default=os.environ.get("FORGEJO_BASE_URL", "https://git.jfraeys.com"))
p.add_argument("--token", default=os.environ.get("FORGEJO_API_TOKEN"))
p.add_argument("--vault-file", default="./secrets/vault.yml")
p.add_argument("--vault-password-file", default="./secrets/.vault_pass")
p.add_argument("--token-var", default="FORGEJO_API_TOKEN")
p.add_argument("--repo", required=True, help="owner/repo")
p.add_argument("--scope", choices=["user", "org", "repo"], default="user")
p.add_argument("--org", help="Organization name (required when --scope org)")
p.add_argument("--repo", help="owner/repo (required when --scope repo)")
p.add_argument("--force-generate-ssh-key", action="store_true")
p.add_argument(
"--generate-ssh-keys",
@ -259,11 +324,26 @@ def main() -> int:
)
return 2
if "/" not in args.repo:
print("--repo must be in the form owner/repo", file=sys.stderr)
return 2
owner: str | None = None
repo: str | None = None
org: str | None = None
owner, repo = args.repo.split("/", 1)
if args.scope == "repo":
if not args.repo or "/" not in args.repo:
print("--repo must be in the form owner/repo when --scope repo", file=sys.stderr)
return 2
owner, repo = args.repo.split("/", 1)
elif args.scope == "org":
if not args.org:
print("--org is required when --scope org", file=sys.stderr)
return 2
org = args.org
if args.scope in ("user", "org"):
print(
"WARNING: Using user/org scoped secrets. Ensure only trusted workflows/repositories can access them.",
file=sys.stderr,
)
if args.generate_ssh_keys:
_ensure_ssh_keypair(args.register_key_path, "actions-register", args.force_generate_ssh_key)
@ -286,8 +366,8 @@ def main() -> int:
with open(args.deregister_key_path, "r", encoding="utf-8") as f:
deregister_secret = f.read()
_put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_REGISTER", register_secret)
_put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_DEREGISTER", deregister_secret)
_put_secret_auto(args.base_url, token, args.scope, owner, repo, org, "SERVICE_SSH_KEY_REGISTER", register_secret)
_put_secret_auto(args.base_url, token, args.scope, owner, repo, org, "SERVICE_SSH_KEY_DEREGISTER", deregister_secret)
if args.update_vault_both_public_keys:
with open(args.register_key_path + ".pub", "r", encoding="utf-8") as f:
@ -306,7 +386,8 @@ def main() -> int:
args.vault_password_file,
)
print(f"Updated Actions secrets SERVICE_SSH_KEY_REGISTER and SERVICE_SSH_KEY_DEREGISTER for {owner}/{repo}")
target = "user" if args.scope == "user" else (f"org:{org}" if args.scope == "org" else f"repo:{owner}/{repo}")
print(f"Updated Actions secrets SERVICE_SSH_KEY_REGISTER and SERVICE_SSH_KEY_DEREGISTER for {target}")
return 0