IPCR Forge: org-root image names and per-repository push credentials

Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
This commit is contained in:
Worph
2026-10-05 11:37:33 +00:00
parent b65bb867c9
commit c961d74e0e
15 changed files with 1275 additions and 189 deletions
+85 -11
View File
@@ -19,7 +19,29 @@ import (
//go:embed all:example
var exampleFiles embed.FS
const tokenName = "ipcr-forge-bridge"
const (
tokenName = "ipcr-forge-bridge"
adminTokenName = "ipcr-forge-secrets"
)
// ensureOrg creates the public organisation org, owned by the admin, unless it exists.
func ensureOrg(g *gitea, org string) error {
code, err := g.call("GET", "/orgs/"+org, nil, nil)
if code == http.StatusOK {
return nil
}
if code != http.StatusNotFound {
return err
}
if _, err := g.call("POST", "/admin/users/"+g.owner+"/orgs", map[string]any{
"username": org, "visibility": "public",
"description": "Its public repositories publish images at the root of the forge's name.",
}, nil); err != nil {
return err
}
log.Printf("setup: created organisation %s", org)
return nil
}
// setup is the install step, run after the stack is up and on every start (each action is
// idempotent). It signs in once as the admin with the server's default app password, because
@@ -30,7 +52,10 @@ const tokenName = "ipcr-forge-bridge"
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
// created or repaired (matched by URL), never duplicated.
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
// SECRETS_DIR/gitea-admin-token write:repository + read:user: push secrets, name collisions.
// IPCR_ROOT_ORG the root organisation, created when absent (public, owned by the admin).
// EXAMPLE_REPO a public example repository with a workflow, created when absent, in
// the root organisation when there is one.
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
//
@@ -78,6 +103,36 @@ func setup() error {
log.Printf("setup: token %s written", tokenName)
}
// The admin-scoped token: sets each publishing repository's IPCR_PUSH_TOKEN secret, and checks
// short-path collisions against every user and organisation. Not public-only: an organisation
// may be private and still own the name.
adminTokenFile := filepath.Join(secrets, "gitea-admin-token")
if b, _ := os.ReadFile(adminTokenFile); len(strings.TrimSpace(string(b))) == 0 {
g.call("DELETE", "/users/"+g.owner+"/tokens/"+adminTokenName, nil, nil)
var t struct {
SHA1 string `json:"sha1"`
}
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
"name": adminTokenName, "scopes": []string{"write:repository", "read:user", "read:organization"},
}, &t); err != nil {
return fmt.Errorf("token: %w", err)
}
if err := writeSecret(adminTokenFile, t.SHA1); err != nil {
return err
}
log.Printf("setup: token %s written", adminTokenName)
}
// The root organisation: its repositories are also published at the root of the forge's name.
owner := g.owner
if org := strings.ToLower(env("IPCR_ROOT_ORG", "")); org != "" {
if err := ensureOrg(g, org); err != nil {
log.Printf("setup: organisation %s: %v", org, err)
} else {
owner = org
}
}
secretFile := filepath.Join(secrets, "hook-secret")
secret, _ := os.ReadFile(secretFile)
if strings.TrimSpace(string(secret)) == "" {
@@ -100,7 +155,7 @@ func setup() error {
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, name); err != nil {
if err := ensureExample(g, owner, name); err != nil {
log.Printf("setup: example repository: %v", err)
}
}
@@ -146,18 +201,22 @@ func ensureHook(g *gitea, url, secret string) error {
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
// repository that already has content is left as it is: it may be the user's by now.
func ensureExample(g *gitea, name string) error {
func ensureExample(g *gitea, owner, name string) error {
var r repo
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r)
switch {
case code == http.StatusNotFound:
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
create := "/admin/users/" + owner + "/repos"
if owner != g.owner {
create = "/orgs/" + owner + "/repos"
}
if _, err := g.call("POST", create, map[string]any{
"name": name, "private": false, "default_branch": "main",
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
}, &r); err != nil {
return err
}
log.Printf("setup: created %s/%s", g.owner, name)
log.Printf("setup: created %s/%s", owner, name)
case err != nil:
return err
case !r.Empty:
@@ -181,7 +240,7 @@ func ensureExample(g *gitea, name string) error {
if err != nil {
return err
}
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
_, err = g.call("POST", "/repos/"+owner+"/"+name+"/contents", map[string]any{
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
}, nil)
return err
@@ -226,9 +285,24 @@ func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
continue
}
if haveSecret {
// Keep it (and its secret); only follow a change of hosts.
_, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil)
return err
// Keep it, and its secret. Gitea regenerates the secret on every update, so only
// update when the hosts changed, and keep the secret it hands back.
if strings.Join(app.RedirectURIs, ",") == strings.Join(redirects, ",") {
return nil
}
var updated struct {
ClientSecret string `json:"client_secret"`
}
if _, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, &updated); err != nil {
return err
}
if updated.ClientSecret != "" {
if err := writeSecret(secretFile, updated.ClientSecret); err != nil {
return err
}
}
log.Printf("setup: OAuth2 app %s now for %s", oauthAppName, strings.Join(redirects, ", "))
return nil
}
if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil {
return err