From b8fa98ad7e75453c67442161c0bc884c3e52d299 Mon Sep 17 00:00:00 2001 From: Rahul Varghese Date: Sun, 2 Aug 2026 22:25:50 +0530 Subject: [PATCH] fix(catalog): auto-sync publishes from tagged main heads and slug-scoped intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent ways the catalog (and its docs) never reached the platform: - The auto-publish gate keyed on scope precedence, where tag outranks branch: a release workflow that tags every main head disabled auto-sync FOREVER, with no output (hit live — a repo's Docs library stayed empty because each merge commit was immediately tagged). The gate now takes the snapshot and accepts a clean main head that is also tagged; dirty/detached/PR states stay excluded. Every silent precondition return now says why under ORUN_VERBOSE. - The push scope adopted the intent-declared project SLUG; state routes take prj_… ids, so the object sync 404'd ('Route not found: …/projects//state/objects/missing', warn-only in autosync). Same resolution as run/#606: OIDC-bound id wins in CI, projects-list resolution elsewhere. Also: `orun catalog push` resolves the backend URL from intent.yaml (passed nil before — the explicit verb demanded --backend-url even inside a configured repo). Co-Authored-By: Claude Fable 5 --- cmd/orun/catalog_autosync.go | 32 +++++++++++++++++++++------- cmd/orun/catalog_autosync_test.go | 35 ++++++++++++++++++------------- cmd/orun/catalog_push.go | 19 ++++++++++++++--- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/cmd/orun/catalog_autosync.go b/cmd/orun/catalog_autosync.go index 8bc3e95f..4c10b3ab 100644 --- a/cmd/orun/catalog_autosync.go +++ b/cmd/orun/catalog_autosync.go @@ -64,12 +64,19 @@ func autopushEnabled(intent *model.Intent) bool { return false } -// catalogAutoPublishScope reports whether a source scope is eligible for -// best-effort auto-publish: only the clean default branch. Feature branches, -// dirty trees, no-git, PRs, and tags are excluded so auto-sync never moves the +// catalogAutoPublishScope reports whether a source snapshot is eligible for +// best-effort auto-publish: the clean default branch — INCLUDING when its +// head commit is also tagged. Scope precedence ranks tag above branch, and a +// release-tagging workflow that tags every main head silently disabled +// auto-sync forever (hit live: a repo whose Docs library stayed empty +// because each merge was immediately tagged). Feature branches, dirty +// trees, no-git, and PRs stay excluded so auto-sync never moves the // project-wide head from non-canonical state. -func catalogAutoPublishScope(scope string) bool { - return scope == catalogmodel.SourceScopeBranchMain +func catalogAutoPublishScope(ws sourcectx.WorkspaceState) bool { + if ws.Scope() == catalogmodel.SourceScopeBranchMain { + return true + } + return ws.Tag != "" && !ws.Dirty && ws.Branch == "main" } // maybeAutoPushCatalog publishes the just-resolved catalog after a successful @@ -83,19 +90,27 @@ func maybeAutoPushCatalog(ctx context.Context) { intent := loadIntentForCloudConfig() if !autopushEnabled(intent) { + autopushVerbosef("catalog auto-sync: disabled") return } backendURL := resolveBackendURLWithConfig(intent, "") if backendURL == "" { - return // autopush is meaningless without a backend to sync to + autopushVerbosef("catalog auto-sync: no backend url") + return } workspaceRoot, err := catalogWorkspaceRoot() if err != nil { + autopushVerbosef("catalog auto-sync: workspace root: %v", err) return } ws, err := sourcectx.ResolveSourceSnapshot(ctx, sourcectx.ResolveOptions{WorkspacePath: workspaceRoot}) - if err != nil || !catalogAutoPublishScope(ws.Scope()) { + if err != nil || !catalogAutoPublishScope(ws) { + scope := "?" + if err == nil { + scope = ws.Scope() + } + autopushVerbosef("catalog auto-sync: scope gate (err=%v scope=%s)", err, scope) return } @@ -103,13 +118,16 @@ func maybeAutoPushCatalog(ctx context.Context) { // unchanged since the last successful auto-publish. _, refs, omRoot, err := openObjectModel() if err != nil { + autopushVerbosef("catalog auto-sync: object model: %v", err) return } cur, err := refs.Read(ctx, catalogCurrentRef) if err != nil { + autopushVerbosef("catalog auto-sync: ref read: %v", err) return } if cur.Target == readAutopushMarker(omRoot) { + autopushVerbosef("catalog auto-sync: debounced (unchanged)") return } diff --git a/cmd/orun/catalog_autosync_test.go b/cmd/orun/catalog_autosync_test.go index d7f8f869..267a632a 100644 --- a/cmd/orun/catalog_autosync_test.go +++ b/cmd/orun/catalog_autosync_test.go @@ -6,8 +6,8 @@ import ( "path/filepath" "testing" - "github.com/sourceplane/orun/internal/catalogmodel" "github.com/sourceplane/orun/internal/model" + "github.com/sourceplane/orun/internal/sourcectx" ) // TestAutopushEnabled covers the OR of the two sources. The user-config branch @@ -29,25 +29,30 @@ func TestAutopushEnabled(t *testing.T) { } } -// TestCatalogAutoPublishScope pins the gate: only the clean default branch is -// eligible for auto-publish, so a feature branch / dirty tree / detached state +// TestCatalogAutoPublishScope pins the gate: the clean default branch is +// eligible for auto-publish — including when its head is ALSO tagged (a +// release workflow that tags every main head must not silently disable +// auto-sync; hit live). A feature branch / dirty tree / PR / detached tag // never moves the project-wide head. func TestCatalogAutoPublishScope(t *testing.T) { - if !catalogAutoPublishScope(catalogmodel.SourceScopeBranchMain) { + mainClean := sourcectx.WorkspaceState{Branch: "main", HeadRevision: "abc"} + if !catalogAutoPublishScope(mainClean) { t.Fatal("branch-main must be auto-publishable") } - for _, scope := range []string{ - catalogmodel.SourceScopeBranchFeature, - catalogmodel.SourceScopeBranchProtected, // conservative: only main for now - catalogmodel.SourceScopeLocalDirty, - catalogmodel.SourceScopeLocalNoGit, - catalogmodel.SourceScopePR, - catalogmodel.SourceScopeTag, - catalogmodel.SourceScopeCIEvent, - "", + taggedMain := sourcectx.WorkspaceState{Branch: "main", HeadRevision: "abc", Tag: "v1.2.3"} + if !catalogAutoPublishScope(taggedMain) { + t.Fatal("a TAGGED clean main head must be auto-publishable") + } + for name, ws := range map[string]sourcectx.WorkspaceState{ + "feature branch": {Branch: "feat", HeadRevision: "abc"}, + "dirty main": {Branch: "main", HeadRevision: "abc", Dirty: true}, + "dirty tagged": {Branch: "main", HeadRevision: "abc", Tag: "v1", Dirty: true}, + "detached tag": {HeadRevision: "abc", Tag: "v1"}, + "pr": {Branch: "main", HeadRevision: "abc", PRNumber: 7}, + "no git": {}, } { - if catalogAutoPublishScope(scope) { - t.Fatalf("scope %q must not be auto-publishable", scope) + if catalogAutoPublishScope(ws) { + t.Fatalf("%s must not be auto-publishable", name) } } } diff --git a/cmd/orun/catalog_push.go b/cmd/orun/catalog_push.go index 30764338..c8f93aac 100644 --- a/cmd/orun/catalog_push.go +++ b/cmd/orun/catalog_push.go @@ -19,6 +19,7 @@ import ( "os" "strings" + "github.com/sourceplane/orun/internal/configsurface" "github.com/sourceplane/orun/internal/objremote" "github.com/sourceplane/orun/internal/remotestate" "github.com/sourceplane/orun/internal/ui" @@ -80,7 +81,7 @@ func validatePushToken(ctx context.Context, tokenSrc remotestate.TokenSource) er func runCatalogPush() error { ctx := context.Background() - backendURL, err := requireBackendURL(nil, catalogPushBackendURL) + backendURL, err := requireBackendURL(loadIntentForCloudConfig(), catalogPushBackendURL) if err != nil { return err } @@ -140,7 +141,10 @@ func pushResolvedCatalog(ctx context.Context, backendURL, orgFlag, projectFlag, } return fmt.Errorf("remote state auth: %w", err) } - // Credential-agnostic CI (OCv2-2): adopt the OIDC-resolved scope where empty. + // Credential-agnostic CI (OCv2-2): adopt the OIDC-resolved scope where the + // local one is empty OR an intent-declared SLUG — state routes take prj_… + // ids only, and a slug 404s the whole sync ("catalog auto-sync skipped: + // … Route not found: …/projects//state/objects/missing", hit live). if oidcSrc, ok := tokenSrc.(*remotestate.OIDCTokenSource); ok { if _, terr := oidcSrc.Token(ctx); terr != nil { return fmt.Errorf("remote state auth (oidc exchange): %w", terr) @@ -149,12 +153,21 @@ func pushResolvedCatalog(ctx context.Context, backendURL, orgFlag, projectFlag, if scope.OrgID == "" { scope.OrgID = exOrg } - if scope.ProjectID == "" { + if p := strings.TrimSpace(scope.ProjectID); (p == "" || !strings.HasPrefix(p, "prj_")) && exProject != "" { scope.ProjectID = exProject } } else if err := validatePushToken(ctx, tokenSrc); err != nil { return err } + // Outside CI the same slug-vs-id gap applies (mirrors `orun run`, #606). + if p := strings.TrimSpace(scope.ProjectID); p != "" && !strings.HasPrefix(p, "prj_") && !isOSSBackend(backendURL) { + cs := configsurface.NewClient(backendURL, version, tokenSrc) + id, rerr := cs.ResolveProjectID(ctx, scope.OrgID, p) + if rerr != nil { + return fmt.Errorf("resolve project %q: %w", p, rerr) + } + scope.ProjectID = id + } client := remotestate.NewClientWithScope(backendURL, version, tokenSrc, scope) remoteStore, remoteRefs := client.RemoteStores()