Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions cmd/orun/catalog_autosync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -83,33 +90,44 @@ 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
}

// Debounce: read the catalog the plan just resolved and skip when it is
// 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
}

Expand Down
35 changes: 20 additions & 15 deletions cmd/orun/catalog_autosync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand Down
19 changes: 16 additions & 3 deletions cmd/orun/catalog_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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/<slug>/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)
Expand All @@ -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()

Expand Down
Loading