gitmote

gitmote / internal / ci / envsecrets.go  ·  history  ·  branches

2955 bytes · raw

package ci

import (
	"sort"
	"strings"
)

// envSecretPrefix is the process-env prefix that seeds a repo's CI secrets
// without the UI/DB path: GITMOTE_REPO_SECRET_<REPO>__<NAME>=value. It is
// deliberately distinct from the runner-facing secretEnvPrefix. These values are
// operator-supplied host config — never persisted, never encrypted, and needing
// no master key (GITMOTE_CI_SECRET_KEY_V<n>): the host env is already the CI
// trust boundary (docs/architecture/safety.md §7). This is what lets the
// fully-automated self-deploy keep its secrets in a gitignored .env instead of
// the Secrets UI (docs/ops.md).
const envSecretPrefix = "GITMOTE_REPO_SECRET_"

// EnvSecrets maps a normalized repo key (see repoKey) to that repo's env-seeded
// CI secrets (name→value). Build it once at boot with LoadEnvSecrets; resolve it
// per dispatch with For.
type EnvSecrets map[string]map[string]string

// LoadEnvSecrets parses the GITMOTE_REPO_SECRET_<REPO>__<NAME> entries out of
// environ (os.Environ() form, "KEY=VALUE"). Entries without the prefix, without
// the "__" repo/name delimiter, or with an empty repo or name are ignored. The
// repo segment is normalized with repoKey so it matches For(repoName) regardless
// of the casing written in the env; the name is kept verbatim (secret names are
// case-sensitive, matching ${{ secrets.NAME }}). A later duplicate key wins.
func LoadEnvSecrets(environ []string) EnvSecrets {
	out := EnvSecrets{}
	for _, kv := range environ {
		key, val, _ := strings.Cut(kv, "=")
		rest, ok := strings.CutPrefix(key, envSecretPrefix)
		if !ok {
			continue
		}
		repo, name, ok := strings.Cut(rest, "__")
		if !ok || repo == "" || name == "" {
			continue
		}
		rk := repoKey(repo)
		m := out[rk]
		if m == nil {
			m = map[string]string{}
			out[rk] = m
		}
		m[name] = val
	}
	return out
}

// For returns the env-seeded secrets for repoName, or nil if none. repoName is
// normalized to the env-key form (uppercased, '-' → '_'), so a repo "my-repo"
// matches GITMOTE_REPO_SECRET_MY_REPO__NAME. A repo name containing "__" can't be
// addressed this way; a single '_' is fine.
func (e EnvSecrets) For(repoName string) map[string]string {
	return e[repoKey(repoName)]
}

// Redacted returns repoKey→sorted secret names, values omitted — safe to log at
// boot to confirm what was seeded without ever printing a value. Nil when empty.
func (e EnvSecrets) Redacted() map[string][]string {
	if len(e) == 0 {
		return nil
	}
	out := make(map[string][]string, len(e))
	for repo, m := range e {
		names := make([]string, 0, len(m))
		for n := range m {
			names = append(names, n)
		}
		sort.Strings(names)
		out[repo] = names
	}
	return out
}

// repoKey normalizes a repo name to its env-key form: uppercased with '-' → '_',
// matching how a repo is spelled in a GITMOTE_REPO_SECRET_<REPO>__… var.
func repoKey(name string) string {
	return strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
}