hub v0.29.0: Day-0 artifact manifest — version dropdowns + auto-derived sha

Operator picks a version from a Gitea-populated dropdown; the hub reads that
version's sha256 from Gitea itself (files-metadata API, no artifact download) and
vouches it — no hand-copied checksums. New internal/gitea read-only client
(ListVersions + FileSHA256, unit-tested). Configuration UI: version <select>s +
read-only sha display; handleSetArtifacts derives the sha authoritatively and
refuses the save on a Gitea lookup failure. Degrades to manual text entry without
registry creds. go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:02:13 +02:00
parent ce26c9d646
commit 079a2cdd08
7 changed files with 375 additions and 12 deletions
+24
View File
@@ -1,5 +1,29 @@
# Felhom Hub — Changelog # Felhom Hub — Changelog
## v0.29.0 — Day-0 artifact manifest: version dropdowns + auto-derived sha (2026-07-01)
Removes the hand-copied sha256 from the Day-0 artifact manifest. The operator now **picks a version**
from a dropdown of what's actually in Gitea (olders get pruned), and the hub **reads that version's
sha256 from Gitea itself** — no transcription, no stale checksums. Keeps the human-in-the-loop trust
gate (the operator still deliberately chooses the version; "latest" is never auto-promoted) while the
hub stays the checksum trust root.
- **`internal/gitea`** (new): a minimal read-only Gitea packages client — `ListVersions` (generic
package versions, newest-semver first) + `FileSHA256` (a version's file sha256 via the files-metadata
API, **without downloading** the artifact — important for the ~GB golden). Basic-auth with the
registry creds the hub already holds. Unit-tested against an httptest server (filter+sort, preferred
file match + fallback, non-200 → error).
- **Configuration → Day-0 artifacts:** the two version text inputs are now `<select>` dropdowns
populated from Gitea; the sha256 fields are **read-only, displayed** (mirrored from the picked
version via a tiny inline script). Choosing "— none —" clears an artifact.
- **`handleSetArtifacts`:** derives each chosen version's sha256 from Gitea **authoritatively** (a
client-submitted sha is ignored); a Gitea lookup failure REFUSES the save (never stores a version
with a wrong/blank checksum) rather than corrupting the manifest.
- **Graceful degradation:** with no registry creds (`web.SetGiteaClient` not wired) the form falls back
to the previous manual text-entry path. `main.go` enables the Gitea browser when `REGISTRY_USERNAME`
/`REGISTRY_TOKEN` are set.
- `go build`/`vet`/`test ./...` clean.
## v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01) ## v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01)
Three operator-requested improvements (companion: host-install script v1.2.0). Three operator-requested improvements (companion: host-install script v1.2.0).
+8
View File
@@ -14,6 +14,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/api" "gitea.dooplex.hu/admin/felhom-hub/internal/api"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay" "gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor" "gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/notify"
@@ -254,6 +255,13 @@ func main() {
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger) webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
webServer.SetTemplateFetcher(templateFetcher) webServer.SetTemplateFetcher(templateFetcher)
webServer.SetAssetManager(assetsMgr) webServer.SetAssetManager(assetsMgr)
// Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the
// sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text
// entry when they're absent.
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
webServer.SetGiteaClient(gitea.New("https://gitea.dooplex.hu", "admin", cfg.Registry.Username, cfg.Registry.Token))
logger.Printf("[INFO] Gitea artifact browser enabled (Day-0 version dropdowns)")
}
// Build HTTP mux // Build HTTP mux
mux := http.NewServeMux() mux := http.NewServeMux()
+134
View File
@@ -0,0 +1,134 @@
// Package gitea is a minimal read-only client for the Gitea generic-package API. It lets the hub
// discover which artifact versions currently exist (olders get pruned) and read each file's sha256
// WITHOUT downloading the artifact — so the operator picks a version in the UI and the hub derives and
// vouches the checksum itself, instead of the operator pasting a 64-hex sha by hand.
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// Client talks to the Gitea packages API with basic auth (the registry username + token the hub
// already holds for the controller image pull).
type Client struct {
baseURL string // e.g. https://gitea.dooplex.hu (no trailing slash)
owner string // package owner, e.g. admin
user string
token string
http *http.Client
}
// New builds a client. baseURL/owner/user/token come from the hub's registry config.
func New(baseURL, owner, user, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
owner: owner,
user: user,
token: token,
http: &http.Client{Timeout: 10 * time.Second},
}
}
type pkgEntry struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
}
// ListVersions returns the versions of a generic package, newest-semver first. Non-semver versions
// (if any) sort after the semver ones so nothing is silently dropped.
func (c *Client) ListVersions(ctx context.Context, pkgName string) ([]string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s?type=generic&q=%s&limit=100",
c.baseURL, c.owner, pkgName)
var pkgs []pkgEntry
if err := c.getJSON(ctx, u, &pkgs); err != nil {
return nil, err
}
var vers []string
for _, p := range pkgs {
if p.Name == pkgName && p.Version != "" {
vers = append(vers, p.Version)
}
}
sort.SliceStable(vers, func(i, j int) bool { return compareSemver(vers[i], vers[j]) > 0 })
return vers, nil
}
type pkgFile struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
}
// FileSHA256 returns the sha256 of a file inside a generic package version. When the version holds
// multiple files it prefers an exact match to preferredFile (e.g. "golden.tar.zst"); otherwise it uses
// the first file. This is a cheap metadata call — the artifact bytes are never downloaded.
func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile string) (string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s/generic/%s/%s/files",
c.baseURL, c.owner, pkgName, version)
var files []pkgFile
if err := c.getJSON(ctx, u, &files); err != nil {
return "", err
}
if len(files) == 0 {
return "", fmt.Errorf("no files listed for %s/%s", pkgName, version)
}
if preferredFile != "" {
for _, f := range files {
if f.Name == preferredFile && f.SHA256 != "" {
return f.SHA256, nil
}
}
}
if files[0].SHA256 == "" {
return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name)
}
return files[0].SHA256, nil
}
func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.user, c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// compareSemver returns >0 if a>b, 0 if equal, <0 if a<b. Falls back to a lexical compare on a
// non-"X.Y.Z" input (mirrors web.compareVersions; kept local to avoid an import cycle).
func compareSemver(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
ap := strings.SplitN(a, ".", 3)
bp := strings.SplitN(b, ".", 3)
if len(ap) != 3 || len(bp) != 3 {
return strings.Compare(a, b)
}
for i := 0; i < 3; i++ {
ai, e1 := strconv.Atoi(ap[i])
bi, e2 := strconv.Atoi(bp[i])
if e1 != nil || e2 != nil {
return strings.Compare(a, b)
}
if ai != bi {
return ai - bi
}
}
return 0
}
+88
View File
@@ -0,0 +1,88 @@
package gitea
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ListVersions filters to the requested package name and returns versions newest-semver first.
func TestListVersions_FilterAndSort(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/packages/admin") {
http.NotFound(w, r)
return
}
// Intentionally unsorted + a foreign package that must be filtered out.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"name":"felhom-agent","version":"0.43.0","type":"generic"},
{"name":"felhom-agent","version":"0.52.0","type":"generic"},
{"name":"felhom-golden","version":"0.85.1","type":"generic"},
{"name":"felhom-agent","version":"0.9.0","type":"generic"}
]`))
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
vers, err := c.ListVersions(context.Background(), "felhom-agent")
if err != nil {
t.Fatalf("ListVersions: %v", err)
}
// Only felhom-agent, newest semver first (0.52.0 > 0.43.0 > 0.9.0 — numeric, not lexical).
want := []string{"0.52.0", "0.43.0", "0.9.0"}
if len(vers) != len(want) {
t.Fatalf("got %v, want %v", vers, want)
}
for i := range want {
if vers[i] != want[i] {
t.Fatalf("got %v, want %v", vers, want)
}
}
}
// FileSHA256 prefers an exact filename match; otherwise it falls back to the first file.
func TestFileSHA256_PreferredAndFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/felhom-golden/0.85.1/files"):
w.Write([]byte(`[
{"name":"stray.txt","sha256":"aaaa"},
{"name":"golden.tar.zst","sha256":"f87031cc"}
]`))
case strings.Contains(r.URL.Path, "/felhom-agent/0.52.0/files"):
w.Write([]byte(`[{"name":"felhom-agent","sha256":"5bfc690c"}]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
// preferred-file match among multiple files
sha, err := c.FileSHA256(context.Background(), "felhom-golden", "0.85.1", "golden.tar.zst")
if err != nil || sha != "f87031cc" {
t.Fatalf("golden sha: got %q err %v, want f87031cc", sha, err)
}
// single-file version resolves regardless of preferred name (fallback to first)
sha, err = c.FileSHA256(context.Background(), "felhom-agent", "0.52.0", "felhom-agent")
if err != nil || sha != "5bfc690c" {
t.Fatalf("agent sha: got %q err %v, want 5bfc690c", sha, err)
}
}
// A non-200 from Gitea surfaces as an error (so a save refuses rather than storing a blank sha).
func TestGetJSON_ErrorOnNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
if _, err := c.ListVersions(context.Background(), "felhom-agent"); err == nil {
t.Fatal("expected an error on HTTP 500")
}
}
+28 -5
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@@ -635,22 +636,24 @@ func normalizeSHA256(raw string) (string, bool) {
} }
// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden // handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
// archive: version + sha256 each) into hub_settings. This is the checksum TRUST ROOT the // archive) into hub_settings the checksum TRUST ROOT the host-bootstrap script verifies fetched
// host-bootstrap script verifies fetched artifacts against. Versions are validated as bare semver // artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES
// (reusing the floor validator); sha256s as 64-hex. Empty fields are allowed (clears that field). // that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no
// hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back
// to the submitted sha256 (legacy manual path). Empty version clears that artifact.
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version")) agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
agentSHA, okAS := normalizeSHA256(r.FormValue("agent_sha256"))
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version")) goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
if !okAV || !okGV { if !okAV || !okGV {
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther) http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
return return
} }
agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
if !okAS || !okGS { if !okAS || !okGS {
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther) http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return return
@@ -669,6 +672,26 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther) http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
} }
// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
if version == "" {
return "", true
}
if s.gitea != nil {
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
return "", false
}
return sha, true
}
return normalizeSHA256(submittedSHA)
}
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor // handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
// override. Empty clears the override (the customer then uses the global floor). // override. Empty clears the override (the customer then uses the global floor).
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) { func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {
+57
View File
@@ -17,10 +17,27 @@ import (
"time" "time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Generic-package names + the artifact filename inside each version (used to resolve version lists +
// sha256 from Gitea for the Day-0 artifact manifest UI).
const (
pkgAgent = "felhom-agent"
fileAgent = "felhom-agent"
pkgGolden = "felhom-golden"
fileGolden = "golden.tar.zst"
)
// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
// the read-only sha display).
type artifactChoice struct {
Version string
SHA256 string
}
// hubSession holds per-session auth and CSRF data. // hubSession holds per-session auth and CSRF data.
type hubSession struct { type hubSession struct {
expiresAt time.Time expiresAt time.Time
@@ -39,6 +56,7 @@ type Server struct {
versionChecker *VersionChecker versionChecker *VersionChecker
templateFetcher *TemplateFetcher templateFetcher *TemplateFetcher
assetsMgr *assets.Manager assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
sessions map[string]*hubSession sessions map[string]*hubSession
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
@@ -125,6 +143,42 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am s.assetsMgr = am
} }
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
s.gitea = c
}
// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
// single version drops just that version, not the whole list.
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
if s.gitea == nil {
return nil
}
vers, err := s.gitea.ListVersions(ctx, pkg)
if err != nil {
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
return nil
}
const maxChoices = 20
if len(vers) > maxChoices {
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
vers = vers[:maxChoices]
}
out := make([]artifactChoice, 0, len(vers))
for _, v := range vers {
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
continue
}
out = append(out, artifactChoice{Version: v, SHA256: sha})
}
return out
}
// ServeHTTP routes web requests. // ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
@@ -586,6 +640,7 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
} }
} }
ctx := r.Context()
data := map[string]interface{}{ data := map[string]interface{}{
"CSRFToken": csrfToken, "CSRFToken": csrfToken,
"CSRFField": s.csrfField(r), "CSRFField": s.csrfField(r),
@@ -593,6 +648,8 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
"AssetLastSync": assetLastSync, "AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(), "GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"Artifacts": s.store.GetArtifactManifest(), "Artifacts": s.store.GetArtifactManifest(),
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
"Flash": r.URL.Query().Get("flash"), "Flash": r.URL.Query().Get("flash"),
} }
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil { if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
+36 -7
View File
@@ -43,7 +43,7 @@
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div> <div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
{{end}} {{end}}
{{if eq .Flash "artifact_sha_invalid"}} {{if eq .Flash "artifact_sha_invalid"}}
<div class="flash flash-error">Invalid sha256 — use 64 hex chars (or blank to clear).</div> <div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
{{end}} {{end}}
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page — <!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
@@ -71,20 +71,49 @@
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;"> <p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
verifies (sha256) before installing. The hub vouches for these checksums (a different trust verifies (sha256) before installing. The hub vouches for these checksums (a different trust
root than Gitea). Paste the version + sha256 printed by <code>publish-agent.sh</code> / root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
<code>build-golden.sh</code>. Blank a field to clear it. copy). Choose <em>— none —</em> to clear an artifact.
</p> </p>
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;"> <form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
{{.CSRFField}} {{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label> <label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;"> {{if .AgentChoices}}
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;"> <select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .AgentChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
{{end}}
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label> <label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
{{if .GoldenChoices}}
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .GoldenChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;"> <input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;"> {{end}}
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
<span></span><span></span> <span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button> <button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form> </form>
<script>
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
function syncArtifactSha(kind) {
var sel = document.getElementById(kind + '_version');
var sha = document.getElementById(kind + '_sha256');
if (!sel || !sha) return;
var opt = sel.options[sel.selectedIndex];
sha.value = (opt && opt.getAttribute('data-sha')) || '';
}
</script>
</section> </section>
<!-- Assets section --> <!-- Assets section -->