From 29e6d51cefe9948efa3479d437d499eca61b1722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Mon, 28 Sep 2026 03:15:20 +0200 Subject: [PATCH 1/6] feat(publish): add shared lifecycle publisher Use dedicated account auth and private project robots for staging. Import immutable releases and publish through the customer lifecycle; keep private PR previews in separate repositories. Leave workflows unchanged pending release-environment and credential approval. Verify real local gateway/Core/Harbor retries with deterministic CLI builds and clean up only identity-verified disposable fixtures. --- docs/shared-lifecycle-publication.md | 92 ++++++ .../local_integration_test.go | 206 ++++++++++++++ scripts/publish-lifecycle/main.go | 266 ++++++++++++++++++ scripts/publish-lifecycle/main_test.go | 218 ++++++++++++++ scripts/push.sh | 22 +- scripts/stage-scroll-ui/main.go | 16 ++ scripts/stage-scroll-ui/main_test.go | 10 + scripts/tests/push-parallel.test.sh | 3 + 8 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 docs/shared-lifecycle-publication.md create mode 100644 scripts/publish-lifecycle/local_integration_test.go create mode 100644 scripts/publish-lifecycle/main.go create mode 100644 scripts/publish-lifecycle/main_test.go diff --git a/docs/shared-lifecycle-publication.md b/docs/shared-lifecycle-publication.md new file mode 100644 index 00000000..836a4083 --- /dev/null +++ b/docs/shared-lifecycle-publication.md @@ -0,0 +1,92 @@ +# Shared authored-release publication + +Implementation is opt-in and locally tested. The production/PR workflows have +**not** been switched: authorization for that ongoing CI change and dedicated +Team credential provisioning remain outstanding. Do not treat this as deployed. + +## Contract + +`scripts/push.sh` remains the single explicit catalog and retains its category +barrier, serial default and bounded concurrency. `SCROLL_PUBLISH_MODE=lifecycle` +adds the shared Core policy after each rendered artifact push: + +1. Verify the authenticated publisher's private project against the configured + owner and registry. Reject admin/public-project registry credentials. +2. Render/stage the existing Scroll and private UI. Include inherited family + `.meta` in the revision, not just a mutable category artifact. +3. Push into that private project with a cryptographically unique staging tag. +4. Resolve that tag once, then import its immutable digest through Core. +5. Core applies canonical identity before hashing, verifies final bytes, handles + immutable-tag conflicts and refreshes the catalog under its repository lock. +6. When explicitly reviewed, publish through the same repository-wide operation + used by customers. No public-registry credentials or Team bypass are involved. + +The source staging artifact is retained. Backup snapshots are rejected by the +authored-release endpoint; they must use backup promotion with provenance. + +## Configuration contract (not provisioned) + +- `SCROLL_LIFECYCLE_URL`: trusted Core base URL, including `/core` at the gateway. +- `SCROLL_LIFECYCLE_OWNER`: identity ID of the dedicated publisher account. +- `SCROLL_REGISTRY_HOST`: scheme-less configured Harbor host. +- `SCROLL_REGISTRY_NAMESPACE`: must equal that identity ID (private staging). +- `SCROLL_REGISTRY_USER` / `SCROLL_REGISTRY_PASSWORD`: robot credential belonging + to that private project, issued by the normal account registry-credential flow. +- `SCROLL_AUTH_URL`, `SCROLL_TEAM_EMAIL`, `SCROLL_TEAM_PASSWORD`: normal dedicated + account sign-in. Auth must have the same trusted origin as Core. Session cookies + stay in memory, owner JWTs refresh before lifecycle calls, and sign-out is + attempted at exit. Auth credentials are not passed to the renderer subprocess. +- Alternatively `SCROLL_LIFECYCLE_TOKEN`: a fresh externally supplied owner JWT; + expiration fails closed. Never commit or print credentials. +- `SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team`: keeps runtime images separate + from the private staging project. +- `SCROLL_TAG_SUFFIX=-`: immutable revision names for repeatable CI. + Reusing a tag with changed content is a conflict, not an overwrite. +- Lifecycle mode sets `SOURCE_DATE_EPOCH` from the checked-out commit unless + explicitly supplied. The matching CLI must support it and deterministic root + layer ordering: otherwise identical rebuilds correctly conflict as different + manifest bytes. +- `SCROLL_LIFECYCLE_PUBLISH=1` plus `SCROLL_LIFECYCLE_REVIEWED=1`: explicitly + reviewed public publication. Without publish, imports remain private unless + the destination was already public, in which case unreviewed import fails. +- `SCROLL_LIFECYCLE_REPOSITORY_SUFFIX=-pr`: **separate private preview + repositories**. A PR tag in an already-public release repository is not private. + +Keep secrets restricted to trusted publication jobs and require a release review +gate. No production account, robot, GitHub secret, or environment has been created +by this implementation. Both Core import support and the matching runtime/server +lifecycle PRs must be deployed before enabling the workflows. + +## Verification + +- Go publisher tests cover shared private/public operations, immutable staging, + expected-owner rejection before push, failed-push isolation, separate private + preview identity, explicit review, fresh JWTs and redirect credential isolation. +- Metadata staging tests check inherited presentation and the existing private UI. +- `SCROLL_PUSH_UI=0 bash scripts/tests/push-parallel.test.sh`: passed. +- Full staging-enabled concurrency run: passed, 18 categories / 127 artifacts. + One earlier run during concurrent edits stopped early (47 artifacts); its + original staging error was not retained. The rerun passed without a pool fix; + do not misreport that earlier result as a diagnosed production defect. +- Core shared policy/adapter/schema tests and real local Harbor/Postgres tests + cover authored imports, immutable conflicts, public review guards, byte + preservation and private staging. See the monorepo verification document. +- Authenticated helper → local gateway/auth → live Core → Harbor acceptance + passed in 7.10s, including private import, an identical digest on rebuild, + shared publication and anonymous visibility. The fixture's robot, projects, + catalog row, auth account/session and development credit were cleaned up. + Four retained fixtures from earlier diagnosis attempts were also removed with + exact identity/name/email guards. CI credential provisioning and rollout are + still outstanding; no production account or registry was touched. +- Earlier live failures identified fixture username/schema mistakes and a real + ORAS creation-timestamp reproducibility gap. The timestamp fix preserves + immutable-tag conflicts rather than weakening them. +- Subsequent retry failures exposed nondeterministic root-layer ordering in the + CLI. A focused real OCI push regression reproduced it in 0.08s; sorting the + discovered paths fixed the byte difference without weakening conflict checks. + The final live test explicitly signs out after signup and signs back in with + the dedicated fixture account. Three consecutive runs passed (8.53s, 6.25s, + 6.16s), including exact retry, publication, anonymous visibility and cleanup. + +On interruption, inspect final registry state before retrying. A disconnected +HTTP request does not prove that an import or publication was rolled back. diff --git a/scripts/publish-lifecycle/local_integration_test.go b/scripts/publish-lifecycle/local_integration_test.go new file mode 100644 index 00000000..1d7a4d67 --- /dev/null +++ b/scripts/publish-lifecycle/local_integration_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Opt-in localhost only. Creates an isolated publisher account/project and one +// tiny Scroll. It neither deploys a workload nor consumes shared game ports. +func TestLocalAuthenticatedPublisher(t *testing.T) { + if os.Getenv("SCROLL_LOCAL_LIFECYCLE_TEST") != "1" { + t.Skip("local live stack opt-in") + } + bin := os.Getenv("DRUID_BIN") + if bin == "" { + t.Fatal("DRUID_BIN is required") + } + jar, _ := cookiejar.New(nil) + p := &publisher{base: "http://localhost:3000/api/core", authURL: "http://localhost:3000/api/auth/v2", host: "druid-gs:8088", client: &http.Client{Jar: jar, Timeout: 2 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + suffix := hex.EncodeToString(nonce) + var account struct { + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := p.authRequest(http.MethodPost, "/sign-up/email", map[string]any{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!", "name": "Local lifecycle acceptance", "username": "lifecycle" + suffix[:12]}, &account); err != nil { + t.Fatal(err) + } + p.owner = account.User.ID + if p.owner == "" { + t.Fatal("signup returned no fixture identity") + } + t.Logf("local fixture identity: %s", p.owner) + t.Cleanup(func() { cleanupLocalPublisher(t, p.owner) }) + t.Cleanup(func() { _ = p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) }) + // Exercise the same normal dedicated-account sign-in used by CI, not just + // the initial signup session returned by fixture setup. + if err := p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil); err != nil { + t.Fatal(err) + } + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!"}, nil); err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + var robot struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := p.request(http.MethodPost, "/v1/registry/credentials", map[string]string{"name": "lifecycle-fixture"}, &robot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := p.request(http.MethodDelete, fmt.Sprintf("/v1/registry/credentials/%d", robot.ID), nil, nil); err != nil { + t.Error("fixture credential revocation failed") + } + }) + cliHome := t.TempDir() + run := func(args []string) error { + cmd := exec.Command(args[0], args[1:]...) + for _, value := range os.Environ() { + if !strings.HasPrefix(value, "HOME=") { + cmd.Env = append(cmd.Env, value) + } + } + cmd.Env = append(cmd.Env, "HOME="+cliHome, "DRUID_REGISTRY_PLAIN_HTTP=true", "SOURCE_DATE_EPOCH=0") + output, err := cmd.CombinedOutput() + if err != nil && len(args) > 1 && args[1] != "login" { + // Fixture-only diagnostics: never print auth/login output or full logs. + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "Error:") { + t.Log(strings.ReplaceAll(strings.ReplaceAll(line, robot.Password, ""), p.token, "")) + } + } + } + return err + } + if err := run([]string{bin, "login", "--host", p.host, "--user", robot.Username, "--password", robot.Password}); err != nil { + t.Fatal("fixture registry login failed") + } + source := t.TempDir() + if err := os.Mkdir(filepath.Join(source, ".meta"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, ".meta", "en-US.md"), []byte("---\nname: Local authored fixture\n---\nNo customer content.\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: fixture\ndesc: Local authored fixture\nversion: 1.0.0\napp_version: '1.0.0'\ncommands:\n start:\n run: persistent\n procedures:\n - id: fixture\n command: [sleep, '600']\n image: busybox:1.36\n"), 0644); err != nil { + t.Fatal(err) + } + command := []string{bin, "push", p.host + "/" + p.owner + "/example:accepted-fixture", source, "--category", "fixture"} + if err := p.publish(command, run); err != nil { + t.Fatal(err) + } + // Repeat exactly through the real authenticated public operation. It must + // verify the existing finalized bytes, not overwrite the revision tag. + p.public = true + if err := p.publish(command, run); err != nil { + t.Fatal(err) + } + t.Log("authenticated private staging, exact retry and shared publication passed") + // Assert publication through Harbor's public metadata without owner cookies. + projectName := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(p.owner))) + response, err := http.Get("http://druid-gs:8088/api/v2.0/projects?page_size=100&name=" + projectName) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatal("anonymous public project listing failed") + } + var projects []struct { + Name string `json:"name"` + Metadata struct { + Public string `json:"public"` + } `json:"metadata"` + } + if err := json.NewDecoder(response.Body).Decode(&projects); err != nil { + t.Fatal(err) + } + found := false + for _, project := range projects { + if project.Name == projectName && project.Metadata.Public == "true" { + found = true + } + } + if !found { + t.Fatal("public fixture was not listed") + } +} + +func cleanupLocalPublisher(t *testing.T, owner string) { + t.Helper() + if !regexp.MustCompile(`^[a-f0-9-]{36}$`).MatchString(owner) { + t.Error("invalid fixture owner; cleanup refused") + return + } + fixture := fmt.Sprintf(`SELECT id::text FROM auth."user" WHERE id='%s' AND name='Local lifecycle acceptance' AND email ~ '^lifecycle-[a-f0-9]{32}@example[.]invalid$'`, owner) + verified, err := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-At", "-c", fixture).Output() + if err != nil || strings.TrimSpace(string(verified)) != owner { + t.Error("exact local fixture identity could not be verified; cleanup refused") + return + } + canonicalProject := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(owner))) + for _, project := range []string{owner, canonicalProject} { + for _, path := range []string{"/api/v2.0/projects/" + project + "/repositories/example", "/api/v2.0/projects/" + project} { + req, _ := http.NewRequest(http.MethodDelete, "http://druid-gs:8088"+path, nil) + password := os.Getenv("LOCAL_HARBOR_TEST_PASSWORD") + if password == "" { + password = "admin" + } + req.SetBasicAuth("admin", password) + req.Header.Set("X-Is-Resource-Name", "true") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Error("local Harbor fixture cleanup failed") + return + } + resp.Body.Close() + if resp.StatusCode != 404 && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + t.Errorf("local Harbor fixture cleanup HTTP %d", resp.StatusCode) + return + } + } + } + // Only this generated fixture identity is eligible. FK failures roll back + // the entire database cleanup; unrelated/user-owned rows are never targeted. + sql := fmt.Sprintf(`BEGIN; +DELETE FROM scroll.repository WHERE user_id::text IN (%s); +DELETE FROM core.ledger_entry WHERE user_id::text IN (%s); +DELETE FROM core."user" WHERE identity_id::text IN (%s); +DELETE FROM auth."user" WHERE id::text IN (%s); +COMMIT;`, fixture, fixture, fixture, fixture) + cmd := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-v", "ON_ERROR_STOP=1", "-c", sql) + if err := cmd.Run(); err != nil { + t.Error("fixture database cleanup failed; exact identity retained:", owner) + } +} + +func TestCleanupRetainedLocalPublisherFixtures(t *testing.T) { + ids := os.Getenv("SCROLL_LOCAL_FIXTURE_CLEANUP") + if ids == "" { + t.Skip("explicit retained-fixture IDs required") + } + for _, id := range strings.Split(ids, ",") { + cleanupLocalPublisher(t, id) + } +} diff --git a/scripts/publish-lifecycle/main.go b/scripts/publish-lifecycle/main.go new file mode 100644 index 00000000..264e6303 --- /dev/null +++ b/scripts/publish-lifecycle/main.go @@ -0,0 +1,266 @@ +// Publishes the existing explicit catalog through the shared Core lifecycle. +// Registry credentials can write private staging only; Core owns finalization. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +type publisher struct { + base, token, owner, host, repositorySuffix string + public bool + client *http.Client + authURL string +} + +var exact = regexp.MustCompile(`^[^@\s]+@sha256:[a-f0-9]{64}$`) + +func configured() (*publisher, error) { + base := strings.TrimRight(os.Getenv("SCROLL_LIFECYCLE_URL"), "/") + u, err := url.Parse(base) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "druid-gs"))) { + return nil, errors.New("SCROLL_LIFECYCLE_URL must be HTTPS (HTTP allowed only for local development)") + } + p := &publisher{base: base, token: os.Getenv("SCROLL_LIFECYCLE_TOKEN"), owner: os.Getenv("SCROLL_LIFECYCLE_OWNER"), host: os.Getenv("SCROLL_REGISTRY_HOST"), repositorySuffix: os.Getenv("SCROLL_LIFECYCLE_REPOSITORY_SUFFIX"), public: os.Getenv("SCROLL_LIFECYCLE_PUBLISH") == "1", + client: &http.Client{Timeout: 70 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + if p.owner == "" || p.host == "" || os.Getenv("SCROLL_REGISTRY_NAMESPACE") != p.owner { + return nil, errors.New("expected owner, registry host and matching private staging namespace are required") + } + if p.public && os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { + return nil, errors.New("public publication requires SCROLL_LIFECYCLE_REVIEWED=1 after content review") + } + // Never reuse production admin/public-project credentials for staging. + if !strings.HasPrefix(os.Getenv("SCROLL_REGISTRY_USER"), "robot$"+p.owner+"+") { + return nil, errors.New("registry credentials must belong to the expected owner's private project") + } + if p.token == "" { + auth, err := url.Parse(strings.TrimRight(os.Getenv("SCROLL_AUTH_URL"), "/")) + if err != nil || auth.Host != u.Host || auth.Scheme != u.Scheme || auth.User != nil || auth.RawQuery != "" || auth.Fragment != "" { + return nil, errors.New("SCROLL_AUTH_URL must use the same trusted origin as Core") + } + email, password := os.Getenv("SCROLL_TEAM_EMAIL"), os.Getenv("SCROLL_TEAM_PASSWORD") + if email == "" || password == "" { + return nil, errors.New("provide a fresh lifecycle token or dedicated Team sign-in credentials") + } + p.authURL = auth.String() + p.client.Jar, _ = cookiejar.New(nil) + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": email, "password": password}, nil); err != nil { + return nil, err + } + } + return p, nil +} + +// Use normal account sign-in and refreshed JWTs, not a Team-specific auth bypass. +// Session cookies stay in memory and are revoked on exit. +func (p *publisher) authRequest(method, path string, body, out any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, p.authURL+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid auth request") + } + req.Header.Set("Content-Type", "application/json") + if origin, err := url.Parse(p.authURL); err == nil { + req.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + } + resp, err := p.client.Do(req) + if err != nil { + return errors.New("publisher authentication failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var failure struct { + Code string `json:"code"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&failure) + if !regexp.MustCompile(`^[A-Z_]{1,64}$`).MatchString(failure.Code) { + failure.Code = "AUTH_FAILED" + } + return fmt.Errorf("publisher authentication rejected: HTTP %d (%s)", resp.StatusCode, failure.Code) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) + } + return nil +} + +func (p *publisher) request(method, path string, body, out any) error { + if p.authURL != "" { + var jwt struct { + Token string `json:"token"` + } + if err := p.authRequest(http.MethodGet, "/token", nil, &jwt); err != nil { + return err + } + if jwt.Token == "" { + return errors.New("authentication returned no owner JWT") + } + p.token = jwt.Token + } + var data []byte + var err error + if body != nil { + data, err = json.Marshal(body) + if err != nil { + return err + } + } + req, err := http.NewRequest(method, p.base+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid lifecycle request") + } + req.Header.Set("Authorization", "Bearer "+p.token) + req.Header.Set("Content-Type", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return errors.New("lifecycle request failed; inspect installed/published state before retrying") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("lifecycle request rejected: HTTP %d", resp.StatusCode) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) + } + return nil +} + +func (p *publisher) preflight() error { + var project struct { + Registry string `json:"registry"` + Project string `json:"project"` + } + // The shared operation ensures this is the authenticated actor's PRIVATE + // project. A supplied owner name alone is never treated as authorization. + if err := p.request(http.MethodGet, "/v1/registry/project", nil, &project); err != nil { + return err + } + if project.Registry != p.host || project.Project != p.host+"/"+p.owner { + return errors.New("authenticated identity or registry differs from the expected publisher") + } + return nil +} + +func (p *publisher) publish(command []string, run func([]string) error) error { + if len(command) < 4 || command[1] != "push" || command[2] == "category" { + return errors.New("expected a rendered artifact push command") + } + ref := command[2] + prefix := p.host + "/" + p.owner + "/" + if !strings.HasPrefix(ref, prefix) { + return errors.New("artifact must target the verified private staging project") + } + nameTag := strings.TrimPrefix(ref, prefix) + parts := strings.Split(nameTag, ":") + if len(parts) != 2 || strings.Contains(parts[0], "/") || !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(parts[0]) || !regexp.MustCompile(`^[\w][\w.-]{0,127}$`).MatchString(parts[1]) { + return errors.New("invalid repository or explicit revision tag") + } + name := parts[0] + p.repositorySuffix + if !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(name) { + return errors.New("invalid final repository name") + } + if err := p.preflight(); err != nil { + return err + } + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return err + } + // Each invocation owns its staging tag, so another job cannot replace the + // mutable selection between our push and the single digest resolution. + stagedRef := prefix + parts[0] + ":build-" + hex.EncodeToString(nonce) + stagedCommand := append([]string(nil), command...) + stagedCommand[2] = stagedRef + // Final reusable revisions embed presentation instead of depending on a + // separately mutable category artifact. Staging supplies inherited .meta. + stagedCommand = append(stagedCommand, "--pack-meta") + if err := run(stagedCommand); err != nil { + return err + } + var staged struct { + Artifact string `json:"artifact"` + } + if err := p.request(http.MethodGet, "/v1/registry/scrolls/revision?artifact="+url.QueryEscape(stagedRef), nil, &staged); err != nil { + return err + } + if !exact.MatchString(staged.Artifact) || !strings.HasPrefix(staged.Artifact, prefix+parts[0]+"@") { + return errors.New("resolved staging revision differs from selected repository") + } + var imported struct { + Artifact string `json:"artifact"` + } + input := map[string]any{"repository": name, "tag": parts[1], "releaseArtifact": staged.Artifact, "reviewedPublicContent": p.public} + if err := p.request(http.MethodPost, "/v1/registry/scrolls/releases/import", input, &imported); err != nil { + return err + } + ownerHash := sha256.Sum256([]byte(p.owner)) + canonical := fmt.Sprintf("%s/scroll-%x-%s/%s", p.host, ownerHash, name, name) + if !exact.MatchString(imported.Artifact) || !strings.HasPrefix(imported.Artifact, canonical+"@") { + return errors.New("Core did not return an exact finalized revision") + } + if p.public { + digest := strings.SplitN(imported.Artifact, "@", 2)[1] + if err := p.request(http.MethodPost, "/v1/registry/scrolls/publish", map[string]any{"repository": name, "revision": digest, "reviewedPublicContent": true}, nil); err != nil { + return err + } + } + fmt.Println(imported.Artifact) + return nil +} + +func runPublisher() error { + p, err := configured() + if err == nil && p.authURL != "" { + defer p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) + } + if err == nil { + if len(os.Args) == 2 && os.Args[1] == "preflight" { + err = p.preflight() + } else { + args := os.Args[1:] + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + err = p.publish(args, func(command []string) error { + cmd := exec.Command(command[0], command[1:]...) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "SCROLL_TEAM_PASSWORD=") || strings.HasPrefix(entry, "SCROLL_TEAM_EMAIL=") || strings.HasPrefix(entry, "SCROLL_LIFECYCLE_TOKEN=") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() + }) + } + } + return err +} + +func main() { + if err := runPublisher(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/publish-lifecycle/main_test.go b/scripts/publish-lifecycle/main_test.go new file mode 100644 index 00000000..23f69a68 --- /dev/null +++ b/scripts/publish-lifecycle/main_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSharedLifecyclePrivateStagingAndExplicitPublication(t *testing.T) { + for _, public := range []bool{false, true} { + t.Run(fmt.Sprint(public), func(t *testing.T) { + var staged string + var imported, published int + name := "scroll-example" + suffix := "" + if !public { + suffix = "-pr12" + } + name += suffix + canonical := fmt.Sprintf("registry.test/scroll-%x-%s/%s", sha256.Sum256([]byte("team-owner")), name, name) + digest := "sha256:" + strings.Repeat("a", 64) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture" { + t.Error("owner authorization missing") + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/registry/project": + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + case "/v1/registry/scrolls/revision": + if staged == "" || r.URL.Query().Get("artifact") != staged { + t.Error("did not freeze own staging tag") + } + json.NewEncoder(w).Encode(map[string]string{"artifact": "registry.test/team-owner/scroll-example@" + digest}) + case "/v1/registry/scrolls/releases/import": + imported++ + var input struct { + Repository, Tag, ReleaseArtifact string + ReviewedPublicContent bool + } + json.NewDecoder(r.Body).Decode(&input) + if input.Repository != name || input.Tag != "v1-commit" || input.ReleaseArtifact != "registry.test/team-owner/scroll-example@"+digest || input.ReviewedPublicContent != public { + t.Errorf("wrong import contract: %+v", input) + } + json.NewEncoder(w).Encode(map[string]string{"artifact": canonical + "@" + digest}) + case "/v1/registry/scrolls/publish": + published++ + var input struct { + Repository, Revision string + ReviewedPublicContent bool + } + json.NewDecoder(r.Body).Decode(&input) + if input.Repository != name || input.Revision != digest || !input.ReviewedPublicContent { + t.Error("publication lost exact revision/review") + } + fmt.Fprint(w, `{}`) + default: + t.Error("unexpected request") + w.WriteHeader(404) + } + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), token: "fixture", owner: "team-owner", host: "registry.test", public: public, repositorySuffix: suffix} + command := []string{"druid", "push", "registry.test/team-owner/scroll-example:v1-commit", "./scroll"} + err := p.publish(command, func(args []string) error { + staged = args[2] + if !strings.HasPrefix(staged, "registry.test/team-owner/scroll-example:build-") { + t.Fatal("push not isolated in private staging") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if imported != 1 || (public && published != 1) || (!public && published != 0) { + t.Fatal("wrong lifecycle calls") + } + if command[2] != "registry.test/team-owner/scroll-example:v1-commit" { + t.Fatal("caller catalog mutated") + } + }) + } +} + +func TestWrongIdentityStopsBeforeRegistryPush(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/other"}`) + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} + called := false + err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { called = true; return nil }) + if err == nil || called { + t.Fatal("wrong identity was allowed to push") + } +} + +func TestFailedPushNeverImports(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + })) + defer server.Close() + p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} + err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { return errors.New("push failed") }) + if err == nil || calls != 1 { + t.Fatal("failed push reached lifecycle import") + } +} + +func TestConfigurationRejectsAdminCredentialsAndUnreviewedPublication(t *testing.T) { + t.Setenv("SCROLL_LIFECYCLE_URL", "https://api.druid.gg/core") + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_USER", "admin") + if _, err := configured(); err == nil { + t.Fatal("admin credentials accepted") + } + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "1") + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "0") + if _, err := configured(); err == nil { + t.Fatal("unreviewed public import accepted") + } + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") + if _, err := configured(); err != nil { + t.Fatal(err) + } +} + +func TestDedicatedAccountRefreshesJWTUsingInMemorySession(t *testing.T) { + tokens := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/v2/sign-in/email": + var input map[string]string + json.NewDecoder(r.Body).Decode(&input) + if input["email"] != "team@example.test" || input["password"] != "fixture-password" { + t.Error("wrong dedicated sign-in credentials") + } + http.SetCookie(w, &http.Cookie{Name: "session", Value: "fixture-session", Path: "/"}) + fmt.Fprint(w, `{}`) + case "/auth/v2/token": + if cookie, err := r.Cookie("session"); err != nil || cookie.Value != "fixture-session" { + t.Error("session cookie missing") + } + tokens++ + fmt.Fprintf(w, `{"token":"refreshed-%d"}`, tokens) + case "/core/v1/registry/project": + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer refreshed-%d", tokens) { + t.Error("fresh owner JWT missing") + } + fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) + default: + t.Error("unexpected request") + w.WriteHeader(404) + } + })) + defer server.Close() + t.Setenv("SCROLL_LIFECYCLE_URL", server.URL+"/core") + t.Setenv("SCROLL_AUTH_URL", server.URL+"/auth/v2") + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "") + t.Setenv("SCROLL_TEAM_EMAIL", "team@example.test") + t.Setenv("SCROLL_TEAM_PASSWORD", "fixture-password") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") + p, err := configured() + if err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + if tokens != 2 { + t.Fatal("owner JWT was not refreshed before each lifecycle request") + } +} + +func TestPublisherNeverFollowsCredentialedRedirects(t *testing.T) { + forwarded := false + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true })) + defer other.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + defer server.Close() + t.Setenv("SCROLL_LIFECYCLE_URL", server.URL) + t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") + t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") + t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") + t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") + t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") + t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") + p, err := configured() + if err != nil { + t.Fatal(err) + } + if err := p.preflight(); err == nil { + t.Fatal("redirect accepted") + } + if forwarded { + t.Fatal("credentials reached redirect target") + } +} diff --git a/scripts/push.sh b/scripts/push.sh index ef5a9e1f..d3690ea9 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -18,6 +18,11 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}" SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}" SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}" SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}" +SCROLL_PUBLISH_MODE="${SCROLL_PUBLISH_MODE:-direct}" +if [[ "$SCROLL_PUBLISH_MODE" != "direct" && "$SCROLL_PUBLISH_MODE" != "lifecycle" ]]; then + echo "SCROLL_PUBLISH_MODE must be direct or lifecycle" >&2 + exit 2 +fi if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2 @@ -122,17 +127,25 @@ run() { fi if ((SCROLL_PUSH_JOBS == 1)); then - "${command[@]}" + execute_push "${command[@]}" return fi - "${command[@]}" & + execute_push "${command[@]}" & push_pids+=("$!") if ((${#push_pids[@]} >= SCROLL_PUSH_JOBS)); then wait_for_oldest_push fi } +execute_push() { + if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "${2:-}" == "push" && "${3:-}" != "category" ]]; then + go run ./scripts/publish-lifecycle -- "$@" + else + "$@" + fi +} + push_release_categories() { run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-spigot minecraft ./scrolls/minecraft/minecraft-spigot/.meta run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-vanilla minecraft ./scrolls/minecraft/minecraft-vanilla/.meta @@ -284,6 +297,11 @@ push_release_artifacts() { run druid push artifacts.druid.gg/druid-team/scroll-hytale:latest ./scrolls/hytale/hytale-druid-gg -p main=5520/udp -i artifacts.druid.gg/druid-team/druid:v0.1.257 --min-disk 10Gi --min-ram 4Gi --min-cpu 1 -m --smart --category hytale } +if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "$SCROLL_PUSH_DRY_RUN" != "1" ]]; then + # Freeze build metadata across retries of one reviewed commit. + export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}" + go run ./scripts/publish-lifecycle preflight +fi login_if_configured if [[ "$SCROLL_PUSH_CATEGORIES" = "1" ]]; then diff --git a/scripts/stage-scroll-ui/main.go b/scripts/stage-scroll-ui/main.go index d59b6d58..8fff78c5 100644 --- a/scripts/stage-scroll-ui/main.go +++ b/scripts/stage-scroll-ui/main.go @@ -339,6 +339,22 @@ func stage(source, destination, bundle string) error { if err := cp.Copy(source, destination); err != nil { return fmt.Errorf("copy Scroll: %w", err) } + // Version directories inherit family presentation. Keep it inside the staged + // release so the lifecycle never depends on mutable category tags afterward. + if _, err := os.Stat(filepath.Join(destination, ".meta")); os.IsNotExist(err) { + for parent := filepath.Dir(source); parent != "." && parent != filepath.Dir(parent); parent = filepath.Dir(parent) { + metadata := filepath.Join(parent, ".meta") + if info, err := os.Stat(metadata); err == nil && info.IsDir() { + if err := cp.Copy(metadata, filepath.Join(destination, ".meta")); err != nil { + return fmt.Errorf("copy presentation: %w", err) + } + break + } + if filepath.Base(parent) == "scrolls" { + break + } + } + } scrollPath := filepath.Join(destination, "scroll.yaml") scrollBytes, err := os.ReadFile(scrollPath) diff --git a/scripts/stage-scroll-ui/main_test.go b/scripts/stage-scroll-ui/main_test.go index 90704054..dd0a02f3 100644 --- a/scripts/stage-scroll-ui/main_test.go +++ b/scripts/stage-scroll-ui/main_test.go @@ -16,6 +16,13 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := os.MkdirAll(source, 0755); err != nil { t.Fatal(err) } + metadata := filepath.Join(filepath.Dir(source), ".meta") + if err := os.MkdirAll(metadata, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(metadata, "en-US.md"), []byte("---\nname: Paper\n---\nPresentation\n"), 0644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\napp_version: 1.21.7\ncommands: {}\n"), 0644); err != nil { t.Fatal(err) } @@ -40,6 +47,9 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := stage(source, destination, bundle); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(destination, ".meta", "en-US.md")); err != nil { + t.Fatal("family presentation missing from release", err) + } stagedYAML, err := os.ReadFile(filepath.Join(destination, "scroll.yaml")) if err != nil { diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index cf856e91..a47bbf50 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -110,6 +110,9 @@ fi failure_starts="$(grep -c '^start artifact ' "$tmp_dir/state/events.log")" failure_ends="$(grep -c '^end artifact ' "$tmp_dir/state/events.log")" +if [[ "$failure_starts" != "$expected_artifacts" ]]; then + sed -n '1,12p' "$tmp_dir/failure.err" >&2 +fi assert_equals "$expected_artifacts" "$failure_starts" \ "a job failure must not abandon the remaining queued artifact pushes" assert_equals "$failure_starts" "$failure_ends" \ From ce15065a75e9ac0fcda8f857b7403a4a9b8cf5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Sat, 3 Oct 2026 19:14:35 +0200 Subject: [PATCH 2/6] ci(scrolls): recover shared lifecycle cutover Route releases and private previews through verified lifecycle publication with immutable revisions and fail-closed provisioning guards. Dedicated credentials and protected environments remain rollout prerequisites. Recover the September 29 local draft and its offline regression coverage into the durable workspace. No remote publication performed. --- .github/workflows/pr.yml | 81 +------- .github/workflows/release.yml | 49 +---- .github/workflows/scroll-lifecycle.yml | 130 +++++++++++++ docs/shared-lifecycle-publication.md | 75 +++++++- scripts/publish-lifecycle/push_script_test.go | 174 ++++++++++++++++++ scripts/tests/push-parallel.test.sh | 3 - .../lifecycle_workflow.go | 124 +++++++++++++ .../lifecycle_workflow_test.go | 95 ++++++++++ scripts/validate-release-workflow/main.go | 4 + 9 files changed, 611 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/scroll-lifecycle.yml create mode 100644 scripts/publish-lifecycle/push_script_test.go create mode 100644 scripts/validate-release-workflow/lifecycle_workflow.go create mode 100644 scripts/validate-release-workflow/lifecycle_workflow_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2844312b..94287ebd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,81 +2,10 @@ name: PR Pipeline on: pull_request: branches: [master] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_PR_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - run: apt update && apt install -y make - - name: Build scroll tree - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then - registry_host="${SCROLL_REGISTRY_HOST#http://}" - registry_host="${registry_host#https://}" - registry_host="${registry_host%%/*}" - export DRUID_COLDSTARTER_IMAGE="${registry_host}/${SCROLL_REGISTRY_PR_NAMESPACE}/druid:stable-pr${{ github.event.pull_request.number }}" - fi - make build-tree - - name: Get registry binary - if: github.event.pull_request.head.repo.full_name == github.repository - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - name: Install druid - if: github.event.pull_request.head.repo.full_name == github.repository - run: | - chmod +x druid - mv druid /usr/local/bin/druid - - name: Validate all scrolls - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Test bounded push parallelism - run: bash ./scripts/tests/push-parallel.test.sh - - name: Login to registry - id: registry_login - continue-on-error: true - if: github.event.pull_request.head.repo.full_name == github.repository - run: druid login --host ${{ secrets.SCROLL_REGISTRY_HOST }} --user '${{ secrets.SCROLL_REGISTRY_USER }}' --password ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - - name: Check scroll changes - id: scroll-changes - run: | - set -euo pipefail - git fetch origin "${{ github.base_ref }}" --depth=1 - if git diff --quiet "origin/${{ github.base_ref }}"..HEAD -- scrolls; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No scroll source changes; skipping preview push." - else - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "Scroll source changed; publishing preview tags." - fi - - name: Push experimental PR tags - if: github.event.pull_request.head.repo.full_name == github.repository && steps.scroll-changes.outputs.changed == 'true' && steps.registry_login.outcome == 'success' - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team - SCROLL_TAG_SUFFIX: -pr${{ github.event.pull_request.number }} - SCROLL_PUSH_CATEGORIES: "0" - run: bash ./scripts/push.sh + uses: ./.github/workflows/scroll-lifecycle.yml + with: + public: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea52735d..237951e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,47 +2,12 @@ name: Release Changed Pipeline on: workflow_dispatch: push: - branches: - - master - tags: - - v* + branches: [master] + tags: [v*] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} - SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - name: Get registry binary - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - run: chmod +x druid - - name: Install druid - run: mv druid /usr/local/bin/druid - - name: druid version - run: druid version - - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Push release scrolls - run: bash ./scripts/push.sh + uses: ./.github/workflows/scroll-lifecycle.yml + with: + public: true diff --git a/.github/workflows/scroll-lifecycle.yml b/.github/workflows/scroll-lifecycle.yml new file mode 100644 index 00000000..592812a9 --- /dev/null +++ b/.github/workflows/scroll-lifecycle.yml @@ -0,0 +1,130 @@ +name: Shared Scroll lifecycle +on: + workflow_call: + inputs: + public: + description: Publish reviewed releases publicly; PR previews remain private. + required: true + type: boolean +permissions: + contents: read + +jobs: + verify: + runs-on: self-hosted + outputs: + changed: ${{ steps.changes.outputs.changed }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.24.7" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build and test Scroll UI + working-directory: ui + run: npm ci && npm test && npm run type-check && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Validate catalog and UI + run: go run ./scripts/validate-release-workflow && go run ./scripts/validate-scrolls.go && bash ./scripts/validate_ui_coverage.sh + - name: Test lifecycle publication + run: go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow -count=1 + - name: Test bounded catalog publication + run: bash ./scripts/tests/push-parallel.test.sh + - name: Check release inputs + id: changes + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "pull_request" ]] && git diff --quiet "$BASE_SHA"...HEAD -- scrolls ui scripts .github/workflows go.mod go.sum Makefile; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + publish: + needs: verify + if: >- + needs.verify.outputs.changed == 'true' && + ((inputs.public && github.event_name != 'pull_request') || + (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) + # A fresh runner keeps Team credentials out of the untrusted validation job. + runs-on: ubuntu-latest + timeout-minutes: 360 + environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }} + concurrency: + group: scroll-lifecycle-${{ inputs.public && 'release' || format('pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: false + steps: + - name: Require provisioned lifecycle environment + env: + LIFECYCLE_READY: ${{ vars.SCROLL_LIFECYCLE_READY }} + run: | + if [[ "$LIFECYCLE_READY" != "true" ]]; then + echo "Configure the protected Team lifecycle environment before publication." >&2 + exit 1 + fi + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Checkout lifecycle-capable CLI + uses: actions/checkout@v4 + with: + repository: highcard-dev/druid-cli + # Includes SOURCE_DATE_EPOCH and stable root-layer ordering. + ref: a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd + path: .druid-cli + token: ${{ secrets.GO_REPO_TOKEN }} + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: .druid-cli/go.mod + - name: Build pinned CLI + working-directory: .druid-cli + run: | + mkdir -p "$RUNNER_TEMP/scroll-cli" + go build -o "$RUNNER_TEMP/scroll-cli/druid" ./apps/druid + echo "$RUNNER_TEMP/scroll-cli" >> "$GITHUB_PATH" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build Scroll UI + working-directory: ui + run: npm ci && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Validate with pinned CLI + run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh + - name: Publish through shared lifecycle + env: + SCROLL_PUBLISH_MODE: lifecycle + SCROLL_LIFECYCLE_URL: ${{ vars.SCROLL_LIFECYCLE_URL }} + SCROLL_AUTH_URL: ${{ vars.SCROLL_AUTH_URL }} + SCROLL_LIFECYCLE_OWNER: ${{ vars.SCROLL_LIFECYCLE_OWNER }} + SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST }} + SCROLL_REGISTRY_NAMESPACE: ${{ vars.SCROLL_LIFECYCLE_OWNER }} + SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team + SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_TEAM_REGISTRY_USER }} + SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }} + SCROLL_TEAM_EMAIL: ${{ secrets.SCROLL_TEAM_EMAIL }} + SCROLL_TEAM_PASSWORD: ${{ secrets.SCROLL_TEAM_PASSWORD }} + SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }} + SCROLL_LIFECYCLE_REPOSITORY_SUFFIX: ${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }} + SCROLL_LIFECYCLE_PUBLISH: ${{ inputs.public && '1' || '0' }} + SCROLL_LIFECYCLE_REVIEWED: ${{ inputs.public && '1' || '0' }} + run: bash ./scripts/push.sh diff --git a/docs/shared-lifecycle-publication.md b/docs/shared-lifecycle-publication.md index 836a4083..fd52626f 100644 --- a/docs/shared-lifecycle-publication.md +++ b/docs/shared-lifecycle-publication.md @@ -1,8 +1,58 @@ # Shared authored-release publication -Implementation is opt-in and locally tested. The production/PR workflows have -**not** been switched: authorization for that ongoing CI change and dedicated -Team credential provisioning remain outstanding. Do not treat this as deployed. +The local workflow draft routes release and PR publication through the shared +lifecycle. This is a local-only recovery; it has not been pushed or run on GitHub. +Dedicated Team +identity/credentials and protected GitHub environments are not provisioned. +Do not treat this as deployed or as complete product acceptance. + +## Local CI cutover draft + +`release.yml` and `pr.yml` call `scroll-lifecycle.yml`; neither retains a direct +registry publication fallback. The shared workflow validates/builds UI, checks +the catalog and runs publisher/staging/workflow tests without Team secrets. +Publication then uses a fresh runner and the protected `scroll-release` or +`scroll-preview` environment. Fork PRs validate but never enter publication. + +The publication job builds CLI commit +`a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd`, which includes fixed-source timestamps +and deterministic layer order, rather than assuming the old released binary has +those behaviors. It checks out the exact release/PR-head SHA, suffixes revisions +with that SHA, and uses separate `-pr` private repositories for previews. +Runtime-image references remain separate from the private staging project. +In-flight publication is not cancelled when a newer run arrives. + +The existing direct script mode remains available for local Harbor bootstrap; +neither CI caller selects it. CI still uses the one explicit catalog and its +category barrier. Categories go into the publisher's private staging project; +final revisions embed their own presentation metadata. + +## Rollout prerequisites (no remote changes performed) + +Before enabling the draft: + +1. Deploy matching Core/runtime lifecycle changes through their normal CI. +2. Create a normal dedicated Team account and issue a private-project robot + through the normal registry-credentials flow. Do not reuse the old admin or + public-project robot. +3. Create `scroll-release` and `scroll-preview` GitHub environments. Require + approval of the exact source commit and disable self-approval. Restrict release + deployment branches/tags to the reviewed release sources. Preview approval + must cover source code that will receive the dedicated preview credentials. +4. Put `GO_REPO_TOKEN` (read-only CLI source access), `SCROLL_TEAM_EMAIL`, + `SCROLL_TEAM_PASSWORD`, `SCROLL_TEAM_REGISTRY_USER` and + `SCROLL_TEAM_REGISTRY_PASSWORD` in those environments, not validation jobs. + Set environment variables `SCROLL_LIFECYCLE_URL`, `SCROLL_AUTH_URL`, + `SCROLL_LIFECYCLE_OWNER` and scheme-less `SCROLL_REGISTRY_HOST`. +5. Only after checking protections and deployed dependencies, set the environment + variable `SCROLL_LIFECYCLE_READY=true`. A missing/false value fails the job; + it does not skip publication successfully or fall back to direct pushes. +6. Review branch-protection required-check names after the shared-workflow change, + and run the first approved preview/release with fresh credentials. + +Workflow YAML names an environment but does not create its required-reviewer +policy. Environment secrets belong to the called job, as described in +[GitHub's reusable-workflow documentation](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). ## Contract @@ -59,6 +109,25 @@ lifecycle PRs must be deployed before enabling the workflows. ## Verification +- Recovery on 2026-10-03 reconstructed the latest retained changes from session + history in `druid-local/.worktrees/scroll-lifecycle/scrolls`, on the local + branch `recovery/scroll-lifecycle-20261003`. Publisher, UI-staging and workflow + suites pass freshly, as do workflow validation and actionlint. The earlier + live acceptance results below are historical, not rerun during recovery. +- Local workflow syntax checks pass with actionlint v1.7.7 (ShellCheck disabled). + Publisher, UI-staging and workflow-validator Go suites pass together. + Twelve workflow mutation cases reject missing cutover guards; executing the + actual provisioning guard proves unset/false/uppercase values fail and only + `true` passes. The guard must precede every other publication-job step. + Independent spec/standards review found no blocking defects; the standards + review prompted the executable provisioning-guard regression test. + This verifies the local draft, not GitHub environment protection or live CI. +- `go test ./scripts/publish-lifecycle -run '^TestPushScript' -count=3`: + passes (37.753s). The real `push.sh` runs against offline boundaries, covering + all category/artifact calls, immutable timestamp/tag inputs, separate runtime + image namespace, lifecycle artifact concurrency, category barrier, preflight + rejection before mutation and complete job reaping after a publisher failure. + This does not exercise live Core or prove that CI selects lifecycle mode. - Go publisher tests cover shared private/public operations, immutable staging, expected-owner rejection before push, failed-push isolation, separate private preview identity, explicit review, fresh JWTs and redirect credential isolation. diff --git a/scripts/publish-lifecycle/push_script_test.go b/scripts/publish-lifecycle/push_script_test.go new file mode 100644 index 00000000..e4cd4638 --- /dev/null +++ b/scripts/publish-lifecycle/push_script_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Exercise the real catalog/pool with an offline publisher boundary. Publisher +// HTTP/auth behavior is covered separately in main_test.go; no registry is used. +func TestPushScriptUsesLifecycleForEveryArtifact(t *testing.T) { + for _, jobs := range []string{"1", "3"} { + t.Run("jobs-"+jobs, func(t *testing.T) { + state, run := lifecycleScriptFixture(t, jobs) + if output, err := run(); err != nil { + t.Fatalf("push script: %v\n%s", err, output) + } + calls := readFixture(t, filepath.Join(state, "publisher.log")) + events := readFixture(t, filepath.Join(state, "events.log")) + starts, active, peak := 0, 0, 0 + for _, line := range strings.Split(events, "\n") { + if strings.HasPrefix(line, "start artifact ") { + starts++ + active++ + if active > peak { + peak = active + } + } + if strings.HasPrefix(line, "end artifact ") { + active-- + } + } + expected, categories := catalogCounts(t) + if starts == 0 || starts != expected || len(strings.Split(strings.TrimSpace(calls), "\n")) != starts { + t.Fatalf("not every catalog artifact used the lifecycle publisher: %d starts\n%s", starts, calls) + } + for _, line := range strings.Split(strings.TrimSpace(calls), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "1700000000" || !strings.HasPrefix(fields[1], "registry.invalid/fixture-owner/") || !strings.HasSuffix(fields[1], "-commit") { + t.Fatalf("unstable timestamp or wrong staging reference: %s", line) + } + if strings.Contains(line, " -i ") && !strings.Contains(line, " -i registry.invalid/druid-team/druid:") { + t.Fatalf("runtime image was redirected into private staging: %s", line) + } + } + if strings.Count(events, "start category ") != categories || strings.Count(events, "end category ") != categories || + strings.LastIndex(events, "end category ") < 0 || strings.LastIndex(events, "end category ") > strings.Index(events, "start artifact ") { + t.Fatal("lifecycle artifacts started before category completion") + } + if readFixture(t, filepath.Join(state, "active")) != "0\n" { + t.Fatal("publisher children remain active") + } + limit, _ := strconv.Atoi(jobs) + if active != 0 || peak < 1 || peak > limit || (limit > 1 && peak < 2) { + t.Fatalf("lifecycle artifact concurrency = %d, configured = %d, active = %d", peak, limit, active) + } + }) + } +} + +func TestPushScriptFailsBeforeMutationWhenLifecyclePreflightFails(t *testing.T) { + state, run := lifecycleScriptFixture(t, "3", "FAKE_PREFLIGHT_FAIL=1") + if output, err := run(); err == nil { + t.Fatalf("failed preflight accepted: %s", output) + } + for _, name := range []string{"events.log", "publisher.log", "login"} { + if _, err := os.Stat(filepath.Join(state, name)); !os.IsNotExist(err) { + t.Fatalf("failed preflight reached %s: %v", name, err) + } + } +} + +func TestPushScriptReapsLifecycleFailures(t *testing.T) { + state, run := lifecycleScriptFixture(t, "3", "FAKE_DRUID_FAIL_REF=registry.invalid/fixture-owner/scroll-minecraft-spigot:1.17-commit") + if output, err := run(); err == nil { + t.Fatalf("failed lifecycle artifact accepted: %s", output) + } + events := readFixture(t, filepath.Join(state, "events.log")) + expected, _ := catalogCounts(t) + if strings.Count(events, "start artifact ") != expected { + t.Fatal("lifecycle failure abandoned unstarted catalog artifacts") + } + if strings.Count(events, "start artifact ") != strings.Count(events, "end artifact ") || readFixture(t, filepath.Join(state, "active")) != "0\n" { + t.Fatal("lifecycle failure abandoned active publisher jobs") + } + if !strings.Contains(events, "status=23") { + t.Fatal("fixture did not exercise the publisher failure") + } +} + +func lifecycleScriptFixture(t *testing.T, jobs string, extra ...string) (string, func() ([]byte, error)) { + t.Helper() + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + state := t.TempDir() + bin := filepath.Join(state, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + stubs := map[string]string{ + "go": `#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == run && "$2" == ./scripts/publish-lifecycle ]] || exit 91 +if [[ "$3" == preflight ]]; then + [[ "${FAKE_PREFLIGHT_FAIL:-0}" != 1 ]] || exit 92 + touch "$FAKE_DRUID_STATE_DIR/preflight" + exit 0 +fi +[[ -f "$FAKE_DRUID_STATE_DIR/preflight" && "$3" == -- && "$4" == "$DRUID_BIN" && "$5" == push && "$6" != category ]] || exit 93 +printf '%s %s %s\n' "$SOURCE_DATE_EPOCH" "$6" "$*" >> "$FAKE_DRUID_STATE_DIR/publisher.log" +shift 4 +exec bash "$FAKE_DRUID_SOURCE" "$@" +`, + "druid": `#!/usr/bin/env bash +set -euo pipefail +[[ -f "$FAKE_DRUID_STATE_DIR/preflight" ]] || exit 94 +if [[ "$1" == login ]]; then + touch "$FAKE_DRUID_STATE_DIR/login" + exit 0 +fi +[[ "$1" == push && "$2" == category ]] || exit 95 +exec bash "$FAKE_DRUID_SOURCE" "$@" +`, + } + for name, script := range stubs { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0700); err != nil { + t.Fatal(err) + } + } + return state, func() ([]byte, error) { + cmd := exec.Command("bash", filepath.Join(root, "scripts/push.sh")) + cmd.Dir = root + cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DRUID_BIN="+filepath.Join(bin, "druid"), + "FAKE_DRUID_SOURCE="+filepath.Join(root, "scripts/tests/fixtures/fake-druid.sh"), "FAKE_DRUID_STATE_DIR="+state, + "FAKE_DRUID_SLEEP=0.001", "FAKE_DRUID_FAIL_REF=", "FAKE_PREFLIGHT_FAIL=0", "SCROLL_PUBLISH_MODE=lifecycle", + "SCROLL_PUSH_DRY_RUN=0", "SCROLL_PUSH_UI=0", "SCROLL_PUSH_CATEGORIES=1", "SCROLL_PUSH_ARTIFACTS=1", + "SCROLL_PUSH_JOBS="+jobs, "SCROLL_REGISTRY_HOST=registry.invalid", "SCROLL_REGISTRY_NAMESPACE=fixture-owner", + "SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team", "SCROLL_TAG_SUFFIX=-commit", "SOURCE_DATE_EPOCH=1700000000", + "SCROLL_REGISTRY_USER=fixture", "SCROLL_REGISTRY_PASSWORD=fixture", "DRUID_SCROLL_RUNTIME_IMAGE=", "DRUID_SCROLL_STEAMCMD_IMAGE=") + cmd.Env = append(cmd.Env, extra...) + return cmd.CombinedOutput() + } +} + +func readFixture(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func catalogCounts(t *testing.T) (artifacts, categories int) { + t.Helper() + for _, line := range strings.Split(readFixture(t, "../push.sh"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "run druid push ") { + if strings.Contains(line, "push category ") { + categories++ + } else { + artifacts++ + } + } + } + if artifacts == 0 || categories == 0 { + t.Fatal("fixture could not read the explicit catalog") + } + return +} diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index a47bbf50..cf856e91 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -110,9 +110,6 @@ fi failure_starts="$(grep -c '^start artifact ' "$tmp_dir/state/events.log")" failure_ends="$(grep -c '^end artifact ' "$tmp_dir/state/events.log")" -if [[ "$failure_starts" != "$expected_artifacts" ]]; then - sed -n '1,12p' "$tmp_dir/failure.err" >&2 -fi assert_equals "$expected_artifacts" "$failure_starts" \ "a job failure must not abandon the remaining queued artifact pushes" assert_equals "$failure_starts" "$failure_ends" \ diff --git a/scripts/validate-release-workflow/lifecycle_workflow.go b/scripts/validate-release-workflow/lifecycle_workflow.go new file mode 100644 index 00000000..b5bb5ae3 --- /dev/null +++ b/scripts/validate-release-workflow/lifecycle_workflow.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +type lifecycleWorkflow struct { + Jobs map[string]lifecycleJob `yaml:"jobs"` +} + +type lifecycleJob struct { + Uses string `yaml:"uses"` + With map[string]any `yaml:"with"` + Secrets any `yaml:"secrets"` + Env map[string]string `yaml:"env"` + Needs string `yaml:"needs"` + If string `yaml:"if"` + RunsOn string `yaml:"runs-on"` + Environment string `yaml:"environment"` + Concurrency struct { + Cancel bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Steps []lifecycleStep `yaml:"steps"` +} + +type lifecycleStep struct { + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + Env map[string]string `yaml:"env"` + With map[string]any `yaml:"with"` +} + +// Check the actual CI callers, not only the opt-in helper. Missing credentials +// must never silently select the legacy direct-publication path. +func validateLifecycleWorkflows(directory string) error { + read := func(name string) (lifecycleWorkflow, error) { + var workflow lifecycleWorkflow + data, err := os.ReadFile(filepath.Join(directory, name)) + if err == nil { + err = yaml.Unmarshal(data, &workflow) + } + return workflow, err + } + for file, public := range map[string]bool{"release.yml": true, "pr.yml": false} { + workflow, err := read(file) + if err != nil { + return err + } + job := workflow.Jobs["build-deploy"] + if len(workflow.Jobs) != 1 || job.Uses != "./.github/workflows/scroll-lifecycle.yml" || job.With["public"] != public || job.Secrets != nil { + return fmt.Errorf("%s must call the shared lifecycle with public=%t and no inherited secrets", file, public) + } + } + workflow, err := read("scroll-lifecycle.yml") + if err != nil { + return err + } + verify, publish := workflow.Jobs["verify"], workflow.Jobs["publish"] + if publish.Needs != "verify" || publish.Environment != "${{ inputs.public && 'scroll-release' || 'scroll-preview' }}" || publish.RunsOn != "ubuntu-latest" || publish.Concurrency.Cancel { + return fmt.Errorf("publication requires verification, protected environments, a fresh runner and non-cancelling concurrency") + } + wantCondition := "needs.verify.outputs.changed == 'true' && ((inputs.public && github.event_name != 'pull_request') || (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository))" + if strings.Join(strings.Fields(publish.If), " ") != wantCondition { + return fmt.Errorf("publication must reject fork PRs and public PR publication") + } + if len(verify.Env) != 0 || len(publish.Env) != 0 { + return fmt.Errorf("lifecycle credentials/configuration must be scoped to steps, not whole jobs") + } + tests, gate, cli, publishCount := false, false, false, 0 + for _, step := range verify.Steps { + if strings.Contains(step.Run, "go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow") { + tests = true + } + for _, value := range step.Env { + if strings.Contains(value, "secrets.") { + return fmt.Errorf("validation must not receive publication secrets") + } + } + } + for index, step := range publish.Steps { + if step.Name == "Require provisioned lifecycle environment" && step.Env["LIFECYCLE_READY"] == "${{ vars.SCROLL_LIFECYCLE_READY }}" && strings.Contains(step.Run, "exit 1") { + if index != 0 { + return fmt.Errorf("provisioning gate must precede every publication job step") + } + gate = true + } + if step.With["repository"] == "highcard-dev/druid-cli" { + ref, _ := step.With["ref"].(string) + cli = regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(ref) && step.With["persist-credentials"] == false + } + if step.Run != "bash ./scripts/push.sh" { + continue + } + publishCount++ + for name, want := range map[string]string{ + "SCROLL_PUBLISH_MODE": "lifecycle", + "SCROLL_REGISTRY_NAMESPACE": "${{ vars.SCROLL_LIFECYCLE_OWNER }}", + "SCROLL_REGISTRY_RUNTIME_NAMESPACE": "druid-team", + "SCROLL_REGISTRY_USER": "${{ secrets.SCROLL_TEAM_REGISTRY_USER }}", + "SCROLL_REGISTRY_PASSWORD": "${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }}", + "SCROLL_TEAM_EMAIL": "${{ secrets.SCROLL_TEAM_EMAIL }}", + "SCROLL_TEAM_PASSWORD": "${{ secrets.SCROLL_TEAM_PASSWORD }}", + "SCROLL_TAG_SUFFIX": "-${{ github.event.pull_request.head.sha || github.sha }}", + "SCROLL_LIFECYCLE_REPOSITORY_SUFFIX": "${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }}", + "SCROLL_LIFECYCLE_PUBLISH": "${{ inputs.public && '1' || '0' }}", + "SCROLL_LIFECYCLE_REVIEWED": "${{ inputs.public && '1' || '0' }}", + } { + if step.Env[name] != want { + return fmt.Errorf("lifecycle publication has incorrect %s", name) + } + } + } + if !tests || !gate || !cli || publishCount != 1 { + return fmt.Errorf("shared workflow needs publisher tests, provisioning gate, pinned CLI and one lifecycle catalog call") + } + return nil +} diff --git a/scripts/validate-release-workflow/lifecycle_workflow_test.go b/scripts/validate-release-workflow/lifecycle_workflow_test.go new file mode 100644 index 00000000..1505a330 --- /dev/null +++ b/scripts/validate-release-workflow/lifecycle_workflow_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestLifecycleWorkflows(t *testing.T) { + if err := validateLifecycleWorkflows("../../.github/workflows"); err != nil { + t.Fatal(err) + } +} + +func TestLifecycleProvisioningGate(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/scroll-lifecycle.yml") + if err != nil { + t.Fatal(err) + } + var workflow lifecycleWorkflow + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + steps := workflow.Jobs["publish"].Steps + if len(steps) == 0 || steps[0].Name != "Require provisioned lifecycle environment" { + t.Fatal("provisioning gate must run before checkout or any credential-bearing step") + } + for _, value := range []string{"", "false", "TRUE", "true"} { + t.Run("ready="+value, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "bash", "-eo", "pipefail", "-c", steps[0].Run) + command.Env = []string{"PATH=" + os.Getenv("PATH")} + if value != "" { + command.Env = append(command.Env, "LIFECYCLE_READY="+value) + } + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + if value == "true" { + if err != nil { + t.Fatalf("provisioned environment rejected: %v: %s", err, output) + } + } else if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + t.Fatalf("unprovisioned environment must exit 1: %v: %s", err, output) + } + }) + } +} + +func TestLifecycleWorkflowRejectsMissingCutoverGuards(t *testing.T) { + for name, change := range map[string][3]string{ + "legacy caller": {"release.yml", "./.github/workflows/scroll-lifecycle.yml", "./.github/workflows/legacy.yml"}, + "public preview": {"pr.yml", "public: false", "public: true"}, + "direct fallback": {"scroll-lifecycle.yml", "SCROLL_PUBLISH_MODE: lifecycle", "SCROLL_PUBLISH_MODE: direct"}, + "mutable revision": {"scroll-lifecycle.yml", "SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }}", "SCROLL_TAG_SUFFIX: -latest"}, + "public preview repository": {"scroll-lifecycle.yml", "format('-pr{0}', github.event.pull_request.number) || ''", "''"}, + "admin credential fallback": {"scroll-lifecycle.yml", "secrets.SCROLL_TEAM_REGISTRY_USER", "secrets.SCROLL_REGISTRY_USER"}, + "fork publication": {"scroll-lifecycle.yml", "github.event.pull_request.head.repo.full_name == github.repository", "true"}, + "no review environment": {"scroll-lifecycle.yml", "environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }}", "environment: production"}, + "mutable CLI": {"scroll-lifecycle.yml", "a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd", "master"}, + "cancel mutation": {"scroll-lifecycle.yml", "cancel-in-progress: false", "cancel-in-progress: true"}, + "skip publisher tests": {"scroll-lifecycle.yml", "go test ./scripts/publish-lifecycle", "go test ./scripts/prebuild"}, + "late provisioning gate": {"scroll-lifecycle.yml", " - name: Require provisioned lifecycle environment", " - run: echo premature-step\n - name: Require provisioned lifecycle environment"}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + for _, file := range []string{"release.yml", "pr.yml", "scroll-lifecycle.yml"} { + data, err := os.ReadFile(filepath.Join("../../.github/workflows", file)) + if err != nil { + t.Fatal(err) + } + if file == change[0] { + if !strings.Contains(string(data), change[1]) { + t.Fatal("mutation did not match workflow") + } + data = []byte(strings.Replace(string(data), change[1], change[2], 1)) + } + if err := os.WriteFile(filepath.Join(dir, file), data, 0600); err != nil { + t.Fatal(err) + } + } + if err := validateLifecycleWorkflows(dir); err == nil { + t.Fatal("unsafe workflow contract accepted") + } + }) + } +} diff --git a/scripts/validate-release-workflow/main.go b/scripts/validate-release-workflow/main.go index d0796d05..92c5fd0a 100644 --- a/scripts/validate-release-workflow/main.go +++ b/scripts/validate-release-workflow/main.go @@ -33,6 +33,10 @@ func main() { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } + if err := validateLifecycleWorkflows(".github/workflows"); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } } func validateReleaseWorkflow(path string) error { From bf621d5987fce2fd108a269a7a08231329ee7cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 22 Oct 2026 19:08:54 +0200 Subject: [PATCH 3/6] fix(publish): use current owner publication API --- .github/workflows/scroll-lifecycle.yml | 54 ++-- docs/shared-lifecycle-publication.md | 231 +++++--------- .../local_integration_test.go | 206 ------------- scripts/publish-lifecycle/main.go | 266 ----------------- scripts/publish-lifecycle/main_test.go | 218 -------------- scripts/publish-lifecycle/push_script_test.go | 174 ----------- scripts/publish-owned/main.go | 281 ++++++++++++++++++ scripts/publish-owned/main_test.go | 170 +++++++++++ scripts/push.sh | 56 +++- scripts/tests/push-parallel.test.sh | 15 + .../lifecycle_workflow.go | 42 ++- .../lifecycle_workflow_test.go | 12 +- 12 files changed, 648 insertions(+), 1077 deletions(-) delete mode 100644 scripts/publish-lifecycle/local_integration_test.go delete mode 100644 scripts/publish-lifecycle/main.go delete mode 100644 scripts/publish-lifecycle/main_test.go delete mode 100644 scripts/publish-lifecycle/push_script_test.go create mode 100644 scripts/publish-owned/main.go create mode 100644 scripts/publish-owned/main_test.go diff --git a/.github/workflows/scroll-lifecycle.yml b/.github/workflows/scroll-lifecycle.yml index 592812a9..115794b8 100644 --- a/.github/workflows/scroll-lifecycle.yml +++ b/.github/workflows/scroll-lifecycle.yml @@ -36,8 +36,8 @@ jobs: run: make build-tree - name: Validate catalog and UI run: go run ./scripts/validate-release-workflow && go run ./scripts/validate-scrolls.go && bash ./scripts/validate_ui_coverage.sh - - name: Test lifecycle publication - run: go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow -count=1 + - name: Test direct publication support + run: go test ./scripts/stage-scroll-ui ./scripts/validate-release-workflow ./scripts/publish-owned -count=1 - name: Test bounded catalog publication run: bash ./scripts/tests/push-parallel.test.sh - name: Check release inputs @@ -59,7 +59,7 @@ jobs: needs.verify.outputs.changed == 'true' && ((inputs.public && github.event_name != 'pull_request') || (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) - # A fresh runner keeps Team credentials out of the untrusted validation job. + # A fresh runner keeps publisher credentials out of the untrusted validation job. runs-on: ubuntu-latest timeout-minutes: 360 environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }} @@ -72,7 +72,7 @@ jobs: LIFECYCLE_READY: ${{ vars.SCROLL_LIFECYCLE_READY }} run: | if [[ "$LIFECYCLE_READY" != "true" ]]; then - echo "Configure the protected Team lifecycle environment before publication." >&2 + echo "Configure the protected lifecycle environment before publication." >&2 exit 1 fi - uses: actions/checkout@v4 @@ -110,21 +110,39 @@ jobs: run: make build-tree - name: Validate with pinned CLI run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Publish through shared lifecycle + - name: Push to publisher-owned registry repositories env: - SCROLL_PUBLISH_MODE: lifecycle + SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST }} + SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }} + SCROLL_REGISTRY_RUNTIME_NAMESPACE: ${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || 'druid-team' }} + # Owner revisions embed presentation; category-only OCI artifacts are + # not Scroll revisions and must not enter a publication snapshot. + SCROLL_PUSH_CATEGORIES: '0' + DRUID_SCROLL_RUNTIME_IMAGE: ${{ vars.DRUID_SCROLL_RUNTIME_IMAGE }} + DRUID_SCROLL_STEAMCMD_IMAGE: ${{ vars.DRUID_SCROLL_STEAMCMD_IMAGE }} + SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} + SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} + SCROLL_PUSH_MANIFEST: ${{ runner.temp }}/scroll-artifacts.txt SCROLL_LIFECYCLE_URL: ${{ vars.SCROLL_LIFECYCLE_URL }} SCROLL_AUTH_URL: ${{ vars.SCROLL_AUTH_URL }} - SCROLL_LIFECYCLE_OWNER: ${{ vars.SCROLL_LIFECYCLE_OWNER }} - SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_NAMESPACE: ${{ vars.SCROLL_LIFECYCLE_OWNER }} - SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team - SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_TEAM_REGISTRY_USER }} - SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }} - SCROLL_TEAM_EMAIL: ${{ secrets.SCROLL_TEAM_EMAIL }} - SCROLL_TEAM_PASSWORD: ${{ secrets.SCROLL_TEAM_PASSWORD }} + SCROLL_PUBLISH_EMAIL: ${{ secrets.SCROLL_PUBLISH_EMAIL }} + SCROLL_PUBLISH_PASSWORD: ${{ secrets.SCROLL_PUBLISH_PASSWORD }} + SCROLL_LIFECYCLE_REVIEWED: ${{ vars.SCROLL_LIFECYCLE_REVIEWED }} + SCROLL_PUBLIC: ${{ inputs.public && '1' || '0' }} SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }} - SCROLL_LIFECYCLE_REPOSITORY_SUFFIX: ${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }} - SCROLL_LIFECYCLE_PUBLISH: ${{ inputs.public && '1' || '0' }} - SCROLL_LIFECYCLE_REVIEWED: ${{ inputs.public && '1' || '0' }} - run: bash ./scripts/push.sh + run: | + set -euo pipefail + for name in SCROLL_REGISTRY_HOST SCROLL_OWNER_ID SCROLL_REGISTRY_USER SCROLL_REGISTRY_PASSWORD; do + if [[ -z "${!name}" ]]; then + echo "Missing required publisher setting: $name" >&2 + exit 1 + fi + done + if [[ "$SCROLL_PUBLIC" == "1" ]]; then + go run ./scripts/publish-owned preflight + fi + : > "$SCROLL_PUSH_MANIFEST" + bash ./scripts/push.sh + if [[ "$SCROLL_PUBLIC" == "1" ]]; then + go run ./scripts/publish-owned publish + fi diff --git a/docs/shared-lifecycle-publication.md b/docs/shared-lifecycle-publication.md index fd52626f..afe201da 100644 --- a/docs/shared-lifecycle-publication.md +++ b/docs/shared-lifecycle-publication.md @@ -1,161 +1,74 @@ # Shared authored-release publication -The local workflow draft routes release and PR publication through the shared -lifecycle. This is a local-only recovery; it has not been pushed or run on GitHub. -Dedicated Team -identity/credentials and protected GitHub environments are not provisioned. -Do not treat this as deployed or as complete product acceptance. - -## Local CI cutover draft - -`release.yml` and `pr.yml` call `scroll-lifecycle.yml`; neither retains a direct -registry publication fallback. The shared workflow validates/builds UI, checks -the catalog and runs publisher/staging/workflow tests without Team secrets. -Publication then uses a fresh runner and the protected `scroll-release` or -`scroll-preview` environment. Fork PRs validate but never enter publication. - -The publication job builds CLI commit -`a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd`, which includes fixed-source timestamps -and deterministic layer order, rather than assuming the old released binary has -those behaviors. It checks out the exact release/PR-head SHA, suffixes revisions -with that SHA, and uses separate `-pr` private repositories for previews. -Runtime-image references remain separate from the private staging project. -In-flight publication is not cancelled when a newer run arrives. - -The existing direct script mode remains available for local Harbor bootstrap; -neither CI caller selects it. CI still uses the one explicit catalog and its -category barrier. Categories go into the publisher's private staging project; -final revisions embed their own presentation metadata. - -## Rollout prerequisites (no remote changes performed) - -Before enabling the draft: - -1. Deploy matching Core/runtime lifecycle changes through their normal CI. -2. Create a normal dedicated Team account and issue a private-project robot - through the normal registry-credentials flow. Do not reuse the old admin or - public-project robot. -3. Create `scroll-release` and `scroll-preview` GitHub environments. Require - approval of the exact source commit and disable self-approval. Restrict release - deployment branches/tags to the reviewed release sources. Preview approval - must cover source code that will receive the dedicated preview credentials. -4. Put `GO_REPO_TOKEN` (read-only CLI source access), `SCROLL_TEAM_EMAIL`, - `SCROLL_TEAM_PASSWORD`, `SCROLL_TEAM_REGISTRY_USER` and - `SCROLL_TEAM_REGISTRY_PASSWORD` in those environments, not validation jobs. - Set environment variables `SCROLL_LIFECYCLE_URL`, `SCROLL_AUTH_URL`, - `SCROLL_LIFECYCLE_OWNER` and scheme-less `SCROLL_REGISTRY_HOST`. -5. Only after checking protections and deployed dependencies, set the environment - variable `SCROLL_LIFECYCLE_READY=true`. A missing/false value fails the job; - it does not skip publication successfully or fall back to direct pushes. -6. Review branch-protection required-check names after the shared-workflow change, - and run the first approved preview/release with fresh credentials. - -Workflow YAML names an environment but does not create its required-reviewer -policy. Environment secrets belong to the called job, as described in -[GitHub's reusable-workflow documentation](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). - -## Contract - -`scripts/push.sh` remains the single explicit catalog and retains its category -barrier, serial default and bounded concurrency. `SCROLL_PUBLISH_MODE=lifecycle` -adds the shared Core policy after each rendered artifact push: - -1. Verify the authenticated publisher's private project against the configured - owner and registry. Reject admin/public-project registry credentials. -2. Render/stage the existing Scroll and private UI. Include inherited family - `.meta` in the revision, not just a mutable category artifact. -3. Push into that private project with a cryptographically unique staging tag. -4. Resolve that tag once, then import its immutable digest through Core. -5. Core applies canonical identity before hashing, verifies final bytes, handles - immutable-tag conflicts and refreshes the catalog under its repository lock. -6. When explicitly reviewed, publish through the same repository-wide operation - used by customers. No public-registry credentials or Team bypass are involved. - -The source staging artifact is retained. Backup snapshots are rejected by the -authored-release endpoint; they must use backup promotion with provenance. - -## Configuration contract (not provisioned) - -- `SCROLL_LIFECYCLE_URL`: trusted Core base URL, including `/core` at the gateway. -- `SCROLL_LIFECYCLE_OWNER`: identity ID of the dedicated publisher account. -- `SCROLL_REGISTRY_HOST`: scheme-less configured Harbor host. -- `SCROLL_REGISTRY_NAMESPACE`: must equal that identity ID (private staging). -- `SCROLL_REGISTRY_USER` / `SCROLL_REGISTRY_PASSWORD`: robot credential belonging - to that private project, issued by the normal account registry-credential flow. -- `SCROLL_AUTH_URL`, `SCROLL_TEAM_EMAIL`, `SCROLL_TEAM_PASSWORD`: normal dedicated - account sign-in. Auth must have the same trusted origin as Core. Session cookies - stay in memory, owner JWTs refresh before lifecycle calls, and sign-out is - attempted at exit. Auth credentials are not passed to the renderer subprocess. -- Alternatively `SCROLL_LIFECYCLE_TOKEN`: a fresh externally supplied owner JWT; - expiration fails closed. Never commit or print credentials. -- `SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team`: keeps runtime images separate - from the private staging project. -- `SCROLL_TAG_SUFFIX=-`: immutable revision names for repeatable CI. - Reusing a tag with changed content is a conflict, not an overwrite. -- Lifecycle mode sets `SOURCE_DATE_EPOCH` from the checked-out commit unless - explicitly supplied. The matching CLI must support it and deterministic root - layer ordering: otherwise identical rebuilds correctly conflict as different - manifest bytes. -- `SCROLL_LIFECYCLE_PUBLISH=1` plus `SCROLL_LIFECYCLE_REVIEWED=1`: explicitly - reviewed public publication. Without publish, imports remain private unless - the destination was already public, in which case unreviewed import fails. -- `SCROLL_LIFECYCLE_REPOSITORY_SUFFIX=-pr`: **separate private preview - repositories**. A PR tag in an already-public release repository is not private. - -Keep secrets restricted to trusted publication jobs and require a release review -gate. No production account, robot, GitHub secret, or environment has been created -by this implementation. Both Core import support and the matching runtime/server -lifecycle PRs must be deployed before enabling the workflows. - -## Verification - -- Recovery on 2026-10-03 reconstructed the latest retained changes from session - history in `druid-local/.worktrees/scroll-lifecycle/scrolls`, on the local - branch `recovery/scroll-lifecycle-20261003`. Publisher, UI-staging and workflow - suites pass freshly, as do workflow validation and actionlint. The earlier - live acceptance results below are historical, not rerun during recovery. -- Local workflow syntax checks pass with actionlint v1.7.7 (ShellCheck disabled). - Publisher, UI-staging and workflow-validator Go suites pass together. - Twelve workflow mutation cases reject missing cutover guards; executing the - actual provisioning guard proves unset/false/uppercase values fail and only - `true` passes. The guard must precede every other publication-job step. - Independent spec/standards review found no blocking defects; the standards - review prompted the executable provisioning-guard regression test. - This verifies the local draft, not GitHub environment protection or live CI. -- `go test ./scripts/publish-lifecycle -run '^TestPushScript' -count=3`: - passes (37.753s). The real `push.sh` runs against offline boundaries, covering - all category/artifact calls, immutable timestamp/tag inputs, separate runtime - image namespace, lifecycle artifact concurrency, category barrier, preflight - rejection before mutation and complete job reaping after a publisher failure. - This does not exercise live Core or prove that CI selects lifecycle mode. -- Go publisher tests cover shared private/public operations, immutable staging, - expected-owner rejection before push, failed-push isolation, separate private - preview identity, explicit review, fresh JWTs and redirect credential isolation. -- Metadata staging tests check inherited presentation and the existing private UI. -- `SCROLL_PUSH_UI=0 bash scripts/tests/push-parallel.test.sh`: passed. -- Full staging-enabled concurrency run: passed, 18 categories / 127 artifacts. - One earlier run during concurrent edits stopped early (47 artifacts); its - original staging error was not retained. The rerun passed without a pool fix; - do not misreport that earlier result as a diagnosed production defect. -- Core shared policy/adapter/schema tests and real local Harbor/Postgres tests - cover authored imports, immutable conflicts, public review guards, byte - preservation and private staging. See the monorepo verification document. -- Authenticated helper → local gateway/auth → live Core → Harbor acceptance - passed in 7.10s, including private import, an identical digest on rebuild, - shared publication and anonymous visibility. The fixture's robot, projects, - catalog row, auth account/session and development credit were cleaned up. - Four retained fixtures from earlier diagnosis attempts were also removed with - exact identity/name/email guards. CI credential provisioning and rollout are - still outstanding; no production account or registry was touched. -- Earlier live failures identified fixture username/schema mistakes and a real - ORAS creation-timestamp reproducibility gap. The timestamp fix preserves - immutable-tag conflicts rather than weakening them. -- Subsequent retry failures exposed nondeterministic root-layer ordering in the - CLI. A focused real OCI push regression reproduced it in 0.08s; sorting the - discovered paths fixed the byte difference without weakening conflict checks. - The final live test explicitly signs out after signup and signs back in with - the dedicated fixture account. Three consecutive runs passed (8.53s, 6.25s, - 6.16s), including exact retry, publication, anonymous visibility and cleanup. - -On interruption, inspect final registry state before retrying. A disconnected -HTTP request does not prove that an import or publication was rolled back. +## Current contract + +Release and PR workflows use the same explicit catalog in `scripts/push.sh`. +They push directly to `//:`; there is +no staging/import API. Core publishes reviewed revisions by copying an exact +snapshot into the owner's public username project. The private source remains. +See the monorepo's `docs/scroll-lifecycle-api-cutover.md` for the Core contract. + +1. Validate and build without publication credentials. +2. Enter a fresh runner and protected `scroll-release` or `scroll-preview` + environment. Fork PRs never publish; a missing readiness gate fails closed. +3. Build the pinned lifecycle-capable CLI and the Scroll UI. +4. Push owner-scoped revisions tagged with the source commit. Runtime images + stay in a separate namespace. Revisions embed presentation; category-only + artifacts are excluded from owner publication. +5. For public releases, `publish-owned` authenticates as the owner, fetches + each repository's publication preview, verifies every pushed tag against + that preview, and submits it unchanged as `expectedSnapshot`. +6. Core rejects changed content. The helper does not automatically retry + conflicts or replace pending operation intent; inspect and use the current + manual recovery flow before retrying. + +Public publication approves the complete repository, not only the latest CI +tag. Use separate release and preview identities/projects. Preview pushes stay +private. Publication does not cancel an in-flight run when another run starts. + +## Configuration + +Provision these through the protected environments, never source control: + +| Setting | Purpose | +| --- | --- | +| `SCROLL_LIFECYCLE_READY=true` | Explicit provisioning gate | +| `SCROLL_OWNER_ID` | Authenticated identity ID naming the private project | +| `SCROLL_REGISTRY_HOST` | Scheme-less Harbor host | +| `SCROLL_REGISTRY_USER`, `SCROLL_REGISTRY_PASSWORD` | Ordinary owner-scoped registry credentials | +| `SCROLL_REGISTRY_RUNTIME_NAMESPACE` | Runtime-image namespace; workflow defaults to `druid-team` | +| `SCROLL_LIFECYCLE_URL`, `SCROLL_AUTH_URL` | Core and Auth URLs sharing the trusted origin | +| `SCROLL_PUBLISH_EMAIL`, `SCROLL_PUBLISH_PASSWORD` | Ordinary publisher sign-in for public publication | +| `SCROLL_LIFECYCLE_REVIEWED=1` | Confirms content review before public publication | +| `GO_REPO_TOKEN` | Read-only access to the pinned CLI source | + +Optional `DRUID_SCROLL_RUNTIME_IMAGE` and `DRUID_SCROLL_STEAMCMD_IMAGE` +override runtime image references. Owner builds use the commit timestamp unless +`SOURCE_DATE_EPOCH` is explicitly set. The workflow sets `SCROLL_PUSH_MANIFEST` +and commit-based `SCROLL_TAG_SUFFIX`; successful pushes populate the manifest. + +`SCROLL_REGISTRY_NAMESPACE` is not a workflow requirement: `SCROLL_OWNER_ID` +determines artifact destinations, and runtime images have their own setting. +The shared push script still supports namespaces and category publication for +local bootstrap. It retains its serial default, bounded parallel option, +category barrier, and failure handling. + +## Rollout and verification + +- Deploy matching Core/runtime contracts through their normal CI. +- Configure protected environments, required reviewers and separate identities + before enabling the readiness gate. Workflow YAML does not provision those + policies. +- Publish a Druid UI package containing `ensureFileService`, + `addAndRunCommand` and `stopCommand` before releasing a consumer that uses + these imports. Local `1000.*` versions and localhost registry URLs are not + release dependencies. +- Run `go test ./scripts/publish-owned ./scripts/stage-scroll-ui ./scripts/validate-release-workflow` + and `bash ./scripts/tests/push-parallel.test.sh`. These test the publisher + contract and catalog; they do not constitute a production publication. +- Verify the first approved preview/release with the provisioned credentials. + Do not weaken approval or readiness gates to make a run green. + +The former staging/import publisher and its tests were replaced. Historical +acceptance results remain in Git history and the monorepo lifecycle audit; +they do not certify this direct-owner publisher or a production rollout. diff --git a/scripts/publish-lifecycle/local_integration_test.go b/scripts/publish-lifecycle/local_integration_test.go deleted file mode 100644 index 1d7a4d67..00000000 --- a/scripts/publish-lifecycle/local_integration_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package main - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/http/cookiejar" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" - "time" -) - -// Opt-in localhost only. Creates an isolated publisher account/project and one -// tiny Scroll. It neither deploys a workload nor consumes shared game ports. -func TestLocalAuthenticatedPublisher(t *testing.T) { - if os.Getenv("SCROLL_LOCAL_LIFECYCLE_TEST") != "1" { - t.Skip("local live stack opt-in") - } - bin := os.Getenv("DRUID_BIN") - if bin == "" { - t.Fatal("DRUID_BIN is required") - } - jar, _ := cookiejar.New(nil) - p := &publisher{base: "http://localhost:3000/api/core", authURL: "http://localhost:3000/api/auth/v2", host: "druid-gs:8088", client: &http.Client{Jar: jar, Timeout: 2 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} - nonce := make([]byte, 16) - if _, err := rand.Read(nonce); err != nil { - t.Fatal(err) - } - suffix := hex.EncodeToString(nonce) - var account struct { - User struct { - ID string `json:"id"` - } `json:"user"` - } - if err := p.authRequest(http.MethodPost, "/sign-up/email", map[string]any{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!", "name": "Local lifecycle acceptance", "username": "lifecycle" + suffix[:12]}, &account); err != nil { - t.Fatal(err) - } - p.owner = account.User.ID - if p.owner == "" { - t.Fatal("signup returned no fixture identity") - } - t.Logf("local fixture identity: %s", p.owner) - t.Cleanup(func() { cleanupLocalPublisher(t, p.owner) }) - t.Cleanup(func() { _ = p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) }) - // Exercise the same normal dedicated-account sign-in used by CI, not just - // the initial signup session returned by fixture setup. - if err := p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil); err != nil { - t.Fatal(err) - } - if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": "lifecycle-" + suffix + "@example.invalid", "password": suffix + "-Fixture!"}, nil); err != nil { - t.Fatal(err) - } - if err := p.preflight(); err != nil { - t.Fatal(err) - } - var robot struct { - ID int `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := p.request(http.MethodPost, "/v1/registry/credentials", map[string]string{"name": "lifecycle-fixture"}, &robot); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := p.request(http.MethodDelete, fmt.Sprintf("/v1/registry/credentials/%d", robot.ID), nil, nil); err != nil { - t.Error("fixture credential revocation failed") - } - }) - cliHome := t.TempDir() - run := func(args []string) error { - cmd := exec.Command(args[0], args[1:]...) - for _, value := range os.Environ() { - if !strings.HasPrefix(value, "HOME=") { - cmd.Env = append(cmd.Env, value) - } - } - cmd.Env = append(cmd.Env, "HOME="+cliHome, "DRUID_REGISTRY_PLAIN_HTTP=true", "SOURCE_DATE_EPOCH=0") - output, err := cmd.CombinedOutput() - if err != nil && len(args) > 1 && args[1] != "login" { - // Fixture-only diagnostics: never print auth/login output or full logs. - for _, line := range strings.Split(string(output), "\n") { - if strings.HasPrefix(line, "Error:") { - t.Log(strings.ReplaceAll(strings.ReplaceAll(line, robot.Password, ""), p.token, "")) - } - } - } - return err - } - if err := run([]string{bin, "login", "--host", p.host, "--user", robot.Username, "--password", robot.Password}); err != nil { - t.Fatal("fixture registry login failed") - } - source := t.TempDir() - if err := os.Mkdir(filepath.Join(source, ".meta"), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, ".meta", "en-US.md"), []byte("---\nname: Local authored fixture\n---\nNo customer content.\n"), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: fixture\ndesc: Local authored fixture\nversion: 1.0.0\napp_version: '1.0.0'\ncommands:\n start:\n run: persistent\n procedures:\n - id: fixture\n command: [sleep, '600']\n image: busybox:1.36\n"), 0644); err != nil { - t.Fatal(err) - } - command := []string{bin, "push", p.host + "/" + p.owner + "/example:accepted-fixture", source, "--category", "fixture"} - if err := p.publish(command, run); err != nil { - t.Fatal(err) - } - // Repeat exactly through the real authenticated public operation. It must - // verify the existing finalized bytes, not overwrite the revision tag. - p.public = true - if err := p.publish(command, run); err != nil { - t.Fatal(err) - } - t.Log("authenticated private staging, exact retry and shared publication passed") - // Assert publication through Harbor's public metadata without owner cookies. - projectName := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(p.owner))) - response, err := http.Get("http://druid-gs:8088/api/v2.0/projects?page_size=100&name=" + projectName) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatal("anonymous public project listing failed") - } - var projects []struct { - Name string `json:"name"` - Metadata struct { - Public string `json:"public"` - } `json:"metadata"` - } - if err := json.NewDecoder(response.Body).Decode(&projects); err != nil { - t.Fatal(err) - } - found := false - for _, project := range projects { - if project.Name == projectName && project.Metadata.Public == "true" { - found = true - } - } - if !found { - t.Fatal("public fixture was not listed") - } -} - -func cleanupLocalPublisher(t *testing.T, owner string) { - t.Helper() - if !regexp.MustCompile(`^[a-f0-9-]{36}$`).MatchString(owner) { - t.Error("invalid fixture owner; cleanup refused") - return - } - fixture := fmt.Sprintf(`SELECT id::text FROM auth."user" WHERE id='%s' AND name='Local lifecycle acceptance' AND email ~ '^lifecycle-[a-f0-9]{32}@example[.]invalid$'`, owner) - verified, err := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-At", "-c", fixture).Output() - if err != nil || strings.TrimSpace(string(verified)) != owner { - t.Error("exact local fixture identity could not be verified; cleanup refused") - return - } - canonicalProject := fmt.Sprintf("scroll-%x-example", sha256.Sum256([]byte(owner))) - for _, project := range []string{owner, canonicalProject} { - for _, path := range []string{"/api/v2.0/projects/" + project + "/repositories/example", "/api/v2.0/projects/" + project} { - req, _ := http.NewRequest(http.MethodDelete, "http://druid-gs:8088"+path, nil) - password := os.Getenv("LOCAL_HARBOR_TEST_PASSWORD") - if password == "" { - password = "admin" - } - req.SetBasicAuth("admin", password) - req.Header.Set("X-Is-Resource-Name", "true") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Error("local Harbor fixture cleanup failed") - return - } - resp.Body.Close() - if resp.StatusCode != 404 && (resp.StatusCode < 200 || resp.StatusCode >= 300) { - t.Errorf("local Harbor fixture cleanup HTTP %d", resp.StatusCode) - return - } - } - } - // Only this generated fixture identity is eligible. FK failures roll back - // the entire database cleanup; unrelated/user-owned rows are never targeted. - sql := fmt.Sprintf(`BEGIN; -DELETE FROM scroll.repository WHERE user_id::text IN (%s); -DELETE FROM core.ledger_entry WHERE user_id::text IN (%s); -DELETE FROM core."user" WHERE identity_id::text IN (%s); -DELETE FROM auth."user" WHERE id::text IN (%s); -COMMIT;`, fixture, fixture, fixture, fixture) - cmd := exec.Command("docker", "exec", "druid-postgres", "psql", "-U", "druid", "-d", "druid", "-v", "ON_ERROR_STOP=1", "-c", sql) - if err := cmd.Run(); err != nil { - t.Error("fixture database cleanup failed; exact identity retained:", owner) - } -} - -func TestCleanupRetainedLocalPublisherFixtures(t *testing.T) { - ids := os.Getenv("SCROLL_LOCAL_FIXTURE_CLEANUP") - if ids == "" { - t.Skip("explicit retained-fixture IDs required") - } - for _, id := range strings.Split(ids, ",") { - cleanupLocalPublisher(t, id) - } -} diff --git a/scripts/publish-lifecycle/main.go b/scripts/publish-lifecycle/main.go deleted file mode 100644 index 264e6303..00000000 --- a/scripts/publish-lifecycle/main.go +++ /dev/null @@ -1,266 +0,0 @@ -// Publishes the existing explicit catalog through the shared Core lifecycle. -// Registry credentials can write private staging only; Core owns finalization. -package main - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "os/exec" - "regexp" - "strings" - "time" -) - -type publisher struct { - base, token, owner, host, repositorySuffix string - public bool - client *http.Client - authURL string -} - -var exact = regexp.MustCompile(`^[^@\s]+@sha256:[a-f0-9]{64}$`) - -func configured() (*publisher, error) { - base := strings.TrimRight(os.Getenv("SCROLL_LIFECYCLE_URL"), "/") - u, err := url.Parse(base) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "druid-gs"))) { - return nil, errors.New("SCROLL_LIFECYCLE_URL must be HTTPS (HTTP allowed only for local development)") - } - p := &publisher{base: base, token: os.Getenv("SCROLL_LIFECYCLE_TOKEN"), owner: os.Getenv("SCROLL_LIFECYCLE_OWNER"), host: os.Getenv("SCROLL_REGISTRY_HOST"), repositorySuffix: os.Getenv("SCROLL_LIFECYCLE_REPOSITORY_SUFFIX"), public: os.Getenv("SCROLL_LIFECYCLE_PUBLISH") == "1", - client: &http.Client{Timeout: 70 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} - if p.owner == "" || p.host == "" || os.Getenv("SCROLL_REGISTRY_NAMESPACE") != p.owner { - return nil, errors.New("expected owner, registry host and matching private staging namespace are required") - } - if p.public && os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { - return nil, errors.New("public publication requires SCROLL_LIFECYCLE_REVIEWED=1 after content review") - } - // Never reuse production admin/public-project credentials for staging. - if !strings.HasPrefix(os.Getenv("SCROLL_REGISTRY_USER"), "robot$"+p.owner+"+") { - return nil, errors.New("registry credentials must belong to the expected owner's private project") - } - if p.token == "" { - auth, err := url.Parse(strings.TrimRight(os.Getenv("SCROLL_AUTH_URL"), "/")) - if err != nil || auth.Host != u.Host || auth.Scheme != u.Scheme || auth.User != nil || auth.RawQuery != "" || auth.Fragment != "" { - return nil, errors.New("SCROLL_AUTH_URL must use the same trusted origin as Core") - } - email, password := os.Getenv("SCROLL_TEAM_EMAIL"), os.Getenv("SCROLL_TEAM_PASSWORD") - if email == "" || password == "" { - return nil, errors.New("provide a fresh lifecycle token or dedicated Team sign-in credentials") - } - p.authURL = auth.String() - p.client.Jar, _ = cookiejar.New(nil) - if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": email, "password": password}, nil); err != nil { - return nil, err - } - } - return p, nil -} - -// Use normal account sign-in and refreshed JWTs, not a Team-specific auth bypass. -// Session cookies stay in memory and are revoked on exit. -func (p *publisher) authRequest(method, path string, body, out any) error { - data, err := json.Marshal(body) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, method, p.authURL+path, bytes.NewReader(data)) - if err != nil { - return errors.New("invalid auth request") - } - req.Header.Set("Content-Type", "application/json") - if origin, err := url.Parse(p.authURL); err == nil { - req.Header.Set("Origin", origin.Scheme+"://"+origin.Host) - } - resp, err := p.client.Do(req) - if err != nil { - return errors.New("publisher authentication failed") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var failure struct { - Code string `json:"code"` - } - _ = json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&failure) - if !regexp.MustCompile(`^[A-Z_]{1,64}$`).MatchString(failure.Code) { - failure.Code = "AUTH_FAILED" - } - return fmt.Errorf("publisher authentication rejected: HTTP %d (%s)", resp.StatusCode, failure.Code) - } - if out != nil { - return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) - } - return nil -} - -func (p *publisher) request(method, path string, body, out any) error { - if p.authURL != "" { - var jwt struct { - Token string `json:"token"` - } - if err := p.authRequest(http.MethodGet, "/token", nil, &jwt); err != nil { - return err - } - if jwt.Token == "" { - return errors.New("authentication returned no owner JWT") - } - p.token = jwt.Token - } - var data []byte - var err error - if body != nil { - data, err = json.Marshal(body) - if err != nil { - return err - } - } - req, err := http.NewRequest(method, p.base+path, bytes.NewReader(data)) - if err != nil { - return errors.New("invalid lifecycle request") - } - req.Header.Set("Authorization", "Bearer "+p.token) - req.Header.Set("Content-Type", "application/json") - resp, err := p.client.Do(req) - if err != nil { - return errors.New("lifecycle request failed; inspect installed/published state before retrying") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("lifecycle request rejected: HTTP %d", resp.StatusCode) - } - if out != nil { - return json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(out) - } - return nil -} - -func (p *publisher) preflight() error { - var project struct { - Registry string `json:"registry"` - Project string `json:"project"` - } - // The shared operation ensures this is the authenticated actor's PRIVATE - // project. A supplied owner name alone is never treated as authorization. - if err := p.request(http.MethodGet, "/v1/registry/project", nil, &project); err != nil { - return err - } - if project.Registry != p.host || project.Project != p.host+"/"+p.owner { - return errors.New("authenticated identity or registry differs from the expected publisher") - } - return nil -} - -func (p *publisher) publish(command []string, run func([]string) error) error { - if len(command) < 4 || command[1] != "push" || command[2] == "category" { - return errors.New("expected a rendered artifact push command") - } - ref := command[2] - prefix := p.host + "/" + p.owner + "/" - if !strings.HasPrefix(ref, prefix) { - return errors.New("artifact must target the verified private staging project") - } - nameTag := strings.TrimPrefix(ref, prefix) - parts := strings.Split(nameTag, ":") - if len(parts) != 2 || strings.Contains(parts[0], "/") || !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(parts[0]) || !regexp.MustCompile(`^[\w][\w.-]{0,127}$`).MatchString(parts[1]) { - return errors.New("invalid repository or explicit revision tag") - } - name := parts[0] + p.repositorySuffix - if !regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`).MatchString(name) { - return errors.New("invalid final repository name") - } - if err := p.preflight(); err != nil { - return err - } - nonce := make([]byte, 16) - if _, err := rand.Read(nonce); err != nil { - return err - } - // Each invocation owns its staging tag, so another job cannot replace the - // mutable selection between our push and the single digest resolution. - stagedRef := prefix + parts[0] + ":build-" + hex.EncodeToString(nonce) - stagedCommand := append([]string(nil), command...) - stagedCommand[2] = stagedRef - // Final reusable revisions embed presentation instead of depending on a - // separately mutable category artifact. Staging supplies inherited .meta. - stagedCommand = append(stagedCommand, "--pack-meta") - if err := run(stagedCommand); err != nil { - return err - } - var staged struct { - Artifact string `json:"artifact"` - } - if err := p.request(http.MethodGet, "/v1/registry/scrolls/revision?artifact="+url.QueryEscape(stagedRef), nil, &staged); err != nil { - return err - } - if !exact.MatchString(staged.Artifact) || !strings.HasPrefix(staged.Artifact, prefix+parts[0]+"@") { - return errors.New("resolved staging revision differs from selected repository") - } - var imported struct { - Artifact string `json:"artifact"` - } - input := map[string]any{"repository": name, "tag": parts[1], "releaseArtifact": staged.Artifact, "reviewedPublicContent": p.public} - if err := p.request(http.MethodPost, "/v1/registry/scrolls/releases/import", input, &imported); err != nil { - return err - } - ownerHash := sha256.Sum256([]byte(p.owner)) - canonical := fmt.Sprintf("%s/scroll-%x-%s/%s", p.host, ownerHash, name, name) - if !exact.MatchString(imported.Artifact) || !strings.HasPrefix(imported.Artifact, canonical+"@") { - return errors.New("Core did not return an exact finalized revision") - } - if p.public { - digest := strings.SplitN(imported.Artifact, "@", 2)[1] - if err := p.request(http.MethodPost, "/v1/registry/scrolls/publish", map[string]any{"repository": name, "revision": digest, "reviewedPublicContent": true}, nil); err != nil { - return err - } - } - fmt.Println(imported.Artifact) - return nil -} - -func runPublisher() error { - p, err := configured() - if err == nil && p.authURL != "" { - defer p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) - } - if err == nil { - if len(os.Args) == 2 && os.Args[1] == "preflight" { - err = p.preflight() - } else { - args := os.Args[1:] - if len(args) > 0 && args[0] == "--" { - args = args[1:] - } - err = p.publish(args, func(command []string) error { - cmd := exec.Command(command[0], command[1:]...) - for _, entry := range os.Environ() { - if strings.HasPrefix(entry, "SCROLL_TEAM_PASSWORD=") || strings.HasPrefix(entry, "SCROLL_TEAM_EMAIL=") || strings.HasPrefix(entry, "SCROLL_LIFECYCLE_TOKEN=") { - continue - } - cmd.Env = append(cmd.Env, entry) - } - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr - return cmd.Run() - }) - } - } - return err -} - -func main() { - if err := runPublisher(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/scripts/publish-lifecycle/main_test.go b/scripts/publish-lifecycle/main_test.go deleted file mode 100644 index 23f69a68..00000000 --- a/scripts/publish-lifecycle/main_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestSharedLifecyclePrivateStagingAndExplicitPublication(t *testing.T) { - for _, public := range []bool{false, true} { - t.Run(fmt.Sprint(public), func(t *testing.T) { - var staged string - var imported, published int - name := "scroll-example" - suffix := "" - if !public { - suffix = "-pr12" - } - name += suffix - canonical := fmt.Sprintf("registry.test/scroll-%x-%s/%s", sha256.Sum256([]byte("team-owner")), name, name) - digest := "sha256:" + strings.Repeat("a", 64) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer fixture" { - t.Error("owner authorization missing") - } - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/v1/registry/project": - fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) - case "/v1/registry/scrolls/revision": - if staged == "" || r.URL.Query().Get("artifact") != staged { - t.Error("did not freeze own staging tag") - } - json.NewEncoder(w).Encode(map[string]string{"artifact": "registry.test/team-owner/scroll-example@" + digest}) - case "/v1/registry/scrolls/releases/import": - imported++ - var input struct { - Repository, Tag, ReleaseArtifact string - ReviewedPublicContent bool - } - json.NewDecoder(r.Body).Decode(&input) - if input.Repository != name || input.Tag != "v1-commit" || input.ReleaseArtifact != "registry.test/team-owner/scroll-example@"+digest || input.ReviewedPublicContent != public { - t.Errorf("wrong import contract: %+v", input) - } - json.NewEncoder(w).Encode(map[string]string{"artifact": canonical + "@" + digest}) - case "/v1/registry/scrolls/publish": - published++ - var input struct { - Repository, Revision string - ReviewedPublicContent bool - } - json.NewDecoder(r.Body).Decode(&input) - if input.Repository != name || input.Revision != digest || !input.ReviewedPublicContent { - t.Error("publication lost exact revision/review") - } - fmt.Fprint(w, `{}`) - default: - t.Error("unexpected request") - w.WriteHeader(404) - } - })) - defer server.Close() - p := &publisher{base: server.URL, client: server.Client(), token: "fixture", owner: "team-owner", host: "registry.test", public: public, repositorySuffix: suffix} - command := []string{"druid", "push", "registry.test/team-owner/scroll-example:v1-commit", "./scroll"} - err := p.publish(command, func(args []string) error { - staged = args[2] - if !strings.HasPrefix(staged, "registry.test/team-owner/scroll-example:build-") { - t.Fatal("push not isolated in private staging") - } - return nil - }) - if err != nil { - t.Fatal(err) - } - if imported != 1 || (public && published != 1) || (!public && published != 0) { - t.Fatal("wrong lifecycle calls") - } - if command[2] != "registry.test/team-owner/scroll-example:v1-commit" { - t.Fatal("caller catalog mutated") - } - }) - } -} - -func TestWrongIdentityStopsBeforeRegistryPush(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/other"}`) - })) - defer server.Close() - p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} - called := false - err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { called = true; return nil }) - if err == nil || called { - t.Fatal("wrong identity was allowed to push") - } -} - -func TestFailedPushNeverImports(t *testing.T) { - calls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) - })) - defer server.Close() - p := &publisher{base: server.URL, client: server.Client(), owner: "team-owner", host: "registry.test"} - err := p.publish([]string{"druid", "push", "registry.test/team-owner/example:v1", "./scroll"}, func([]string) error { return errors.New("push failed") }) - if err == nil || calls != 1 { - t.Fatal("failed push reached lifecycle import") - } -} - -func TestConfigurationRejectsAdminCredentialsAndUnreviewedPublication(t *testing.T) { - t.Setenv("SCROLL_LIFECYCLE_URL", "https://api.druid.gg/core") - t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") - t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") - t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") - t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") - t.Setenv("SCROLL_REGISTRY_USER", "admin") - if _, err := configured(); err == nil { - t.Fatal("admin credentials accepted") - } - t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") - t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "1") - t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "0") - if _, err := configured(); err == nil { - t.Fatal("unreviewed public import accepted") - } - t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") - if _, err := configured(); err != nil { - t.Fatal(err) - } -} - -func TestDedicatedAccountRefreshesJWTUsingInMemorySession(t *testing.T) { - tokens := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/auth/v2/sign-in/email": - var input map[string]string - json.NewDecoder(r.Body).Decode(&input) - if input["email"] != "team@example.test" || input["password"] != "fixture-password" { - t.Error("wrong dedicated sign-in credentials") - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "fixture-session", Path: "/"}) - fmt.Fprint(w, `{}`) - case "/auth/v2/token": - if cookie, err := r.Cookie("session"); err != nil || cookie.Value != "fixture-session" { - t.Error("session cookie missing") - } - tokens++ - fmt.Fprintf(w, `{"token":"refreshed-%d"}`, tokens) - case "/core/v1/registry/project": - if r.Header.Get("Authorization") != fmt.Sprintf("Bearer refreshed-%d", tokens) { - t.Error("fresh owner JWT missing") - } - fmt.Fprint(w, `{"registry":"registry.test","project":"registry.test/team-owner"}`) - default: - t.Error("unexpected request") - w.WriteHeader(404) - } - })) - defer server.Close() - t.Setenv("SCROLL_LIFECYCLE_URL", server.URL+"/core") - t.Setenv("SCROLL_AUTH_URL", server.URL+"/auth/v2") - t.Setenv("SCROLL_LIFECYCLE_TOKEN", "") - t.Setenv("SCROLL_TEAM_EMAIL", "team@example.test") - t.Setenv("SCROLL_TEAM_PASSWORD", "fixture-password") - t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") - t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") - t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") - t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") - t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") - p, err := configured() - if err != nil { - t.Fatal(err) - } - if err := p.preflight(); err != nil { - t.Fatal(err) - } - if err := p.preflight(); err != nil { - t.Fatal(err) - } - if tokens != 2 { - t.Fatal("owner JWT was not refreshed before each lifecycle request") - } -} - -func TestPublisherNeverFollowsCredentialedRedirects(t *testing.T) { - forwarded := false - other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true })) - defer other.Close() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) - })) - defer server.Close() - t.Setenv("SCROLL_LIFECYCLE_URL", server.URL) - t.Setenv("SCROLL_LIFECYCLE_TOKEN", "fixture") - t.Setenv("SCROLL_LIFECYCLE_OWNER", "team-owner") - t.Setenv("SCROLL_REGISTRY_NAMESPACE", "team-owner") - t.Setenv("SCROLL_REGISTRY_HOST", "registry.test") - t.Setenv("SCROLL_REGISTRY_USER", "robot$team-owner+ci") - t.Setenv("SCROLL_LIFECYCLE_PUBLISH", "0") - p, err := configured() - if err != nil { - t.Fatal(err) - } - if err := p.preflight(); err == nil { - t.Fatal("redirect accepted") - } - if forwarded { - t.Fatal("credentials reached redirect target") - } -} diff --git a/scripts/publish-lifecycle/push_script_test.go b/scripts/publish-lifecycle/push_script_test.go deleted file mode 100644 index e4cd4638..00000000 --- a/scripts/publish-lifecycle/push_script_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" -) - -// Exercise the real catalog/pool with an offline publisher boundary. Publisher -// HTTP/auth behavior is covered separately in main_test.go; no registry is used. -func TestPushScriptUsesLifecycleForEveryArtifact(t *testing.T) { - for _, jobs := range []string{"1", "3"} { - t.Run("jobs-"+jobs, func(t *testing.T) { - state, run := lifecycleScriptFixture(t, jobs) - if output, err := run(); err != nil { - t.Fatalf("push script: %v\n%s", err, output) - } - calls := readFixture(t, filepath.Join(state, "publisher.log")) - events := readFixture(t, filepath.Join(state, "events.log")) - starts, active, peak := 0, 0, 0 - for _, line := range strings.Split(events, "\n") { - if strings.HasPrefix(line, "start artifact ") { - starts++ - active++ - if active > peak { - peak = active - } - } - if strings.HasPrefix(line, "end artifact ") { - active-- - } - } - expected, categories := catalogCounts(t) - if starts == 0 || starts != expected || len(strings.Split(strings.TrimSpace(calls), "\n")) != starts { - t.Fatalf("not every catalog artifact used the lifecycle publisher: %d starts\n%s", starts, calls) - } - for _, line := range strings.Split(strings.TrimSpace(calls), "\n") { - fields := strings.Fields(line) - if len(fields) < 2 || fields[0] != "1700000000" || !strings.HasPrefix(fields[1], "registry.invalid/fixture-owner/") || !strings.HasSuffix(fields[1], "-commit") { - t.Fatalf("unstable timestamp or wrong staging reference: %s", line) - } - if strings.Contains(line, " -i ") && !strings.Contains(line, " -i registry.invalid/druid-team/druid:") { - t.Fatalf("runtime image was redirected into private staging: %s", line) - } - } - if strings.Count(events, "start category ") != categories || strings.Count(events, "end category ") != categories || - strings.LastIndex(events, "end category ") < 0 || strings.LastIndex(events, "end category ") > strings.Index(events, "start artifact ") { - t.Fatal("lifecycle artifacts started before category completion") - } - if readFixture(t, filepath.Join(state, "active")) != "0\n" { - t.Fatal("publisher children remain active") - } - limit, _ := strconv.Atoi(jobs) - if active != 0 || peak < 1 || peak > limit || (limit > 1 && peak < 2) { - t.Fatalf("lifecycle artifact concurrency = %d, configured = %d, active = %d", peak, limit, active) - } - }) - } -} - -func TestPushScriptFailsBeforeMutationWhenLifecyclePreflightFails(t *testing.T) { - state, run := lifecycleScriptFixture(t, "3", "FAKE_PREFLIGHT_FAIL=1") - if output, err := run(); err == nil { - t.Fatalf("failed preflight accepted: %s", output) - } - for _, name := range []string{"events.log", "publisher.log", "login"} { - if _, err := os.Stat(filepath.Join(state, name)); !os.IsNotExist(err) { - t.Fatalf("failed preflight reached %s: %v", name, err) - } - } -} - -func TestPushScriptReapsLifecycleFailures(t *testing.T) { - state, run := lifecycleScriptFixture(t, "3", "FAKE_DRUID_FAIL_REF=registry.invalid/fixture-owner/scroll-minecraft-spigot:1.17-commit") - if output, err := run(); err == nil { - t.Fatalf("failed lifecycle artifact accepted: %s", output) - } - events := readFixture(t, filepath.Join(state, "events.log")) - expected, _ := catalogCounts(t) - if strings.Count(events, "start artifact ") != expected { - t.Fatal("lifecycle failure abandoned unstarted catalog artifacts") - } - if strings.Count(events, "start artifact ") != strings.Count(events, "end artifact ") || readFixture(t, filepath.Join(state, "active")) != "0\n" { - t.Fatal("lifecycle failure abandoned active publisher jobs") - } - if !strings.Contains(events, "status=23") { - t.Fatal("fixture did not exercise the publisher failure") - } -} - -func lifecycleScriptFixture(t *testing.T, jobs string, extra ...string) (string, func() ([]byte, error)) { - t.Helper() - root, err := filepath.Abs("../..") - if err != nil { - t.Fatal(err) - } - state := t.TempDir() - bin := filepath.Join(state, "bin") - if err := os.Mkdir(bin, 0700); err != nil { - t.Fatal(err) - } - stubs := map[string]string{ - "go": `#!/usr/bin/env bash -set -euo pipefail -[[ "$1" == run && "$2" == ./scripts/publish-lifecycle ]] || exit 91 -if [[ "$3" == preflight ]]; then - [[ "${FAKE_PREFLIGHT_FAIL:-0}" != 1 ]] || exit 92 - touch "$FAKE_DRUID_STATE_DIR/preflight" - exit 0 -fi -[[ -f "$FAKE_DRUID_STATE_DIR/preflight" && "$3" == -- && "$4" == "$DRUID_BIN" && "$5" == push && "$6" != category ]] || exit 93 -printf '%s %s %s\n' "$SOURCE_DATE_EPOCH" "$6" "$*" >> "$FAKE_DRUID_STATE_DIR/publisher.log" -shift 4 -exec bash "$FAKE_DRUID_SOURCE" "$@" -`, - "druid": `#!/usr/bin/env bash -set -euo pipefail -[[ -f "$FAKE_DRUID_STATE_DIR/preflight" ]] || exit 94 -if [[ "$1" == login ]]; then - touch "$FAKE_DRUID_STATE_DIR/login" - exit 0 -fi -[[ "$1" == push && "$2" == category ]] || exit 95 -exec bash "$FAKE_DRUID_SOURCE" "$@" -`, - } - for name, script := range stubs { - if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0700); err != nil { - t.Fatal(err) - } - } - return state, func() ([]byte, error) { - cmd := exec.Command("bash", filepath.Join(root, "scripts/push.sh")) - cmd.Dir = root - cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DRUID_BIN="+filepath.Join(bin, "druid"), - "FAKE_DRUID_SOURCE="+filepath.Join(root, "scripts/tests/fixtures/fake-druid.sh"), "FAKE_DRUID_STATE_DIR="+state, - "FAKE_DRUID_SLEEP=0.001", "FAKE_DRUID_FAIL_REF=", "FAKE_PREFLIGHT_FAIL=0", "SCROLL_PUBLISH_MODE=lifecycle", - "SCROLL_PUSH_DRY_RUN=0", "SCROLL_PUSH_UI=0", "SCROLL_PUSH_CATEGORIES=1", "SCROLL_PUSH_ARTIFACTS=1", - "SCROLL_PUSH_JOBS="+jobs, "SCROLL_REGISTRY_HOST=registry.invalid", "SCROLL_REGISTRY_NAMESPACE=fixture-owner", - "SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team", "SCROLL_TAG_SUFFIX=-commit", "SOURCE_DATE_EPOCH=1700000000", - "SCROLL_REGISTRY_USER=fixture", "SCROLL_REGISTRY_PASSWORD=fixture", "DRUID_SCROLL_RUNTIME_IMAGE=", "DRUID_SCROLL_STEAMCMD_IMAGE=") - cmd.Env = append(cmd.Env, extra...) - return cmd.CombinedOutput() - } -} - -func readFixture(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return string(data) -} - -func catalogCounts(t *testing.T) (artifacts, categories int) { - t.Helper() - for _, line := range strings.Split(readFixture(t, "../push.sh"), "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "run druid push ") { - if strings.Contains(line, "push category ") { - categories++ - } else { - artifacts++ - } - } - } - if artifacts == 0 || categories == 0 { - t.Fatal("fixture could not read the explicit catalog") - } - return -} diff --git a/scripts/publish-owned/main.go b/scripts/publish-owned/main.go new file mode 100644 index 00000000..b6231e8a --- /dev/null +++ b/scripts/publish-owned/main.go @@ -0,0 +1,281 @@ +// Publishes direct owner pushes through Core's generic repository operation. +// It never copies, imports, or mutates a selected Scroll artifact. +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "sort" + "strings" + "time" +) + +type publisher struct { + base, auth, owner, host, email, password, manifest string + client *http.Client +} + +type publicationArtifact struct { + Digest string `json:"digest"` + Tags []string `json:"tags"` +} + +var namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`) +var tagPattern = regexp.MustCompile(`^[\w][\w.-]{0,127}$`) +var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +func configured() (*publisher, error) { + base := strings.TrimRight(os.Getenv("SCROLL_LIFECYCLE_URL"), "/") + auth := strings.TrimRight(os.Getenv("SCROLL_AUTH_URL"), "/") + coreURL, err := trustedURL(base) + if err != nil { + return nil, fmt.Errorf("SCROLL_LIFECYCLE_URL: %w", err) + } + authURL, err := trustedURL(auth) + if err != nil || authURL.Scheme != coreURL.Scheme || authURL.Host != coreURL.Host { + return nil, errors.New("SCROLL_AUTH_URL must share Core's trusted origin") + } + host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(os.Getenv("SCROLL_REGISTRY_HOST"), "https://"), "http://"), "/") + p := &publisher{base: base, auth: auth, owner: os.Getenv("SCROLL_OWNER_ID"), host: host, + email: os.Getenv("SCROLL_PUBLISH_EMAIL"), password: os.Getenv("SCROLL_PUBLISH_PASSWORD"), manifest: os.Getenv("SCROLL_PUSH_MANIFEST")} + if p.owner == "" || p.host == "" || strings.ContainsAny(p.host, "/\\") || p.email == "" || p.password == "" { + return nil, errors.New("owner, registry host and ordinary publisher sign-in credentials are required") + } + jar, _ := cookiejar.New(nil) + p.client = &http.Client{Timeout: 70 * time.Minute, Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + return p, nil +} + +func trustedURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || + (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "druid-gs"))) { + return nil, errors.New("expected HTTPS URL (HTTP only for local development)") + } + return u, nil +} + +func (p *publisher) authRequest(method, path string, body, out any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, p.auth+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid authentication request") + } + req.Header.Set("Content-Type", "application/json") + parsed, _ := url.Parse(p.auth) + req.Header.Set("Origin", parsed.Scheme+"://"+parsed.Host) + resp, err := p.client.Do(req) + if err != nil { + return errors.New("publisher authentication failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("publisher authentication rejected: HTTP %d", resp.StatusCode) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) + } + return nil +} + +func (p *publisher) request(method, path string, body, out any) error { + var jwt struct { + Token string `json:"token"` + } + if err := p.authRequest(http.MethodGet, "/token", nil, &jwt); err != nil { + return err + } + if jwt.Token == "" { + return errors.New("authentication returned no owner token") + } + data, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(method, p.base+path, bytes.NewReader(data)) + if err != nil { + return errors.New("invalid lifecycle request") + } + req.Header.Set("Authorization", "Bearer "+jwt.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return errors.New("lifecycle request failed; inspect published state before retrying") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("lifecycle request rejected: HTTP %d", resp.StatusCode) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) + } + return nil +} + +func (p *publisher) preflight() error { + var project struct { + Registry string `json:"registry"` + Project string `json:"project"` + } + if err := p.request(http.MethodGet, "/v1/registry/project", nil, &project); err != nil { + return err + } + if project.Registry != p.host || project.Project != p.host+"/"+p.owner { + return errors.New("authenticated owner or registry differs from publisher configuration") + } + return nil +} + +func (p *publisher) parseRef(ref string) (string, string, error) { + prefix := p.host + "/" + if !strings.HasPrefix(ref, prefix) { + return "", "", errors.New("push manifest contains another registry") + } + project, remainder, ok := strings.Cut(strings.TrimPrefix(ref, prefix), "/") + if !ok { + return "", "", errors.New("push manifest contains no repository") + } + name, tag, ok := strings.Cut(remainder, ":") + if !ok || !namePattern.MatchString(name) || !tagPattern.MatchString(tag) { + return "", "", errors.New("push manifest contains an invalid tagged Scroll") + } + if project != p.owner { + return "", "", errors.New("push manifest contains a repository outside the owner project") + } + return name, ref, nil +} + +func (p *publisher) publishManifest() error { + if os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { + return errors.New("public publication requires SCROLL_LIFECYCLE_REVIEWED=1") + } + if p.manifest == "" { + return errors.New("SCROLL_PUSH_MANIFEST is required") + } + file, err := os.Open(p.manifest) + if err != nil { + return err + } + defer file.Close() + selected := map[string]map[string]bool{} + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 4096), 1<<20) + for scanner.Scan() { + name, ref, err := p.parseRef(scanner.Text()) + if err != nil { + return err + } + if selected[name] == nil { + selected[name] = map[string]bool{} + } + selected[name][ref] = true + } + if err := scanner.Err(); err != nil { + return err + } + if len(selected) == 0 { + return errors.New("no pushed Scroll artifacts to publish") + } + names := make([]string, 0, len(selected)) + for name := range selected { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + var snapshot []publicationArtifact + if err := p.request(http.MethodGet, "/v1/registry/repositories/"+url.PathEscape(name)+"/publication-preview", nil, &snapshot); err != nil { + return fmt.Errorf("preview %s: %w", name, err) + } + if len(snapshot) == 0 { + return fmt.Errorf("empty publication preview for %s", name) + } + previewTags := map[string]string{} + for _, artifact := range snapshot { + if !digestPattern.MatchString(artifact.Digest) || artifact.Tags == nil { + return fmt.Errorf("invalid publication preview for %s", name) + } + for _, tag := range artifact.Tags { + if !tagPattern.MatchString(tag) || previewTags[tag] != "" { + return fmt.Errorf("invalid publication tag for %s", name) + } + previewTags[tag] = artifact.Digest + } + } + refs := make([]string, 0, len(selected[name])) + for ref := range selected[name] { + refs = append(refs, ref) + } + sort.Strings(refs) + for _, ref := range refs { + var resolved struct { + Repository string `json:"repository"` + Digest string `json:"digest"` + Artifact string `json:"artifact"` + } + if err := p.request(http.MethodGet, "/v1/registry/scrolls/revision?artifact="+url.QueryEscape(ref), nil, &resolved); err != nil { + return fmt.Errorf("resolve %s: %w", ref, err) + } + repository := ref[:strings.LastIndex(ref, ":")] + if resolved.Repository != repository || !digestPattern.MatchString(resolved.Digest) || resolved.Artifact != repository+"@"+resolved.Digest { + return fmt.Errorf("resolved revision differs from pushed repository %s", repository) + } + if previewTags[ref[strings.LastIndex(ref, ":")+1:]] != resolved.Digest { + return fmt.Errorf("pushed revision changed since publication preview: %s", ref) + } + } + input := map[string]any{"repository": name, "expectedSnapshot": snapshot, "reviewedPublicContent": true} + if err := p.request(http.MethodPost, "/v1/registry/scrolls/publish", input, nil); err != nil { + return fmt.Errorf("publish %s: %w", name, err) + } + for _, ref := range refs { + fmt.Println(ref) + } + } + return nil +} + +func run() error { + if len(os.Args) != 2 || (os.Args[1] != "preflight" && os.Args[1] != "publish") { + return errors.New("usage: publish-owned preflight|publish") + } + p, err := configured() + if err != nil { + return err + } + if os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { + return errors.New("public publication requires reviewed content") + } + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": p.email, "password": p.password}, nil); err != nil { + return err + } + defer p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) + if err := p.preflight(); err != nil { + return err + } + if os.Args[1] == "publish" { + return p.publishManifest() + } + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/publish-owned/main_test.go b/scripts/publish-owned/main_test.go new file mode 100644 index 00000000..875dcb9c --- /dev/null +++ b/scripts/publish-owned/main_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func testRef(owner, host, name, tag string) string { + return fmt.Sprintf("%s/%s/%s:%s", host, owner, name, tag) +} + +func TestParseRefRequiresOwnerPrivateProject(t *testing.T) { + p := &publisher{owner: "owner-1", host: "registry.example:5000"} + ref := testRef(p.owner, p.host, "example", "v1") + name, got, err := p.parseRef(ref) + if err != nil || name != "example" || got != ref { + t.Fatalf("owned direct push rejected: %q %q %v", name, got, err) + } + for _, invalid := range []string{ + testRef("another-owner", p.host, "example", "v1"), + "other.example/project/example:v1", + strings.Replace(ref, "/example:v1", "/nested/example:v1", 1), + strings.TrimSuffix(ref, ":v1") + ":invalid:tag", + } { + if _, _, err := p.parseRef(invalid); err == nil { + t.Errorf("accepted unsafe push manifest reference: %s", invalid) + } + } +} + +func TestPublishManifestUsesReviewedSnapshotAndOneOperationPerRepository(t *testing.T) { + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") + owner, host, name := "owner-1", "registry.example:5000", "example" + first := testRef(owner, host, name, "v1") + second := testRef(owner, host, name, "v2") + repository := first[:strings.LastIndex(first, ":")] + digest := "sha256:" + strings.Repeat("a", 64) + snapshot := []map[string]any{{"digest": digest, "tags": []string{"v1", "v2"}}} + manifest := filepath.Join(t.TempDir(), "pushed.txt") + if err := os.WriteFile(manifest, []byte(first+"\n"+second+"\n"), 0600); err != nil { + t.Fatal(err) + } + resolved, previewed, published := 0, 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sign-in/email": + http.SetCookie(w, &http.Cookie{Name: "session", Value: "fixture", Path: "/"}) + case "/token": + if _, err := r.Cookie("session"); err != nil { + t.Error("missing owner session") + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": "owner-jwt"}) + case "/v1/registry/project": + if r.Header.Get("Authorization") != "Bearer owner-jwt" { + t.Error("missing owner token") + } + _ = json.NewEncoder(w).Encode(map[string]string{"registry": host, "project": host + "/" + owner}) + case "/v1/registry/scrolls/revision": + resolved++ + if r.URL.Query().Get("artifact") != first && r.URL.Query().Get("artifact") != second { + t.Error("resolved wrong pushed ref") + } + _ = json.NewEncoder(w).Encode(map[string]string{"repository": repository, "digest": digest, "artifact": repository + "@" + digest}) + case "/v1/registry/repositories/example/publication-preview": + previewed++ + _ = json.NewEncoder(w).Encode(snapshot) + case "/v1/registry/scrolls/publish": + published++ + var input struct { + Repository string `json:"repository"` + Snapshot []map[string]any `json:"expectedSnapshot"` + Reviewed bool `json:"reviewedPublicContent"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Error(err) + } + got, _ := json.Marshal(input.Snapshot) + want, _ := json.Marshal(snapshot) + if input.Repository != name || string(got) != string(want) || !input.Reviewed { + t.Errorf("unexpected publication input: %+v", input) + } + default: + t.Errorf("unexpected request: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + jar, _ := cookiejar.New(nil) + p := &publisher{base: server.URL, auth: server.URL, owner: owner, host: host, manifest: manifest, client: &http.Client{Jar: jar}} + if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": "owner@example.com", "password": "fixture"}, nil); err != nil { + t.Fatal(err) + } + if err := p.preflight(); err != nil { + t.Fatal(err) + } + if err := p.publishManifest(); err != nil { + t.Fatal(err) + } + if resolved != 2 || previewed != 1 || published != 1 { + t.Fatalf("expected two resolves, one preview and one publish, got %d, %d and %d", resolved, previewed, published) + } +} + +func TestPublishManifestRequiresReview(t *testing.T) { + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "") + if err := (&publisher{}).publishManifest(); err == nil { + t.Fatal("publication without content review accepted") + } +} + +func TestPublishManifestRejectsUnsafePreview(t *testing.T) { + for _, scenario := range []string{"empty", "invalid digest", "missing tag", "moved tag", "conflict"} { + t.Run(scenario, func(t *testing.T) { + t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") + digest := "sha256:" + strings.Repeat("a", 64) + ref := testRef("owner", "registry.example", "example", "v1") + manifest := filepath.Join(t.TempDir(), "pushed.txt") + if err := os.WriteFile(manifest, []byte(ref+"\n"), 0600); err != nil { + t.Fatal(err) + } + published := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + _ = json.NewEncoder(w).Encode(map[string]string{"token": "fixture"}) + case "/v1/registry/repositories/example/publication-preview": + snapshot := []publicationArtifact{{Digest: digest, Tags: []string{"v1"}}} + switch scenario { + case "empty": + snapshot = nil + case "invalid digest": + snapshot[0].Digest = "latest" + case "missing tag": + snapshot[0].Tags = []string{"v2"} + case "moved tag": + snapshot[0].Digest = "sha256:" + strings.Repeat("b", 64) + } + _ = json.NewEncoder(w).Encode(snapshot) + case "/v1/registry/scrolls/revision": + _ = json.NewEncoder(w).Encode(map[string]string{"repository": "registry.example/owner/example", "digest": digest, "artifact": "registry.example/owner/example@" + digest}) + case "/v1/registry/scrolls/publish": + published++ + http.Error(w, "changed since review", http.StatusConflict) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + p := &publisher{base: server.URL, auth: server.URL, owner: "owner", host: "registry.example", manifest: manifest, client: server.Client()} + if err := p.publishManifest(); err == nil { + t.Fatal("unsafe publication accepted") + } + want := 0 + if scenario == "conflict" { + want = 1 + } + if published != want { + t.Fatalf("publication requests: got %d, want %d; conflicts must not auto-retry", published, want) + } + }) + } +} diff --git a/scripts/push.sh b/scripts/push.sh index d3690ea9..a406be99 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -11,6 +11,7 @@ DRUID_BIN="${DRUID_BIN:-druid}" SCROLL_PUSH_DRY_RUN="${SCROLL_PUSH_DRY_RUN:-0}" SCROLL_REGISTRY_HOST="${SCROLL_REGISTRY_HOST:-artifacts.druid.gg}" SCROLL_REGISTRY_NAMESPACE="${SCROLL_REGISTRY_NAMESPACE:-druid-team}" +SCROLL_OWNER_ID="${SCROLL_OWNER_ID:-}" SCROLL_REGISTRY_USER="${SCROLL_REGISTRY_USER:-}" SCROLL_REGISTRY_PASSWORD="${SCROLL_REGISTRY_PASSWORD:-}" DRUID_CLI_VERSION="${DRUID_CLI_VERSION:-v0.1.257}" @@ -18,12 +19,6 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}" SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}" SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}" SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}" -SCROLL_PUBLISH_MODE="${SCROLL_PUBLISH_MODE:-direct}" -if [[ "$SCROLL_PUBLISH_MODE" != "direct" && "$SCROLL_PUBLISH_MODE" != "lifecycle" ]]; then - echo "SCROLL_PUBLISH_MODE must be direct or lifecycle" >&2 - exit 2 -fi - if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2 exit 2 @@ -37,6 +32,11 @@ registry_host="${SCROLL_REGISTRY_HOST#http://}" registry_host="${registry_host#https://}" registry_host="${registry_host%%/*}" registry_prefix="${registry_host}/${SCROLL_REGISTRY_NAMESPACE}" +if [[ -n "$SCROLL_OWNER_ID" ]]; then + [[ "$SCROLL_OWNER_ID" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]] || { echo "Invalid owner project" >&2; exit 2; } + registry_prefix="${registry_host}/${SCROLL_OWNER_ID}" + export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}" +fi runtime_namespace="${SCROLL_REGISTRY_RUNTIME_NAMESPACE:-${SCROLL_REGISTRY_NAMESPACE}}" runtime_prefix="${registry_host}/${runtime_namespace}" runtime_image="${DRUID_SCROLL_RUNTIME_IMAGE:-${runtime_prefix}/druid:${DRUID_CLI_VERSION}}" @@ -83,6 +83,26 @@ login_if_configured() { fi } +rewrite_registry_reference() { + local value="$1" + local source_prefix="artifacts.druid.gg/druid-team/" + if [[ "$value" == "$runtime_image" || "$value" == "$steamcmd_image" ]]; then + printf '%s' "$value" + return + fi + if [[ -n "$SCROLL_OWNER_ID" && "$value" == "$source_prefix"* ]]; then + local path="${value#"$source_prefix"}" + local name="${path%%[:@]*}" + if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]]; then + echo "Invalid owner Scroll repository: $name" >&2 + return 2 + fi + printf '%s' "${registry_prefix}/${path}" + else + printf '%s' "${value//artifacts.druid.gg\/druid-team/$registry_prefix}" + fi +} + run() { local -a command=("$@") local index @@ -101,12 +121,22 @@ run() { command[2]="$ref" fi + # Direct owner repositories carry their own presentation metadata. The + # generic publication preflight cannot depend on separate category artifacts. + if [[ -n "$SCROLL_OWNER_ID" && "${command[1]:-}" == "push" && "${command[2]:-}" != "category" ]]; then + command+=(--pack-meta) + fi + # The catalog intentionally uses production refs. Retarget them here for # local Harbor, staging, or PR namespaces without changing every push line. for index in "${!command[@]}"; do command[$index]="${command[$index]//artifacts.druid.gg\/druid-team\/druid:v0.1.257-steamcmd/$steamcmd_image}" command[$index]="${command[$index]//artifacts.druid.gg\/druid-team\/druid:v0.1.257/$runtime_image}" - command[$index]="${command[$index]//artifacts.druid.gg\/druid-team/$registry_prefix}" + if [[ "${command[2]:-}" == "category" ]]; then + command[$index]="${command[$index]//artifacts.druid.gg\/druid-team/$registry_prefix}" + else + command[$index]="$(rewrite_registry_reference "${command[$index]}")" + fi done if [[ "${command[0]:-}" == "druid" ]]; then command[0]="$DRUID_BIN" @@ -139,10 +169,9 @@ run() { } execute_push() { - if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "${2:-}" == "push" && "${3:-}" != "category" ]]; then - go run ./scripts/publish-lifecycle -- "$@" - else - "$@" + "$@" || return + if [[ -n "${SCROLL_PUSH_MANIFEST:-}" && "$2" == "push" && "$3" != "category" ]]; then + printf '%s\n' "$3" >> "$SCROLL_PUSH_MANIFEST" fi } @@ -297,11 +326,6 @@ push_release_artifacts() { run druid push artifacts.druid.gg/druid-team/scroll-hytale:latest ./scrolls/hytale/hytale-druid-gg -p main=5520/udp -i artifacts.druid.gg/druid-team/druid:v0.1.257 --min-disk 10Gi --min-ram 4Gi --min-cpu 1 -m --smart --category hytale } -if [[ "$SCROLL_PUBLISH_MODE" == "lifecycle" && "$SCROLL_PUSH_DRY_RUN" != "1" ]]; then - # Freeze build metadata across retries of one reviewed commit. - export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}" - go run ./scripts/publish-lifecycle preflight -fi login_if_configured if [[ "$SCROLL_PUSH_CATEGORIES" = "1" ]]; then diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index cf856e91..57a6543b 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -38,8 +38,17 @@ artifact_dry_run="$( SCROLL_PUSH_DRY_RUN=1 \ SCROLL_PUSH_CATEGORIES=0 \ SCROLL_PUSH_ARTIFACTS=1 \ + SCROLL_OWNER_ID=test-owner \ bash "$push_script" )" +[[ "$artifact_dry_run" == *"artifacts.druid.gg/test-owner/scroll-minecraft-spigot:1.17"* ]] || \ + fail "direct pushes must target the owner's private project" +[[ "$artifact_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257"* ]] || \ + fail "owner mapping must leave runtime image references unchanged" +[[ "$artifact_dry_run" == *"--pack-meta"* ]] || \ + fail "direct owner pushes must carry presentation metadata" +[[ "$category_dry_run" == *"artifacts.druid.gg/druid-team/scroll-minecraft-spigot"* ]] || \ + fail "category metadata must retain the configured registry namespace" expected_categories="$(printf '%s\n' "$category_dry_run" | grep -Ec '^"?druid"? push category ')" expected_artifacts="$(printf '%s\n' "$artifact_dry_run" | grep -Ec '^"?druid"? push ')" @@ -78,6 +87,7 @@ DRUID_BIN="$fake_druid" \ FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ FAKE_DRUID_SLEEP=0.02 \ SCROLL_PUSH_JOBS=3 \ +SCROLL_PUSH_MANIFEST="$tmp_dir/pushed.txt" \ bash "$push_script" >"$tmp_dir/parallel.out" maximum="$(<"$tmp_dir/state/max")" @@ -90,6 +100,11 @@ assert_equals "$expected_categories" "$actual_categories" \ "parallel mode must invoke every category push exactly once" assert_equals "$expected_artifacts" "$actual_artifacts" \ "parallel mode must invoke every artifact push exactly once" +assert_equals "$expected_artifacts" "$(wc -l < "$tmp_dir/pushed.txt" | tr -d ' ')" \ + "the publication manifest must record each successful artifact push" +if grep -q 'category' "$tmp_dir/pushed.txt"; then + fail "category metadata must not enter the publication manifest" +fi last_category_end="$(grep -n '^end category ' "$tmp_dir/state/events.log" | tail -n 1 | cut -d: -f1)" first_artifact_start="$(grep -m 1 -n '^start artifact ' "$tmp_dir/state/events.log" | cut -d: -f1)" diff --git a/scripts/validate-release-workflow/lifecycle_workflow.go b/scripts/validate-release-workflow/lifecycle_workflow.go index b5bb5ae3..9dd57fb5 100644 --- a/scripts/validate-release-workflow/lifecycle_workflow.go +++ b/scripts/validate-release-workflow/lifecycle_workflow.go @@ -37,8 +37,7 @@ type lifecycleStep struct { With map[string]any `yaml:"with"` } -// Check the actual CI callers, not only the opt-in helper. Missing credentials -// must never silently select the legacy direct-publication path. +// Check the actual CI callers and keep publication in protected environments. func validateLifecycleWorkflows(directory string) error { read := func(name string) (lifecycleWorkflow, error) { var workflow lifecycleWorkflow @@ -75,7 +74,7 @@ func validateLifecycleWorkflows(directory string) error { } tests, gate, cli, publishCount := false, false, false, 0 for _, step := range verify.Steps { - if strings.Contains(step.Run, "go test ./scripts/publish-lifecycle ./scripts/stage-scroll-ui ./scripts/validate-release-workflow") { + if strings.Contains(step.Run, "go test ./scripts/stage-scroll-ui ./scripts/validate-release-workflow ./scripts/publish-owned") { tests = true } for _, value := range step.Env { @@ -95,30 +94,41 @@ func validateLifecycleWorkflows(directory string) error { ref, _ := step.With["ref"].(string) cli = regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(ref) && step.With["persist-credentials"] == false } - if step.Run != "bash ./scripts/push.sh" { + if !strings.Contains(step.Run, "bash ./scripts/push.sh") { continue } publishCount++ + if !strings.Contains(step.Run, "for name in SCROLL_REGISTRY_HOST SCROLL_OWNER_ID SCROLL_REGISTRY_USER SCROLL_REGISTRY_PASSWORD; do") || + !strings.Contains(step.Run, "if [[ -z \"${!name}\" ]]; then") || !strings.Contains(step.Run, "exit 1") || + !strings.Contains(step.Run, "go run ./scripts/publish-owned preflight") || !strings.Contains(step.Run, "go run ./scripts/publish-owned publish") || + !strings.Contains(step.Run, ": > \"$SCROLL_PUSH_MANIFEST\"") { + return fmt.Errorf("lifecycle publication must reject missing publisher settings") + } for name, want := range map[string]string{ - "SCROLL_PUBLISH_MODE": "lifecycle", - "SCROLL_REGISTRY_NAMESPACE": "${{ vars.SCROLL_LIFECYCLE_OWNER }}", - "SCROLL_REGISTRY_RUNTIME_NAMESPACE": "druid-team", - "SCROLL_REGISTRY_USER": "${{ secrets.SCROLL_TEAM_REGISTRY_USER }}", - "SCROLL_REGISTRY_PASSWORD": "${{ secrets.SCROLL_TEAM_REGISTRY_PASSWORD }}", - "SCROLL_TEAM_EMAIL": "${{ secrets.SCROLL_TEAM_EMAIL }}", - "SCROLL_TEAM_PASSWORD": "${{ secrets.SCROLL_TEAM_PASSWORD }}", - "SCROLL_TAG_SUFFIX": "-${{ github.event.pull_request.head.sha || github.sha }}", - "SCROLL_LIFECYCLE_REPOSITORY_SUFFIX": "${{ github.event_name == 'pull_request' && format('-pr{0}', github.event.pull_request.number) || '' }}", - "SCROLL_LIFECYCLE_PUBLISH": "${{ inputs.public && '1' || '0' }}", - "SCROLL_LIFECYCLE_REVIEWED": "${{ inputs.public && '1' || '0' }}", + "SCROLL_LIFECYCLE_URL": "${{ vars.SCROLL_LIFECYCLE_URL }}", + "SCROLL_AUTH_URL": "${{ vars.SCROLL_AUTH_URL }}", + "SCROLL_PUBLISH_EMAIL": "${{ secrets.SCROLL_PUBLISH_EMAIL }}", + "SCROLL_PUBLISH_PASSWORD": "${{ secrets.SCROLL_PUBLISH_PASSWORD }}", + "SCROLL_LIFECYCLE_REVIEWED": "${{ vars.SCROLL_LIFECYCLE_REVIEWED }}", + "SCROLL_PUBLIC": "${{ inputs.public && '1' || '0' }}", + "SCROLL_PUSH_MANIFEST": "${{ runner.temp }}/scroll-artifacts.txt", + "SCROLL_OWNER_ID": "${{ vars.SCROLL_OWNER_ID }}", + "SCROLL_REGISTRY_RUNTIME_NAMESPACE": "${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || 'druid-team' }}", + "SCROLL_PUSH_CATEGORIES": "0", + "SCROLL_REGISTRY_USER": "${{ secrets.SCROLL_REGISTRY_USER }}", + "SCROLL_REGISTRY_PASSWORD": "${{ secrets.SCROLL_REGISTRY_PASSWORD }}", + "SCROLL_TAG_SUFFIX": "-${{ github.event.pull_request.head.sha || github.sha }}", } { if step.Env[name] != want { return fmt.Errorf("lifecycle publication has incorrect %s", name) } } + if _, legacyMode := step.Env["SCROLL_PUBLISH_MODE"]; legacyMode { + return fmt.Errorf("publisher workflow must use direct push without a lifecycle mode") + } } if !tests || !gate || !cli || publishCount != 1 { - return fmt.Errorf("shared workflow needs publisher tests, provisioning gate, pinned CLI and one lifecycle catalog call") + return fmt.Errorf("shared workflow needs publication tests, provisioning gate, pinned CLI and one catalog push") } return nil } diff --git a/scripts/validate-release-workflow/lifecycle_workflow_test.go b/scripts/validate-release-workflow/lifecycle_workflow_test.go index 1505a330..82fdac8b 100644 --- a/scripts/validate-release-workflow/lifecycle_workflow_test.go +++ b/scripts/validate-release-workflow/lifecycle_workflow_test.go @@ -57,17 +57,21 @@ func TestLifecycleProvisioningGate(t *testing.T) { func TestLifecycleWorkflowRejectsMissingCutoverGuards(t *testing.T) { for name, change := range map[string][3]string{ + "category artifacts in owner repositories": {"scroll-lifecycle.yml", "SCROLL_PUSH_CATEGORIES: '0'", "SCROLL_PUSH_CATEGORIES: '1'"}, "legacy caller": {"release.yml", "./.github/workflows/scroll-lifecycle.yml", "./.github/workflows/legacy.yml"}, "public preview": {"pr.yml", "public: false", "public: true"}, - "direct fallback": {"scroll-lifecycle.yml", "SCROLL_PUBLISH_MODE: lifecycle", "SCROLL_PUBLISH_MODE: direct"}, + "staging fallback": {"scroll-lifecycle.yml", "SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }}", "SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }}\n SCROLL_PUBLISH_MODE: lifecycle"}, "mutable revision": {"scroll-lifecycle.yml", "SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }}", "SCROLL_TAG_SUFFIX: -latest"}, - "public preview repository": {"scroll-lifecycle.yml", "format('-pr{0}', github.event.pull_request.number) || ''", "''"}, - "admin credential fallback": {"scroll-lifecycle.yml", "secrets.SCROLL_TEAM_REGISTRY_USER", "secrets.SCROLL_REGISTRY_USER"}, + "hard-coded owner": {"scroll-lifecycle.yml", "vars.SCROLL_OWNER_ID", "'druid-team'"}, + "admin credential fallback": {"scroll-lifecycle.yml", "secrets.SCROLL_REGISTRY_USER", "secrets.HARBOR_ADMIN_USER"}, "fork publication": {"scroll-lifecycle.yml", "github.event.pull_request.head.repo.full_name == github.repository", "true"}, "no review environment": {"scroll-lifecycle.yml", "environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }}", "environment: production"}, "mutable CLI": {"scroll-lifecycle.yml", "a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd", "master"}, "cancel mutation": {"scroll-lifecycle.yml", "cancel-in-progress: false", "cancel-in-progress: true"}, - "skip publisher tests": {"scroll-lifecycle.yml", "go test ./scripts/publish-lifecycle", "go test ./scripts/prebuild"}, + "skip publication tests": {"scroll-lifecycle.yml", "go test ./scripts/stage-scroll-ui", "go test ./scripts/prebuild"}, + "missing credential guard": {"scroll-lifecycle.yml", "if [[ -z \"${!name}\" ]]; then", "if [[ -n \"${!name}\" ]]; then"}, + "skip generic publish": {"scroll-lifecycle.yml", "go run ./scripts/publish-owned publish", "echo skipped-publication"}, + "admin owner sign-in": {"scroll-lifecycle.yml", "secrets.SCROLL_PUBLISH_EMAIL", "secrets.HARBOR_ADMIN_EMAIL"}, "late provisioning gate": {"scroll-lifecycle.yml", " - name: Require provisioned lifecycle environment", " - run: echo premature-step\n - name: Require provisioned lifecycle environment"}, } { t.Run(name, func(t *testing.T) { From 7ae16771240b14d9bc244d51ee2561a716a8e28b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 22 Oct 2026 19:08:54 +0200 Subject: [PATCH 4/6] fix(ui): start file service before config access Requires the platform command-controls package release before merge. Keep local registry versions out of the public lockfile. --- ui/package-lock.json | 530 +----------------- ui/src/app.tsx | 16 +- .../file-service-gateway.test.ts | 58 ++ ui/src/config-editor/file-service-gateway.ts | 42 ++ 4 files changed, 119 insertions(+), 527 deletions(-) create mode 100644 ui/src/config-editor/file-service-gateway.test.ts create mode 100644 ui/src/config-editor/file-service-gateway.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index f2cb3d65..21247bc2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -587,9 +587,9 @@ } }, "node_modules/@druid-ui/build": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/build/-/build-3.0.0.tgz", - "integrity": "sha512-Tr7ctnLNpjny+btPAFbVdsDjH90GWLgsfswk8iJyBRsvW09ZV26DY8uHx1oD+yyzxuuVLqMkbhz2btMWrBdVAg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/build/-/build-3.0.1.tgz", + "integrity": "sha512-ZNwEE7hPmotzf/BRoOGdsF580GV8i1FuY1NbgTIaTxcLnEVbm+hMdAh8GeOTfBG3aR+NUS2j56xgDyfDLTL5mw==", "dev": true, "dependencies": { "@bytecodealliance/componentize-js": "^0.19.3", @@ -603,9 +603,9 @@ } }, "node_modules/@druid-ui/component": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/component/-/component-3.0.0.tgz", - "integrity": "sha512-4Hz/6M0ssAnNXHepKnMZLrjBFrXjMsZclvsBlNIacAk1ta23zOpgr/AJpWGPzDCZhqhiUM5WULRMnxNQXkZzLA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/component/-/component-3.0.1.tgz", + "integrity": "sha512-c6mWsenI+TKhFxN3WEDJLVyFx5GeO7xVIt7S+cAtVgzmT5eFr2AqBArbkOro4zlIBDetoO/5z1A+SX0LUSArOg==", "dev": true, "dependencies": { "@druid-ui/build": "*", @@ -624,9 +624,9 @@ } }, "node_modules/@druid-ui/plattform": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/plattform/-/plattform-3.0.0.tgz", - "integrity": "sha512-PJtjiuAxYJYHZAkDOooTr1PrGMLz0Ucd1W2LBY+PdCQSf3IXkLF1Lu8unRzgJLE0AQhHSe2+JlnQllWk1hcoDg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/plattform/-/plattform-3.0.1.tgz", + "integrity": "sha512-A3hs60cYgmF+/VVYTQPr8o1V5xLVwls8XqXe6pzuepw5W4iUulva9m+K0tCLkR5zozC7VLxAScIlx0QIvaTbFw==", "dev": true, "dependencies": { "@druid-ui/component": "*" @@ -5194,474 +5194,6 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", @@ -5696,50 +5228,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, "node_modules/vitest/node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 00a2c6e0..4c486d95 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -2,12 +2,13 @@ import { createConfigEditorComponent, fingerprint, withMissingFileFallback, - type FileGateway, } from "./config-editor/index.js"; import { + ensureFileService, loadFileFromDeployment, saveFileToDeployment, } from "@druid-ui/plattform"; +import { createFileServiceGateway } from "./config-editor/file-service-gateway.js"; const requireString = (value: unknown, operation: string): string => { if (typeof value !== "string") { @@ -16,15 +17,18 @@ const requireString = (value: unknown, operation: string): string => { return value; }; -const gateway = withMissingFileFallback({ - async load(path) { +const gateway = withMissingFileFallback(createFileServiceGateway({ + ensureFileService, + async loadFile(path) { return requireString(await loadFileFromDeployment(path), "loadFileFromDeployment"); }, - async save(path, content) { + async saveFile(path, content) { await saveFileToDeployment(path, content); - return { status: "saved", fingerprint: await fingerprint(content) }; }, -} satisfies FileGateway); + async fingerprint(content) { + return fingerprint(content); + }, +})); export const component = createConfigEditorComponent({ manifestPath: ".druid/config-editor.manifest.json", diff --git a/ui/src/config-editor/file-service-gateway.test.ts b/ui/src/config-editor/file-service-gateway.test.ts new file mode 100644 index 00000000..9b02a705 --- /dev/null +++ b/ui/src/config-editor/file-service-gateway.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFileServiceGateway } from "./file-service-gateway.js"; + +const createService = () => ({ + ensureFileService: vi.fn<() => Promise>().mockResolvedValue(undefined), + loadFile: vi.fn<(path: string) => Promise>().mockResolvedValue("content"), + saveFile: vi.fn<(path: string, content: string) => Promise>().mockResolvedValue(undefined), + fingerprint: vi.fn<(content: string) => Promise>().mockResolvedValue("fingerprint"), +}); + +describe("createFileServiceGateway", () => { + it("ensures the file service before loading and saving files", async () => { + const service = createService(); + const gateway = createFileServiceGateway(service); + + await expect(gateway.load("server.properties")).resolves.toBe("content"); + await expect(gateway.save("server.properties", "content", "ignored")).resolves.toEqual({ + status: "saved", + fingerprint: "fingerprint", + }); + + expect(service.ensureFileService).toHaveBeenCalledOnce(); + expect(service.ensureFileService).toHaveBeenCalledWith(false); + expect(service.loadFile).toHaveBeenCalledWith("server.properties"); + expect(service.saveFile).toHaveBeenCalledWith("server.properties", "content"); + }); + + it("shares startup between concurrent file operations", async () => { + let resolveStartup: (() => void) | undefined; + const service = createService(); + service.ensureFileService.mockImplementation( + () => + new Promise((resolve) => { + resolveStartup = resolve; + }) + ); + const gateway = createFileServiceGateway(service); + + const load = gateway.load("server.properties"); + const save = gateway.save("server.properties", "content", "ignored"); + await vi.waitFor(() => expect(service.ensureFileService).toHaveBeenCalledOnce()); + resolveStartup?.(); + + await Promise.all([load, save]); + expect(service.ensureFileService).toHaveBeenCalledWith(false); + }); + + it("retries startup after a failure", async () => { + const service = createService(); + service.ensureFileService.mockRejectedValueOnce(new Error("unavailable")); + const gateway = createFileServiceGateway(service); + + await expect(gateway.load("server.properties")).rejects.toThrow("unavailable"); + await expect(gateway.load("server.properties")).resolves.toBe("content"); + + expect(service.ensureFileService).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/config-editor/file-service-gateway.ts b/ui/src/config-editor/file-service-gateway.ts new file mode 100644 index 00000000..04dc2b89 --- /dev/null +++ b/ui/src/config-editor/file-service-gateway.ts @@ -0,0 +1,42 @@ +import type { FileGateway, SaveResult } from "./gateway.js"; + +export interface DeploymentFileService { + ensureFileService(watch?: boolean): Promise; + loadFile(path: string): Promise; + saveFile(path: string, content: string): Promise; + fingerprint(content: string): Promise; +} + +/** + * Ensures WebDAV once before using deployment files. A failed startup is not + * cached, allowing the next user action to retry it. + */ +export const createFileServiceGateway = (service: DeploymentFileService): FileGateway => { + let ready: Promise | undefined; + + const ensureReady = (): Promise => { + if (ready) return ready; + + let startup: Promise; + startup = Promise.resolve() + .then(() => service.ensureFileService(false)) + .catch((error: unknown) => { + if (ready === startup) ready = undefined; + throw error; + }); + ready = startup; + return startup; + }; + + return { + async load(path) { + await ensureReady(); + return service.loadFile(path); + }, + async save(path, content): Promise { + await ensureReady(); + await service.saveFile(path, content); + return { status: "saved", fingerprint: await service.fingerprint(content) }; + }, + }; +}; From 07c12ac0197ab43e493c53dddd607a6a46e3b6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 22 Oct 2026 19:12:37 +0200 Subject: [PATCH 5/6] fix(ci): retain npm peer dependency lock entries Regenerate with the npm 10 resolver used by CI. The newer local npm resolver omitted optional esbuild peer entries required by npm ci. --- ui/package-lock.json | 512 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) diff --git a/ui/package-lock.json b/ui/package-lock.json index 21247bc2..bad0eddc 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5194,6 +5194,474 @@ } } }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", @@ -5228,6 +5696,50 @@ "dev": true, "license": "MIT" }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/vitest/node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", From 00c8092a9ce73076805d75a6e4d4a551329b5d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Tue, 27 Oct 2026 02:54:58 +0100 Subject: [PATCH 6/6] ci(scrolls): push registry updates directly --- .github/workflows/pr.yml | 5 +- .github/workflows/release.yml | 5 +- .github/workflows/scroll-lifecycle.yml | 148 --------- .github/workflows/scrolls.yml | 78 +++++ docs/registry-publication.md | 54 ++++ docs/shared-lifecycle-publication.md | 74 ----- scripts/publish-owned/main.go | 281 ------------------ scripts/publish-owned/main_test.go | 170 ----------- scripts/push.sh | 36 +-- scripts/tests/push-parallel.test.sh | 59 +++- .../lifecycle_workflow.go | 134 --------- .../lifecycle_workflow_test.go | 99 ------ scripts/validate-release-workflow/main.go | 159 ---------- .../validate-release-workflow/main_test.go | 59 ---- scripts/validate_all_scrolls.sh | 2 - 15 files changed, 187 insertions(+), 1176 deletions(-) delete mode 100644 .github/workflows/scroll-lifecycle.yml create mode 100644 .github/workflows/scrolls.yml create mode 100644 docs/registry-publication.md delete mode 100644 docs/shared-lifecycle-publication.md delete mode 100644 scripts/publish-owned/main.go delete mode 100644 scripts/publish-owned/main_test.go delete mode 100644 scripts/validate-release-workflow/lifecycle_workflow.go delete mode 100644 scripts/validate-release-workflow/lifecycle_workflow_test.go delete mode 100644 scripts/validate-release-workflow/main.go delete mode 100644 scripts/validate-release-workflow/main_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 94287ebd..aed032d1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,6 +6,7 @@ permissions: contents: read jobs: build-deploy: - uses: ./.github/workflows/scroll-lifecycle.yml + uses: ./.github/workflows/scrolls.yml + secrets: inherit with: - public: false + release: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 237951e9..56032a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ permissions: contents: read jobs: build-deploy: - uses: ./.github/workflows/scroll-lifecycle.yml + uses: ./.github/workflows/scrolls.yml + secrets: inherit with: - public: true + release: true diff --git a/.github/workflows/scroll-lifecycle.yml b/.github/workflows/scroll-lifecycle.yml deleted file mode 100644 index 115794b8..00000000 --- a/.github/workflows/scroll-lifecycle.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Shared Scroll lifecycle -on: - workflow_call: - inputs: - public: - description: Publish reviewed releases publicly; PR previews remain private. - required: true - type: boolean -permissions: - contents: read - -jobs: - verify: - runs-on: self-hosted - outputs: - changed: ${{ steps.changes.outputs.changed }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - fetch-depth: 0 - - uses: actions/setup-go@v5 - with: - go-version: "1.24.7" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - name: Build scroll tree - if: github.event_name == 'pull_request' - run: make build-tree - - name: Validate catalog and UI - run: go run ./scripts/validate-release-workflow && go run ./scripts/validate-scrolls.go && bash ./scripts/validate_ui_coverage.sh - - name: Test direct publication support - run: go test ./scripts/stage-scroll-ui ./scripts/validate-release-workflow ./scripts/publish-owned -count=1 - - name: Test bounded catalog publication - run: bash ./scripts/tests/push-parallel.test.sh - - name: Check release inputs - id: changes - env: - EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - set -euo pipefail - if [[ "$EVENT_NAME" == "pull_request" ]] && git diff --quiet "$BASE_SHA"...HEAD -- scrolls ui scripts .github/workflows go.mod go.sum Makefile; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - publish: - needs: verify - if: >- - needs.verify.outputs.changed == 'true' && - ((inputs.public && github.event_name != 'pull_request') || - (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) - # A fresh runner keeps publisher credentials out of the untrusted validation job. - runs-on: ubuntu-latest - timeout-minutes: 360 - environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }} - concurrency: - group: scroll-lifecycle-${{ inputs.public && 'release' || format('pr-{0}', github.event.pull_request.number) }} - cancel-in-progress: false - steps: - - name: Require provisioned lifecycle environment - env: - LIFECYCLE_READY: ${{ vars.SCROLL_LIFECYCLE_READY }} - run: | - if [[ "$LIFECYCLE_READY" != "true" ]]; then - echo "Configure the protected lifecycle environment before publication." >&2 - exit 1 - fi - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - name: Checkout lifecycle-capable CLI - uses: actions/checkout@v4 - with: - repository: highcard-dev/druid-cli - # Includes SOURCE_DATE_EPOCH and stable root-layer ordering. - ref: a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd - path: .druid-cli - token: ${{ secrets.GO_REPO_TOKEN }} - persist-credentials: false - - uses: actions/setup-go@v5 - with: - go-version-file: .druid-cli/go.mod - - name: Build pinned CLI - working-directory: .druid-cli - run: | - mkdir -p "$RUNNER_TEMP/scroll-cli" - go build -o "$RUNNER_TEMP/scroll-cli/druid" ./apps/druid - echo "$RUNNER_TEMP/scroll-cli" >> "$GITHUB_PATH" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build Scroll UI - working-directory: ui - run: npm ci && npm run build - - name: Build scroll tree - if: github.event_name == 'pull_request' - run: make build-tree - - name: Validate with pinned CLI - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Push to publisher-owned registry repositories - env: - SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST }} - SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }} - SCROLL_REGISTRY_RUNTIME_NAMESPACE: ${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || 'druid-team' }} - # Owner revisions embed presentation; category-only OCI artifacts are - # not Scroll revisions and must not enter a publication snapshot. - SCROLL_PUSH_CATEGORIES: '0' - DRUID_SCROLL_RUNTIME_IMAGE: ${{ vars.DRUID_SCROLL_RUNTIME_IMAGE }} - DRUID_SCROLL_STEAMCMD_IMAGE: ${{ vars.DRUID_SCROLL_STEAMCMD_IMAGE }} - SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} - SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - SCROLL_PUSH_MANIFEST: ${{ runner.temp }}/scroll-artifacts.txt - SCROLL_LIFECYCLE_URL: ${{ vars.SCROLL_LIFECYCLE_URL }} - SCROLL_AUTH_URL: ${{ vars.SCROLL_AUTH_URL }} - SCROLL_PUBLISH_EMAIL: ${{ secrets.SCROLL_PUBLISH_EMAIL }} - SCROLL_PUBLISH_PASSWORD: ${{ secrets.SCROLL_PUBLISH_PASSWORD }} - SCROLL_LIFECYCLE_REVIEWED: ${{ vars.SCROLL_LIFECYCLE_REVIEWED }} - SCROLL_PUBLIC: ${{ inputs.public && '1' || '0' }} - SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }} - run: | - set -euo pipefail - for name in SCROLL_REGISTRY_HOST SCROLL_OWNER_ID SCROLL_REGISTRY_USER SCROLL_REGISTRY_PASSWORD; do - if [[ -z "${!name}" ]]; then - echo "Missing required publisher setting: $name" >&2 - exit 1 - fi - done - if [[ "$SCROLL_PUBLIC" == "1" ]]; then - go run ./scripts/publish-owned preflight - fi - : > "$SCROLL_PUSH_MANIFEST" - bash ./scripts/push.sh - if [[ "$SCROLL_PUBLIC" == "1" ]]; then - go run ./scripts/publish-owned publish - fi diff --git a/.github/workflows/scrolls.yml b/.github/workflows/scrolls.yml new file mode 100644 index 00000000..400d9340 --- /dev/null +++ b/.github/workflows/scrolls.yml @@ -0,0 +1,78 @@ +name: Build and push Scrolls +on: + workflow_call: + inputs: + release: + description: Update release tags instead of PR preview tags. + required: true + type: boolean +permissions: + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 360 + concurrency: + group: scrolls-${{ inputs.release && 'release' || format('pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: false + env: + DRUID_CLI_VERSION: ${{ vars.DRUID_CLI_VERSION || 'v0.1.257' }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version: "1.24.7" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build and test Scroll UI + working-directory: ui + run: npm ci && npm test && npm run type-check && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Download released CLI + uses: robinraju/release-downloader@v1.7 + with: + repository: highcard-dev/druid-cli + tag: ${{ env.DRUID_CLI_VERSION }} + fileName: druid + token: ${{ github.token }} + - name: Install CLI + run: | + mkdir -p "$RUNNER_TEMP/scroll-cli" + chmod +x druid + mv druid "$RUNNER_TEMP/scroll-cli/druid" + echo "$RUNNER_TEMP/scroll-cli" >> "$GITHUB_PATH" + - name: Validate catalog and UI + run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh + - name: Test catalog publication + run: bash ./scripts/tests/push-parallel.test.sh + - name: Push registry updates + # Fork PRs validate without receiving registry credentials. + if: >- + (inputs.release && github.event_name != 'pull_request') || + (!inputs.release && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + env: + SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST || 'artifacts.druid.gg' }} + SCROLL_REGISTRY_NAMESPACE: ${{ inputs.release && (vars.SCROLL_REGISTRY_NAMESPACE || 'druid-team') || (vars.SCROLL_PREVIEW_NAMESPACE || 'druid-team-experimental') }} + SCROLL_REGISTRY_RUNTIME_NAMESPACE: ${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || vars.SCROLL_REGISTRY_NAMESPACE || 'druid-team' }} + SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} + SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} + SCROLL_PUSH_CATEGORIES: ${{ inputs.release && '1' || '0' }} + DRUID_SCROLL_RUNTIME_IMAGE: ${{ vars.DRUID_SCROLL_RUNTIME_IMAGE }} + DRUID_SCROLL_STEAMCMD_IMAGE: ${{ vars.DRUID_SCROLL_STEAMCMD_IMAGE }} + run: | + set -euo pipefail + : "${SCROLL_REGISTRY_USER:?Set the SCROLL_REGISTRY_USER secret}" + : "${SCROLL_REGISTRY_PASSWORD:?Set the SCROLL_REGISTRY_PASSWORD secret}" + if [[ "${{ inputs.release }}" != "true" ]]; then + export SCROLL_TAG_SUFFIX="-pr${{ github.event.pull_request.number }}" + fi + bash ./scripts/push.sh diff --git a/docs/registry-publication.md b/docs/registry-publication.md new file mode 100644 index 00000000..a73feddf --- /dev/null +++ b/docs/registry-publication.md @@ -0,0 +1,54 @@ +# Registry updates + +CI builds the Scroll UI, validates the catalog, logs into the registry and runs +`scripts/push.sh`. That shared script is the explicit catalog used by both CI +and local seeding. Release runs update the existing repositories and tags, +with category metadata pushed before Scroll artifacts. + +There are no Druid account credentials, Core/Auth API calls, publication +snapshots, catalog registration calls or visibility changes. These repositories +are already public and registered; CI only updates their OCI artifacts. + +## Configuration + +Only two GitHub Actions secrets are required: + +- `SCROLL_REGISTRY_USER` +- `SCROLL_REGISTRY_PASSWORD` + +Optional repository variables override the existing defaults: + +| Variable | Default | +| --- | --- | +| `SCROLL_REGISTRY_HOST` | `artifacts.druid.gg` | +| `SCROLL_REGISTRY_NAMESPACE` | `druid-team` | +| `SCROLL_PREVIEW_NAMESPACE` | `druid-team-experimental` | +| `SCROLL_REGISTRY_RUNTIME_NAMESPACE` | Release namespace | +| `DRUID_CLI_VERSION` | `v0.1.257` | + +`DRUID_CLI_VERSION` selects the released CLI and default runtime image tag. +`DRUID_SCROLL_RUNTIME_IMAGE` and `DRUID_SCROLL_STEAMCMD_IMAGE` can override the +runtime image references independently. CI downloads the CLI release binary; +it does not check out or build CLI source. + +Same-repository PRs push preview tags suffixed with `-pr` into the +preview namespace without changing release tags or category metadata. Fork +PRs only build and validate. Runtime image references stay in the release +namespace. Both reusable-workflow callers inherit the repository secrets. + +## Verification and dependencies + +Run `bash scripts/tests/push-parallel.test.sh` and +`bash scripts/validate_all_scrolls.sh` locally. Use `SCROLL_PUSH_DRY_RUN=1 bash +scripts/push.sh` to inspect the catalog without registry writes. The push script +retains its serial default, bounded parallel option and category barrier. + +The existing Go tree generator, Scroll validator and UI staging helper remain: +they generate or validate actual artifact content. The CI-only Go API publisher +and workflow parser are removed. + +The Scroll UI still needs published Druid UI packages exporting +`ensureFileService`, `addAndRunCommand` and `stopCommand`, and a matching lockfile. +Local unpublished packages do not prove that a clean CI build will pass. +Registry updates themselves do not depend on Core publication APIs or a Druid +account. diff --git a/docs/shared-lifecycle-publication.md b/docs/shared-lifecycle-publication.md deleted file mode 100644 index afe201da..00000000 --- a/docs/shared-lifecycle-publication.md +++ /dev/null @@ -1,74 +0,0 @@ -# Shared authored-release publication - -## Current contract - -Release and PR workflows use the same explicit catalog in `scripts/push.sh`. -They push directly to `//:`; there is -no staging/import API. Core publishes reviewed revisions by copying an exact -snapshot into the owner's public username project. The private source remains. -See the monorepo's `docs/scroll-lifecycle-api-cutover.md` for the Core contract. - -1. Validate and build without publication credentials. -2. Enter a fresh runner and protected `scroll-release` or `scroll-preview` - environment. Fork PRs never publish; a missing readiness gate fails closed. -3. Build the pinned lifecycle-capable CLI and the Scroll UI. -4. Push owner-scoped revisions tagged with the source commit. Runtime images - stay in a separate namespace. Revisions embed presentation; category-only - artifacts are excluded from owner publication. -5. For public releases, `publish-owned` authenticates as the owner, fetches - each repository's publication preview, verifies every pushed tag against - that preview, and submits it unchanged as `expectedSnapshot`. -6. Core rejects changed content. The helper does not automatically retry - conflicts or replace pending operation intent; inspect and use the current - manual recovery flow before retrying. - -Public publication approves the complete repository, not only the latest CI -tag. Use separate release and preview identities/projects. Preview pushes stay -private. Publication does not cancel an in-flight run when another run starts. - -## Configuration - -Provision these through the protected environments, never source control: - -| Setting | Purpose | -| --- | --- | -| `SCROLL_LIFECYCLE_READY=true` | Explicit provisioning gate | -| `SCROLL_OWNER_ID` | Authenticated identity ID naming the private project | -| `SCROLL_REGISTRY_HOST` | Scheme-less Harbor host | -| `SCROLL_REGISTRY_USER`, `SCROLL_REGISTRY_PASSWORD` | Ordinary owner-scoped registry credentials | -| `SCROLL_REGISTRY_RUNTIME_NAMESPACE` | Runtime-image namespace; workflow defaults to `druid-team` | -| `SCROLL_LIFECYCLE_URL`, `SCROLL_AUTH_URL` | Core and Auth URLs sharing the trusted origin | -| `SCROLL_PUBLISH_EMAIL`, `SCROLL_PUBLISH_PASSWORD` | Ordinary publisher sign-in for public publication | -| `SCROLL_LIFECYCLE_REVIEWED=1` | Confirms content review before public publication | -| `GO_REPO_TOKEN` | Read-only access to the pinned CLI source | - -Optional `DRUID_SCROLL_RUNTIME_IMAGE` and `DRUID_SCROLL_STEAMCMD_IMAGE` -override runtime image references. Owner builds use the commit timestamp unless -`SOURCE_DATE_EPOCH` is explicitly set. The workflow sets `SCROLL_PUSH_MANIFEST` -and commit-based `SCROLL_TAG_SUFFIX`; successful pushes populate the manifest. - -`SCROLL_REGISTRY_NAMESPACE` is not a workflow requirement: `SCROLL_OWNER_ID` -determines artifact destinations, and runtime images have their own setting. -The shared push script still supports namespaces and category publication for -local bootstrap. It retains its serial default, bounded parallel option, -category barrier, and failure handling. - -## Rollout and verification - -- Deploy matching Core/runtime contracts through their normal CI. -- Configure protected environments, required reviewers and separate identities - before enabling the readiness gate. Workflow YAML does not provision those - policies. -- Publish a Druid UI package containing `ensureFileService`, - `addAndRunCommand` and `stopCommand` before releasing a consumer that uses - these imports. Local `1000.*` versions and localhost registry URLs are not - release dependencies. -- Run `go test ./scripts/publish-owned ./scripts/stage-scroll-ui ./scripts/validate-release-workflow` - and `bash ./scripts/tests/push-parallel.test.sh`. These test the publisher - contract and catalog; they do not constitute a production publication. -- Verify the first approved preview/release with the provisioned credentials. - Do not weaken approval or readiness gates to make a run green. - -The former staging/import publisher and its tests were replaced. Historical -acceptance results remain in Git history and the monorepo lifecycle audit; -they do not certify this direct-owner publisher or a production rollout. diff --git a/scripts/publish-owned/main.go b/scripts/publish-owned/main.go deleted file mode 100644 index b6231e8a..00000000 --- a/scripts/publish-owned/main.go +++ /dev/null @@ -1,281 +0,0 @@ -// Publishes direct owner pushes through Core's generic repository operation. -// It never copies, imports, or mutates a selected Scroll artifact. -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "regexp" - "sort" - "strings" - "time" -) - -type publisher struct { - base, auth, owner, host, email, password, manifest string - client *http.Client -} - -type publicationArtifact struct { - Digest string `json:"digest"` - Tags []string `json:"tags"` -} - -var namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`) -var tagPattern = regexp.MustCompile(`^[\w][\w.-]{0,127}$`) -var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) - -func configured() (*publisher, error) { - base := strings.TrimRight(os.Getenv("SCROLL_LIFECYCLE_URL"), "/") - auth := strings.TrimRight(os.Getenv("SCROLL_AUTH_URL"), "/") - coreURL, err := trustedURL(base) - if err != nil { - return nil, fmt.Errorf("SCROLL_LIFECYCLE_URL: %w", err) - } - authURL, err := trustedURL(auth) - if err != nil || authURL.Scheme != coreURL.Scheme || authURL.Host != coreURL.Host { - return nil, errors.New("SCROLL_AUTH_URL must share Core's trusted origin") - } - host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(os.Getenv("SCROLL_REGISTRY_HOST"), "https://"), "http://"), "/") - p := &publisher{base: base, auth: auth, owner: os.Getenv("SCROLL_OWNER_ID"), host: host, - email: os.Getenv("SCROLL_PUBLISH_EMAIL"), password: os.Getenv("SCROLL_PUBLISH_PASSWORD"), manifest: os.Getenv("SCROLL_PUSH_MANIFEST")} - if p.owner == "" || p.host == "" || strings.ContainsAny(p.host, "/\\") || p.email == "" || p.password == "" { - return nil, errors.New("owner, registry host and ordinary publisher sign-in credentials are required") - } - jar, _ := cookiejar.New(nil) - p.client = &http.Client{Timeout: 70 * time.Minute, Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} - return p, nil -} - -func trustedURL(raw string) (*url.URL, error) { - u, err := url.Parse(raw) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || - (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "druid-gs"))) { - return nil, errors.New("expected HTTPS URL (HTTP only for local development)") - } - return u, nil -} - -func (p *publisher) authRequest(method, path string, body, out any) error { - data, err := json.Marshal(body) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, method, p.auth+path, bytes.NewReader(data)) - if err != nil { - return errors.New("invalid authentication request") - } - req.Header.Set("Content-Type", "application/json") - parsed, _ := url.Parse(p.auth) - req.Header.Set("Origin", parsed.Scheme+"://"+parsed.Host) - resp, err := p.client.Do(req) - if err != nil { - return errors.New("publisher authentication failed") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("publisher authentication rejected: HTTP %d", resp.StatusCode) - } - if out != nil { - return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) - } - return nil -} - -func (p *publisher) request(method, path string, body, out any) error { - var jwt struct { - Token string `json:"token"` - } - if err := p.authRequest(http.MethodGet, "/token", nil, &jwt); err != nil { - return err - } - if jwt.Token == "" { - return errors.New("authentication returned no owner token") - } - data, err := json.Marshal(body) - if err != nil { - return err - } - req, err := http.NewRequest(method, p.base+path, bytes.NewReader(data)) - if err != nil { - return errors.New("invalid lifecycle request") - } - req.Header.Set("Authorization", "Bearer "+jwt.Token) - req.Header.Set("Content-Type", "application/json") - resp, err := p.client.Do(req) - if err != nil { - return errors.New("lifecycle request failed; inspect published state before retrying") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("lifecycle request rejected: HTTP %d", resp.StatusCode) - } - if out != nil { - return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) - } - return nil -} - -func (p *publisher) preflight() error { - var project struct { - Registry string `json:"registry"` - Project string `json:"project"` - } - if err := p.request(http.MethodGet, "/v1/registry/project", nil, &project); err != nil { - return err - } - if project.Registry != p.host || project.Project != p.host+"/"+p.owner { - return errors.New("authenticated owner or registry differs from publisher configuration") - } - return nil -} - -func (p *publisher) parseRef(ref string) (string, string, error) { - prefix := p.host + "/" - if !strings.HasPrefix(ref, prefix) { - return "", "", errors.New("push manifest contains another registry") - } - project, remainder, ok := strings.Cut(strings.TrimPrefix(ref, prefix), "/") - if !ok { - return "", "", errors.New("push manifest contains no repository") - } - name, tag, ok := strings.Cut(remainder, ":") - if !ok || !namePattern.MatchString(name) || !tagPattern.MatchString(tag) { - return "", "", errors.New("push manifest contains an invalid tagged Scroll") - } - if project != p.owner { - return "", "", errors.New("push manifest contains a repository outside the owner project") - } - return name, ref, nil -} - -func (p *publisher) publishManifest() error { - if os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { - return errors.New("public publication requires SCROLL_LIFECYCLE_REVIEWED=1") - } - if p.manifest == "" { - return errors.New("SCROLL_PUSH_MANIFEST is required") - } - file, err := os.Open(p.manifest) - if err != nil { - return err - } - defer file.Close() - selected := map[string]map[string]bool{} - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 4096), 1<<20) - for scanner.Scan() { - name, ref, err := p.parseRef(scanner.Text()) - if err != nil { - return err - } - if selected[name] == nil { - selected[name] = map[string]bool{} - } - selected[name][ref] = true - } - if err := scanner.Err(); err != nil { - return err - } - if len(selected) == 0 { - return errors.New("no pushed Scroll artifacts to publish") - } - names := make([]string, 0, len(selected)) - for name := range selected { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - var snapshot []publicationArtifact - if err := p.request(http.MethodGet, "/v1/registry/repositories/"+url.PathEscape(name)+"/publication-preview", nil, &snapshot); err != nil { - return fmt.Errorf("preview %s: %w", name, err) - } - if len(snapshot) == 0 { - return fmt.Errorf("empty publication preview for %s", name) - } - previewTags := map[string]string{} - for _, artifact := range snapshot { - if !digestPattern.MatchString(artifact.Digest) || artifact.Tags == nil { - return fmt.Errorf("invalid publication preview for %s", name) - } - for _, tag := range artifact.Tags { - if !tagPattern.MatchString(tag) || previewTags[tag] != "" { - return fmt.Errorf("invalid publication tag for %s", name) - } - previewTags[tag] = artifact.Digest - } - } - refs := make([]string, 0, len(selected[name])) - for ref := range selected[name] { - refs = append(refs, ref) - } - sort.Strings(refs) - for _, ref := range refs { - var resolved struct { - Repository string `json:"repository"` - Digest string `json:"digest"` - Artifact string `json:"artifact"` - } - if err := p.request(http.MethodGet, "/v1/registry/scrolls/revision?artifact="+url.QueryEscape(ref), nil, &resolved); err != nil { - return fmt.Errorf("resolve %s: %w", ref, err) - } - repository := ref[:strings.LastIndex(ref, ":")] - if resolved.Repository != repository || !digestPattern.MatchString(resolved.Digest) || resolved.Artifact != repository+"@"+resolved.Digest { - return fmt.Errorf("resolved revision differs from pushed repository %s", repository) - } - if previewTags[ref[strings.LastIndex(ref, ":")+1:]] != resolved.Digest { - return fmt.Errorf("pushed revision changed since publication preview: %s", ref) - } - } - input := map[string]any{"repository": name, "expectedSnapshot": snapshot, "reviewedPublicContent": true} - if err := p.request(http.MethodPost, "/v1/registry/scrolls/publish", input, nil); err != nil { - return fmt.Errorf("publish %s: %w", name, err) - } - for _, ref := range refs { - fmt.Println(ref) - } - } - return nil -} - -func run() error { - if len(os.Args) != 2 || (os.Args[1] != "preflight" && os.Args[1] != "publish") { - return errors.New("usage: publish-owned preflight|publish") - } - p, err := configured() - if err != nil { - return err - } - if os.Getenv("SCROLL_LIFECYCLE_REVIEWED") != "1" { - return errors.New("public publication requires reviewed content") - } - if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": p.email, "password": p.password}, nil); err != nil { - return err - } - defer p.authRequest(http.MethodPost, "/sign-out", map[string]any{}, nil) - if err := p.preflight(); err != nil { - return err - } - if os.Args[1] == "publish" { - return p.publishManifest() - } - return nil -} - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/scripts/publish-owned/main_test.go b/scripts/publish-owned/main_test.go deleted file mode 100644 index 875dcb9c..00000000 --- a/scripts/publish-owned/main_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func testRef(owner, host, name, tag string) string { - return fmt.Sprintf("%s/%s/%s:%s", host, owner, name, tag) -} - -func TestParseRefRequiresOwnerPrivateProject(t *testing.T) { - p := &publisher{owner: "owner-1", host: "registry.example:5000"} - ref := testRef(p.owner, p.host, "example", "v1") - name, got, err := p.parseRef(ref) - if err != nil || name != "example" || got != ref { - t.Fatalf("owned direct push rejected: %q %q %v", name, got, err) - } - for _, invalid := range []string{ - testRef("another-owner", p.host, "example", "v1"), - "other.example/project/example:v1", - strings.Replace(ref, "/example:v1", "/nested/example:v1", 1), - strings.TrimSuffix(ref, ":v1") + ":invalid:tag", - } { - if _, _, err := p.parseRef(invalid); err == nil { - t.Errorf("accepted unsafe push manifest reference: %s", invalid) - } - } -} - -func TestPublishManifestUsesReviewedSnapshotAndOneOperationPerRepository(t *testing.T) { - t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") - owner, host, name := "owner-1", "registry.example:5000", "example" - first := testRef(owner, host, name, "v1") - second := testRef(owner, host, name, "v2") - repository := first[:strings.LastIndex(first, ":")] - digest := "sha256:" + strings.Repeat("a", 64) - snapshot := []map[string]any{{"digest": digest, "tags": []string{"v1", "v2"}}} - manifest := filepath.Join(t.TempDir(), "pushed.txt") - if err := os.WriteFile(manifest, []byte(first+"\n"+second+"\n"), 0600); err != nil { - t.Fatal(err) - } - resolved, previewed, published := 0, 0, 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/sign-in/email": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "fixture", Path: "/"}) - case "/token": - if _, err := r.Cookie("session"); err != nil { - t.Error("missing owner session") - } - _ = json.NewEncoder(w).Encode(map[string]string{"token": "owner-jwt"}) - case "/v1/registry/project": - if r.Header.Get("Authorization") != "Bearer owner-jwt" { - t.Error("missing owner token") - } - _ = json.NewEncoder(w).Encode(map[string]string{"registry": host, "project": host + "/" + owner}) - case "/v1/registry/scrolls/revision": - resolved++ - if r.URL.Query().Get("artifact") != first && r.URL.Query().Get("artifact") != second { - t.Error("resolved wrong pushed ref") - } - _ = json.NewEncoder(w).Encode(map[string]string{"repository": repository, "digest": digest, "artifact": repository + "@" + digest}) - case "/v1/registry/repositories/example/publication-preview": - previewed++ - _ = json.NewEncoder(w).Encode(snapshot) - case "/v1/registry/scrolls/publish": - published++ - var input struct { - Repository string `json:"repository"` - Snapshot []map[string]any `json:"expectedSnapshot"` - Reviewed bool `json:"reviewedPublicContent"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - t.Error(err) - } - got, _ := json.Marshal(input.Snapshot) - want, _ := json.Marshal(snapshot) - if input.Repository != name || string(got) != string(want) || !input.Reviewed { - t.Errorf("unexpected publication input: %+v", input) - } - default: - t.Errorf("unexpected request: %s", r.URL.Path) - http.NotFound(w, r) - } - })) - defer server.Close() - jar, _ := cookiejar.New(nil) - p := &publisher{base: server.URL, auth: server.URL, owner: owner, host: host, manifest: manifest, client: &http.Client{Jar: jar}} - if err := p.authRequest(http.MethodPost, "/sign-in/email", map[string]string{"email": "owner@example.com", "password": "fixture"}, nil); err != nil { - t.Fatal(err) - } - if err := p.preflight(); err != nil { - t.Fatal(err) - } - if err := p.publishManifest(); err != nil { - t.Fatal(err) - } - if resolved != 2 || previewed != 1 || published != 1 { - t.Fatalf("expected two resolves, one preview and one publish, got %d, %d and %d", resolved, previewed, published) - } -} - -func TestPublishManifestRequiresReview(t *testing.T) { - t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "") - if err := (&publisher{}).publishManifest(); err == nil { - t.Fatal("publication without content review accepted") - } -} - -func TestPublishManifestRejectsUnsafePreview(t *testing.T) { - for _, scenario := range []string{"empty", "invalid digest", "missing tag", "moved tag", "conflict"} { - t.Run(scenario, func(t *testing.T) { - t.Setenv("SCROLL_LIFECYCLE_REVIEWED", "1") - digest := "sha256:" + strings.Repeat("a", 64) - ref := testRef("owner", "registry.example", "example", "v1") - manifest := filepath.Join(t.TempDir(), "pushed.txt") - if err := os.WriteFile(manifest, []byte(ref+"\n"), 0600); err != nil { - t.Fatal(err) - } - published := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/token": - _ = json.NewEncoder(w).Encode(map[string]string{"token": "fixture"}) - case "/v1/registry/repositories/example/publication-preview": - snapshot := []publicationArtifact{{Digest: digest, Tags: []string{"v1"}}} - switch scenario { - case "empty": - snapshot = nil - case "invalid digest": - snapshot[0].Digest = "latest" - case "missing tag": - snapshot[0].Tags = []string{"v2"} - case "moved tag": - snapshot[0].Digest = "sha256:" + strings.Repeat("b", 64) - } - _ = json.NewEncoder(w).Encode(snapshot) - case "/v1/registry/scrolls/revision": - _ = json.NewEncoder(w).Encode(map[string]string{"repository": "registry.example/owner/example", "digest": digest, "artifact": "registry.example/owner/example@" + digest}) - case "/v1/registry/scrolls/publish": - published++ - http.Error(w, "changed since review", http.StatusConflict) - default: - t.Errorf("unexpected request: %s", r.URL.Path) - http.NotFound(w, r) - } - })) - defer server.Close() - p := &publisher{base: server.URL, auth: server.URL, owner: "owner", host: "registry.example", manifest: manifest, client: server.Client()} - if err := p.publishManifest(); err == nil { - t.Fatal("unsafe publication accepted") - } - want := 0 - if scenario == "conflict" { - want = 1 - } - if published != want { - t.Fatalf("publication requests: got %d, want %d; conflicts must not auto-retry", published, want) - } - }) - } -} diff --git a/scripts/push.sh b/scripts/push.sh index a406be99..6ea02b6b 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -11,7 +11,6 @@ DRUID_BIN="${DRUID_BIN:-druid}" SCROLL_PUSH_DRY_RUN="${SCROLL_PUSH_DRY_RUN:-0}" SCROLL_REGISTRY_HOST="${SCROLL_REGISTRY_HOST:-artifacts.druid.gg}" SCROLL_REGISTRY_NAMESPACE="${SCROLL_REGISTRY_NAMESPACE:-druid-team}" -SCROLL_OWNER_ID="${SCROLL_OWNER_ID:-}" SCROLL_REGISTRY_USER="${SCROLL_REGISTRY_USER:-}" SCROLL_REGISTRY_PASSWORD="${SCROLL_REGISTRY_PASSWORD:-}" DRUID_CLI_VERSION="${DRUID_CLI_VERSION:-v0.1.257}" @@ -32,11 +31,6 @@ registry_host="${SCROLL_REGISTRY_HOST#http://}" registry_host="${registry_host#https://}" registry_host="${registry_host%%/*}" registry_prefix="${registry_host}/${SCROLL_REGISTRY_NAMESPACE}" -if [[ -n "$SCROLL_OWNER_ID" ]]; then - [[ "$SCROLL_OWNER_ID" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]] || { echo "Invalid owner project" >&2; exit 2; } - registry_prefix="${registry_host}/${SCROLL_OWNER_ID}" - export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)}" -fi runtime_namespace="${SCROLL_REGISTRY_RUNTIME_NAMESPACE:-${SCROLL_REGISTRY_NAMESPACE}}" runtime_prefix="${registry_host}/${runtime_namespace}" runtime_image="${DRUID_SCROLL_RUNTIME_IMAGE:-${runtime_prefix}/druid:${DRUID_CLI_VERSION}}" @@ -85,22 +79,11 @@ login_if_configured() { rewrite_registry_reference() { local value="$1" - local source_prefix="artifacts.druid.gg/druid-team/" if [[ "$value" == "$runtime_image" || "$value" == "$steamcmd_image" ]]; then printf '%s' "$value" return fi - if [[ -n "$SCROLL_OWNER_ID" && "$value" == "$source_prefix"* ]]; then - local path="${value#"$source_prefix"}" - local name="${path%%[:@]*}" - if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]]; then - echo "Invalid owner Scroll repository: $name" >&2 - return 2 - fi - printf '%s' "${registry_prefix}/${path}" - else - printf '%s' "${value//artifacts.druid.gg\/druid-team/$registry_prefix}" - fi + printf '%s' "${value//artifacts.druid.gg\/druid-team/$registry_prefix}" } run() { @@ -121,12 +104,6 @@ run() { command[2]="$ref" fi - # Direct owner repositories carry their own presentation metadata. The - # generic publication preflight cannot depend on separate category artifacts. - if [[ -n "$SCROLL_OWNER_ID" && "${command[1]:-}" == "push" && "${command[2]:-}" != "category" ]]; then - command+=(--pack-meta) - fi - # The catalog intentionally uses production refs. Retarget them here for # local Harbor, staging, or PR namespaces without changing every push line. for index in "${!command[@]}"; do @@ -157,24 +134,17 @@ run() { fi if ((SCROLL_PUSH_JOBS == 1)); then - execute_push "${command[@]}" + "${command[@]}" return fi - execute_push "${command[@]}" & + "${command[@]}" & push_pids+=("$!") if ((${#push_pids[@]} >= SCROLL_PUSH_JOBS)); then wait_for_oldest_push fi } -execute_push() { - "$@" || return - if [[ -n "${SCROLL_PUSH_MANIFEST:-}" && "$2" == "push" && "$3" != "category" ]]; then - printf '%s\n' "$3" >> "$SCROLL_PUSH_MANIFEST" - fi -} - push_release_categories() { run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-spigot minecraft ./scrolls/minecraft/minecraft-spigot/.meta run druid push category artifacts.druid.gg/druid-team/scroll-minecraft-vanilla minecraft ./scrolls/minecraft/minecraft-vanilla/.meta diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index 57a6543b..d8713b79 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -38,20 +38,32 @@ artifact_dry_run="$( SCROLL_PUSH_DRY_RUN=1 \ SCROLL_PUSH_CATEGORIES=0 \ SCROLL_PUSH_ARTIFACTS=1 \ - SCROLL_OWNER_ID=test-owner \ bash "$push_script" )" -[[ "$artifact_dry_run" == *"artifacts.druid.gg/test-owner/scroll-minecraft-spigot:1.17"* ]] || \ - fail "direct pushes must target the owner's private project" +[[ "$artifact_dry_run" == *"artifacts.druid.gg/druid-team/scroll-minecraft-spigot:1.17"* ]] || \ + fail "release pushes must update the existing repositories and tags" [[ "$artifact_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257"* ]] || \ - fail "owner mapping must leave runtime image references unchanged" -[[ "$artifact_dry_run" == *"--pack-meta"* ]] || \ - fail "direct owner pushes must carry presentation metadata" + fail "release pushes must preserve runtime image references" [[ "$category_dry_run" == *"artifacts.druid.gg/druid-team/scroll-minecraft-spigot"* ]] || \ fail "category metadata must retain the configured registry namespace" expected_categories="$(printf '%s\n' "$category_dry_run" | grep -Ec '^"?druid"? push category ')" expected_artifacts="$(printf '%s\n' "$artifact_dry_run" | grep -Ec '^"?druid"? push ')" +preview_dry_run="$( + SCROLL_PUSH_DRY_RUN=1 \ + SCROLL_PUSH_CATEGORIES=0 \ + SCROLL_REGISTRY_NAMESPACE=druid-team-experimental \ + SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team \ + SCROLL_TAG_SUFFIX=-pr123 \ + bash "$push_script" +)" +[[ "$preview_dry_run" == *"artifacts.druid.gg/druid-team-experimental/scroll-minecraft-spigot:1.17-pr123"* ]] || \ + fail "preview pushes must use the preview namespace and tag suffix" +[[ "$preview_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257"* ]] || \ + fail "preview pushes must leave runtime images in the release namespace" +[[ "$preview_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257-steamcmd"* ]] || \ + fail "preview pushes must preserve the SteamCMD runtime image" + serial_dry_run="$(SCROLL_PUSH_DRY_RUN=1 SCROLL_PUSH_JOBS=1 bash "$push_script")" parallel_dry_run="$(SCROLL_PUSH_DRY_RUN=1 SCROLL_PUSH_JOBS=4 bash "$push_script")" assert_equals "$serial_dry_run" "$parallel_dry_run" \ @@ -82,12 +94,39 @@ reset_state assert_equals "1" "$(<"$tmp_dir/state/max")" \ "the default must preserve serial push behavior" +reset_state +DRUID_BIN="$fake_druid" \ +FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ +FAKE_DRUID_SLEEP=0 \ +SCROLL_REGISTRY_USER=test-publisher \ +SCROLL_REGISTRY_PASSWORD=test-password \ +SCROLL_PUSH_ARTIFACTS=0 \ + bash "$push_script" >"$tmp_dir/login.out" +grep -q '^start artifact login --host artifacts.druid.gg --user test-publisher --password test-password$' "$tmp_dir/state/events.log" || \ + fail "configured registry credentials must be passed to druid login" +assert_equals "start artifact login" "$(head -n 1 "$tmp_dir/state/events.log" | cut -d ' ' -f1-3)" \ + "registry login must happen before any push" + +reset_state +if DRUID_BIN="$fake_druid" \ + FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ + FAKE_DRUID_SLEEP=0 \ + FAKE_DRUID_FAIL_REF=--host \ + SCROLL_REGISTRY_USER=test-publisher \ + SCROLL_REGISTRY_PASSWORD=test-password \ + SCROLL_PUSH_ARTIFACTS=0 \ + bash "$push_script" >"$tmp_dir/login-failure.out" 2>"$tmp_dir/login-failure.err"; then + fail "a failed registry login must make push.sh fail" +fi +if grep -q '^start .* push ' "$tmp_dir/state/events.log"; then + fail "no artifacts or categories may be pushed after a failed login" +fi + reset_state DRUID_BIN="$fake_druid" \ FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ FAKE_DRUID_SLEEP=0.02 \ SCROLL_PUSH_JOBS=3 \ -SCROLL_PUSH_MANIFEST="$tmp_dir/pushed.txt" \ bash "$push_script" >"$tmp_dir/parallel.out" maximum="$(<"$tmp_dir/state/max")" @@ -100,12 +139,6 @@ assert_equals "$expected_categories" "$actual_categories" \ "parallel mode must invoke every category push exactly once" assert_equals "$expected_artifacts" "$actual_artifacts" \ "parallel mode must invoke every artifact push exactly once" -assert_equals "$expected_artifacts" "$(wc -l < "$tmp_dir/pushed.txt" | tr -d ' ')" \ - "the publication manifest must record each successful artifact push" -if grep -q 'category' "$tmp_dir/pushed.txt"; then - fail "category metadata must not enter the publication manifest" -fi - last_category_end="$(grep -n '^end category ' "$tmp_dir/state/events.log" | tail -n 1 | cut -d: -f1)" first_artifact_start="$(grep -m 1 -n '^start artifact ' "$tmp_dir/state/events.log" | cut -d: -f1)" ((last_category_end < first_artifact_start)) || \ diff --git a/scripts/validate-release-workflow/lifecycle_workflow.go b/scripts/validate-release-workflow/lifecycle_workflow.go deleted file mode 100644 index 9dd57fb5..00000000 --- a/scripts/validate-release-workflow/lifecycle_workflow.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - "gopkg.in/yaml.v3" -) - -type lifecycleWorkflow struct { - Jobs map[string]lifecycleJob `yaml:"jobs"` -} - -type lifecycleJob struct { - Uses string `yaml:"uses"` - With map[string]any `yaml:"with"` - Secrets any `yaml:"secrets"` - Env map[string]string `yaml:"env"` - Needs string `yaml:"needs"` - If string `yaml:"if"` - RunsOn string `yaml:"runs-on"` - Environment string `yaml:"environment"` - Concurrency struct { - Cancel bool `yaml:"cancel-in-progress"` - } `yaml:"concurrency"` - Steps []lifecycleStep `yaml:"steps"` -} - -type lifecycleStep struct { - Name string `yaml:"name"` - Uses string `yaml:"uses"` - Run string `yaml:"run"` - Env map[string]string `yaml:"env"` - With map[string]any `yaml:"with"` -} - -// Check the actual CI callers and keep publication in protected environments. -func validateLifecycleWorkflows(directory string) error { - read := func(name string) (lifecycleWorkflow, error) { - var workflow lifecycleWorkflow - data, err := os.ReadFile(filepath.Join(directory, name)) - if err == nil { - err = yaml.Unmarshal(data, &workflow) - } - return workflow, err - } - for file, public := range map[string]bool{"release.yml": true, "pr.yml": false} { - workflow, err := read(file) - if err != nil { - return err - } - job := workflow.Jobs["build-deploy"] - if len(workflow.Jobs) != 1 || job.Uses != "./.github/workflows/scroll-lifecycle.yml" || job.With["public"] != public || job.Secrets != nil { - return fmt.Errorf("%s must call the shared lifecycle with public=%t and no inherited secrets", file, public) - } - } - workflow, err := read("scroll-lifecycle.yml") - if err != nil { - return err - } - verify, publish := workflow.Jobs["verify"], workflow.Jobs["publish"] - if publish.Needs != "verify" || publish.Environment != "${{ inputs.public && 'scroll-release' || 'scroll-preview' }}" || publish.RunsOn != "ubuntu-latest" || publish.Concurrency.Cancel { - return fmt.Errorf("publication requires verification, protected environments, a fresh runner and non-cancelling concurrency") - } - wantCondition := "needs.verify.outputs.changed == 'true' && ((inputs.public && github.event_name != 'pull_request') || (!inputs.public && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository))" - if strings.Join(strings.Fields(publish.If), " ") != wantCondition { - return fmt.Errorf("publication must reject fork PRs and public PR publication") - } - if len(verify.Env) != 0 || len(publish.Env) != 0 { - return fmt.Errorf("lifecycle credentials/configuration must be scoped to steps, not whole jobs") - } - tests, gate, cli, publishCount := false, false, false, 0 - for _, step := range verify.Steps { - if strings.Contains(step.Run, "go test ./scripts/stage-scroll-ui ./scripts/validate-release-workflow ./scripts/publish-owned") { - tests = true - } - for _, value := range step.Env { - if strings.Contains(value, "secrets.") { - return fmt.Errorf("validation must not receive publication secrets") - } - } - } - for index, step := range publish.Steps { - if step.Name == "Require provisioned lifecycle environment" && step.Env["LIFECYCLE_READY"] == "${{ vars.SCROLL_LIFECYCLE_READY }}" && strings.Contains(step.Run, "exit 1") { - if index != 0 { - return fmt.Errorf("provisioning gate must precede every publication job step") - } - gate = true - } - if step.With["repository"] == "highcard-dev/druid-cli" { - ref, _ := step.With["ref"].(string) - cli = regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(ref) && step.With["persist-credentials"] == false - } - if !strings.Contains(step.Run, "bash ./scripts/push.sh") { - continue - } - publishCount++ - if !strings.Contains(step.Run, "for name in SCROLL_REGISTRY_HOST SCROLL_OWNER_ID SCROLL_REGISTRY_USER SCROLL_REGISTRY_PASSWORD; do") || - !strings.Contains(step.Run, "if [[ -z \"${!name}\" ]]; then") || !strings.Contains(step.Run, "exit 1") || - !strings.Contains(step.Run, "go run ./scripts/publish-owned preflight") || !strings.Contains(step.Run, "go run ./scripts/publish-owned publish") || - !strings.Contains(step.Run, ": > \"$SCROLL_PUSH_MANIFEST\"") { - return fmt.Errorf("lifecycle publication must reject missing publisher settings") - } - for name, want := range map[string]string{ - "SCROLL_LIFECYCLE_URL": "${{ vars.SCROLL_LIFECYCLE_URL }}", - "SCROLL_AUTH_URL": "${{ vars.SCROLL_AUTH_URL }}", - "SCROLL_PUBLISH_EMAIL": "${{ secrets.SCROLL_PUBLISH_EMAIL }}", - "SCROLL_PUBLISH_PASSWORD": "${{ secrets.SCROLL_PUBLISH_PASSWORD }}", - "SCROLL_LIFECYCLE_REVIEWED": "${{ vars.SCROLL_LIFECYCLE_REVIEWED }}", - "SCROLL_PUBLIC": "${{ inputs.public && '1' || '0' }}", - "SCROLL_PUSH_MANIFEST": "${{ runner.temp }}/scroll-artifacts.txt", - "SCROLL_OWNER_ID": "${{ vars.SCROLL_OWNER_ID }}", - "SCROLL_REGISTRY_RUNTIME_NAMESPACE": "${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || 'druid-team' }}", - "SCROLL_PUSH_CATEGORIES": "0", - "SCROLL_REGISTRY_USER": "${{ secrets.SCROLL_REGISTRY_USER }}", - "SCROLL_REGISTRY_PASSWORD": "${{ secrets.SCROLL_REGISTRY_PASSWORD }}", - "SCROLL_TAG_SUFFIX": "-${{ github.event.pull_request.head.sha || github.sha }}", - } { - if step.Env[name] != want { - return fmt.Errorf("lifecycle publication has incorrect %s", name) - } - } - if _, legacyMode := step.Env["SCROLL_PUBLISH_MODE"]; legacyMode { - return fmt.Errorf("publisher workflow must use direct push without a lifecycle mode") - } - } - if !tests || !gate || !cli || publishCount != 1 { - return fmt.Errorf("shared workflow needs publication tests, provisioning gate, pinned CLI and one catalog push") - } - return nil -} diff --git a/scripts/validate-release-workflow/lifecycle_workflow_test.go b/scripts/validate-release-workflow/lifecycle_workflow_test.go deleted file mode 100644 index 82fdac8b..00000000 --- a/scripts/validate-release-workflow/lifecycle_workflow_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "gopkg.in/yaml.v3" -) - -func TestLifecycleWorkflows(t *testing.T) { - if err := validateLifecycleWorkflows("../../.github/workflows"); err != nil { - t.Fatal(err) - } -} - -func TestLifecycleProvisioningGate(t *testing.T) { - data, err := os.ReadFile("../../.github/workflows/scroll-lifecycle.yml") - if err != nil { - t.Fatal(err) - } - var workflow lifecycleWorkflow - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - steps := workflow.Jobs["publish"].Steps - if len(steps) == 0 || steps[0].Name != "Require provisioned lifecycle environment" { - t.Fatal("provisioning gate must run before checkout or any credential-bearing step") - } - for _, value := range []string{"", "false", "TRUE", "true"} { - t.Run("ready="+value, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - command := exec.CommandContext(ctx, "bash", "-eo", "pipefail", "-c", steps[0].Run) - command.Env = []string{"PATH=" + os.Getenv("PATH")} - if value != "" { - command.Env = append(command.Env, "LIFECYCLE_READY="+value) - } - output, err := command.CombinedOutput() - if ctx.Err() != nil { - t.Fatal(ctx.Err()) - } - if value == "true" { - if err != nil { - t.Fatalf("provisioned environment rejected: %v: %s", err, output) - } - } else if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { - t.Fatalf("unprovisioned environment must exit 1: %v: %s", err, output) - } - }) - } -} - -func TestLifecycleWorkflowRejectsMissingCutoverGuards(t *testing.T) { - for name, change := range map[string][3]string{ - "category artifacts in owner repositories": {"scroll-lifecycle.yml", "SCROLL_PUSH_CATEGORIES: '0'", "SCROLL_PUSH_CATEGORIES: '1'"}, - "legacy caller": {"release.yml", "./.github/workflows/scroll-lifecycle.yml", "./.github/workflows/legacy.yml"}, - "public preview": {"pr.yml", "public: false", "public: true"}, - "staging fallback": {"scroll-lifecycle.yml", "SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }}", "SCROLL_OWNER_ID: ${{ vars.SCROLL_OWNER_ID }}\n SCROLL_PUBLISH_MODE: lifecycle"}, - "mutable revision": {"scroll-lifecycle.yml", "SCROLL_TAG_SUFFIX: -${{ github.event.pull_request.head.sha || github.sha }}", "SCROLL_TAG_SUFFIX: -latest"}, - "hard-coded owner": {"scroll-lifecycle.yml", "vars.SCROLL_OWNER_ID", "'druid-team'"}, - "admin credential fallback": {"scroll-lifecycle.yml", "secrets.SCROLL_REGISTRY_USER", "secrets.HARBOR_ADMIN_USER"}, - "fork publication": {"scroll-lifecycle.yml", "github.event.pull_request.head.repo.full_name == github.repository", "true"}, - "no review environment": {"scroll-lifecycle.yml", "environment: ${{ inputs.public && 'scroll-release' || 'scroll-preview' }}", "environment: production"}, - "mutable CLI": {"scroll-lifecycle.yml", "a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd", "master"}, - "cancel mutation": {"scroll-lifecycle.yml", "cancel-in-progress: false", "cancel-in-progress: true"}, - "skip publication tests": {"scroll-lifecycle.yml", "go test ./scripts/stage-scroll-ui", "go test ./scripts/prebuild"}, - "missing credential guard": {"scroll-lifecycle.yml", "if [[ -z \"${!name}\" ]]; then", "if [[ -n \"${!name}\" ]]; then"}, - "skip generic publish": {"scroll-lifecycle.yml", "go run ./scripts/publish-owned publish", "echo skipped-publication"}, - "admin owner sign-in": {"scroll-lifecycle.yml", "secrets.SCROLL_PUBLISH_EMAIL", "secrets.HARBOR_ADMIN_EMAIL"}, - "late provisioning gate": {"scroll-lifecycle.yml", " - name: Require provisioned lifecycle environment", " - run: echo premature-step\n - name: Require provisioned lifecycle environment"}, - } { - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - for _, file := range []string{"release.yml", "pr.yml", "scroll-lifecycle.yml"} { - data, err := os.ReadFile(filepath.Join("../../.github/workflows", file)) - if err != nil { - t.Fatal(err) - } - if file == change[0] { - if !strings.Contains(string(data), change[1]) { - t.Fatal("mutation did not match workflow") - } - data = []byte(strings.Replace(string(data), change[1], change[2], 1)) - } - if err := os.WriteFile(filepath.Join(dir, file), data, 0600); err != nil { - t.Fatal(err) - } - } - if err := validateLifecycleWorkflows(dir); err == nil { - t.Fatal("unsafe workflow contract accepted") - } - }) - } -} diff --git a/scripts/validate-release-workflow/main.go b/scripts/validate-release-workflow/main.go deleted file mode 100644 index 92c5fd0a..00000000 --- a/scripts/validate-release-workflow/main.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "regexp" - "strconv" - "strings" -) - -var ( - forbiddenReleaseValues = []string{ - "v0.1.248", - "highcard/druid", - "stable-nix", - "latest-nix", - "-nix-steamcmd", - } - portOverrideNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) - pzserverRequiredPorts = map[string]string{ - "main": "/udp", - "main2": "/udp", - } -) - -func main() { - path := "scripts/push.sh" - if len(os.Args) > 1 { - path = os.Args[1] - } - if err := validateReleaseWorkflow(path); err != nil { - fmt.Fprintln(os.Stderr, "Error:", err) - os.Exit(1) - } - if err := validateLifecycleWorkflows(".github/workflows"); err != nil { - fmt.Fprintln(os.Stderr, "Error:", err) - os.Exit(1) - } -} - -func validateReleaseWorkflow(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - lines := strings.Split(string(data), "\n") - var failures []string - foundPZServer := false - - for index, line := range lines { - lineNumber := index + 1 - for _, forbidden := range forbiddenReleaseValues { - if strings.Contains(line, forbidden) { - failures = append(failures, fmt.Sprintf("%s:%d contains forbidden value %q", path, lineNumber, forbidden)) - } - } - if !isArtifactPushLine(line) { - continue - } - fields := strings.Fields(line) - ports, err := validatePortOverrides(fields) - if err != nil { - failures = append(failures, fmt.Sprintf("%s:%d %v", path, lineNumber, err)) - } - if strings.Contains(line, "scroll-lgsm:pzserver") { - foundPZServer = true - for name, expected := range pzserverRequiredPorts { - if actual := ports[name]; actual != expected { - failures = append(failures, fmt.Sprintf("%s:%d pzserver requires -p %s=%s, got %q", path, lineNumber, name, expected, actual)) - } - } - } - } - - if !foundPZServer { - failures = append(failures, fmt.Sprintf("%s: missing pzserver release push", path)) - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "\n")) - } - return nil -} - -func isArtifactPushLine(line string) bool { - fields := strings.Fields(line) - for i := 0; i+2 < len(fields); i++ { - if fields[i] == "druid" && fields[i+1] == "push" && fields[i+2] != "category" { - return true - } - } - return false -} - -func validatePortOverrides(fields []string) (map[string]string, error) { - ports := make(map[string]string) - var failures []string - - for i, field := range fields { - if field != "-p" && field != "--port" { - continue - } - if i+1 >= len(fields) { - failures = append(failures, fmt.Sprintf("%s is missing an override", field)) - continue - } - override := fields[i+1] - name, value, hasValue := strings.Cut(override, "=") - if !portOverrideNamePattern.MatchString(name) { - failures = append(failures, fmt.Sprintf("invalid %s override %q; invalid port name", field, override)) - continue - } - if !hasValue { - value = "" - } - portText := value - protocol := "" - if strings.Contains(value, "/") { - parts := strings.Split(value, "/") - if len(parts) != 2 || parts[1] == "" { - failures = append(failures, fmt.Sprintf("invalid %s override %q; expected name=port or name=port/protocol", field, override)) - continue - } - portText = parts[0] - protocol = parts[1] - switch protocol { - case "tcp", "udp", "http", "https": - default: - failures = append(failures, fmt.Sprintf("invalid %s override %q; unsupported protocol", field, override)) - continue - } - } - if portText == "" || portText == "0" { - if protocol == "http" || protocol == "https" { - failures = append(failures, fmt.Sprintf("invalid %s override %q; dynamic HTTP ports are not supported", field, override)) - continue - } - ports[name] = "0" - if protocol != "" { - ports[name] = "/" + protocol - } - continue - } - port, err := strconv.Atoi(portText) - if err != nil || port < 1 || port > 65535 { - failures = append(failures, fmt.Sprintf("invalid %s override %q; port must be omitted or 1-65535", field, override)) - continue - } - ports[name] = portText - if protocol != "" { - ports[name] += "/" + protocol - } - } - - if len(failures) > 0 { - return ports, errors.New(strings.Join(failures, "; ")) - } - return ports, nil -} diff --git a/scripts/validate-release-workflow/main_test.go b/scripts/validate-release-workflow/main_test.go deleted file mode 100644 index b183a246..00000000 --- a/scripts/validate-release-workflow/main_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestValidatePortOverridesAcceptsConcretePorts(t *testing.T) { - fields := strings.Fields("druid push repo:tag ./scroll -p main=25565 -p query=27015/udp -p battle-eye=2304/udp -p webpanel=8080") - - ports, err := validatePortOverrides(fields) - if err != nil { - t.Fatal(err) - } - if ports["main"] != "25565" || ports["query"] != "27015/udp" || ports["battle-eye"] != "2304/udp" || ports["webpanel"] != "8080" { - t.Fatalf("ports = %#v", ports) - } -} - -func TestValidatePortOverridesAcceptsDynamicPorts(t *testing.T) { - for _, value := range []string{ - "main", - "main=", - "main=/udp", - "main=0", - "main=0/udp", - } { - fields := []string{"druid", "push", "repo:tag", "./scroll", "-p", value} - if _, err := validatePortOverrides(fields); err != nil { - t.Fatalf("%s failed validation: %v", value, err) - } - } -} - -func TestValidatePortOverridesRejectsInvalidPorts(t *testing.T) { - for _, value := range []string{ - "main=65536", - "main=/", - "main=/sctp", - "web=/http", - "web=0/https", - "main=123/udp/extra", - "1main=123", - } { - fields := []string{"druid", "push", "repo:tag", "./scroll", "-p", value} - if _, err := validatePortOverrides(fields); err == nil { - t.Fatalf("%s passed validation, want failure", value) - } - } -} - -func TestIsArtifactPushLineSkipsCategories(t *testing.T) { - if isArtifactPushLine("druid push category artifacts.druid.gg/foo bar ./meta") { - t.Fatal("category push should not be treated as artifact push") - } - if !isArtifactPushLine("druid push artifacts.druid.gg/foo:tag ./scroll -p main=1") { - t.Fatal("artifact push was not detected") - } -} diff --git a/scripts/validate_all_scrolls.sh b/scripts/validate_all_scrolls.sh index 4e5f6693..d7bc73da 100755 --- a/scripts/validate_all_scrolls.sh +++ b/scripts/validate_all_scrolls.sh @@ -2,8 +2,6 @@ set -euo pipefail -go run ./scripts/validate-release-workflow - if command -v druid >/dev/null 2>&1 && druid validate --help >/dev/null 2>&1; then while IFS= read -r file; do dir="${file%/scroll.yaml}"