Skip to content

Commit 6089482

Browse files
authored
Merge pull request #131 from NDDev-OpenNetwork/feat/classify-by-account-not-by-fork
feat(estate): classify a repository by its account, not by being a fork
2 parents 50842fd + 98f4ab3 commit 6089482

27 files changed

Lines changed: 77 additions & 131 deletions

.gds/bundle.lock.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ bundle:
55
version: "0.9.0-dev"
66
release_sequence: 0
77
channel: "development"
8-
source_tree_digest: "sha256:e88904c3b4b3654f40b30843ea28f1ab33bb4ee45db6d6b0f0f89076857ba54f"
9-
digest: "sha256:1e6b426bb6796d36de00d07037fbfc2591f2f6af30d63f2bd65ae091dbf82973"
8+
source_tree_digest: "sha256:4f651053830e5752c664d7f08be88f18164c6f4cd57c7450631e459250bac15b"
9+
digest: "sha256:e587a8a3ae46d44ccb7d1385eeba75a89463d9e5642c7e05b914c42a386e6ae4"
1010

1111
projection:
12-
input_digest: "sha256:c939fde7254664217bc73f2b990ce7e73db38359ae62bcc8ca690ab6ca066b92"
13-
output_digest: "sha256:0f5697325f6623916cb575aa8e1b2e0a3422635de596a1fd048d2776deda131c"
12+
input_digest: "sha256:470d0746c588cb1f506c95548a99d4304424f27cc728e4d9fc559a81d4453d88"
13+
output_digest: "sha256:d3f7deef13fb002b68c913edf9e838842f56acf1624510a89c2dd4a3b6eea2ea"
1414
files:
1515
- path: ".gds/compiled-policy.json"
1616
digest: "sha256:7ba2962e8afdf99eaf32582cd866f394abff2e24728b0644f013ec67d478b521"
1717
- path: ".github/workflows/gds-ci.yml"
18-
digest: "sha256:a6a19aa0ac91644efe450663924f229a7f474d016e58764be2b561608b4bd8b4"
18+
digest: "sha256:56d79046a6adea23115ff848ad8de960e4b7c1baf33ebb08d50c81a1246522bc"

.github/workflows/gds-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# GENERATED FILE - DO NOT EDIT DIRECTLY
22
# generator: gds
33
# bundle: 0.9.0-dev
4-
# source-tree-digest: sha256:e88904c3b4b3654f40b30843ea28f1ab33bb4ee45db6d6b0f0f89076857ba54f
5-
# input-digest: sha256:c939fde7254664217bc73f2b990ce7e73db38359ae62bcc8ca690ab6ca066b92
4+
# source-tree-digest: sha256:4f651053830e5752c664d7f08be88f18164c6f4cd57c7450631e459250bac15b
5+
# input-digest: sha256:470d0746c588cb1f506c95548a99d4304424f27cc728e4d9fc559a81d4453d88
66
# output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c
77
# edit-source:
88
# - .gds/repository.yaml

core/assurance/fixtures.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ func (fixtureAudit) Record(
5454

5555
func buildFixtureRepositories(options Options) []fixtureRepository {
5656
result := make([]fixtureRepository, 0, options.RepositoryCount)
57-
// Forks are restricted to owners that carry explicit fork selectors in the
58-
// canonical estate (personal and organization); the other organization
59-
// installations have no fork selector, so a fork owned there would compile
60-
// unassigned.
57+
// Two owners carry the fork half of the fixture. The restriction used to
58+
// be forced — only these two had fork selectors — and is now only a stable
59+
// distribution the bounded-evidence counts are calibrated against. A fork
60+
// is classified by the account that holds it, like any other repository.
6161
forkOwners := []string{"example-user", "example-org"}
6262
sourceOwners := []string{"example-user", "example-org", "example-media", "NDDev-OpenNetwork"}
6363
for index := 0; index < options.RepositoryCount; index++ {

core/assurance/runner.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,15 @@ func Run(ctx context.Context, options Options, schemas *validation.Set) (Report,
8989
len(compiled.Repositories), findingCodes(findings),
9090
)
9191
}
92-
forkAssignments := countForkAssignments(compiled)
92+
// Forks are no longer a classification of their own, so the invariant is
93+
// no longer "N repositories landed in a fork portfolio" — a count that
94+
// would now be zero forever and prove nothing. What must hold is that a
95+
// fork is classified exactly like any other repository of its account:
96+
// matched by a selector and carrying a portfolio.
97+
forkAssignments := countClassifiedForks(fixtures, compiled)
9398
if forkAssignments != options.ForkCount {
9499
return Report{}, fmt.Errorf(
95-
"fork assignment mismatch: got %d want %d", forkAssignments, options.ForkCount,
100+
"classified fork mismatch: got %d want %d", forkAssignments, options.ForkCount,
96101
)
97102
}
98103

@@ -267,13 +272,24 @@ func resolveRoot(path string) (string, error) {
267272
return filepath.Clean(resolved), nil
268273
}
269274

270-
func countForkAssignments(inventory estate.CompiledInventory) int {
271-
count := 0
275+
func countClassifiedForks(
276+
fixtures []fixtureRepository,
277+
inventory estate.CompiledInventory,
278+
) int {
279+
assignments := make(map[int64]estate.Assignment, len(inventory.Repositories))
272280
for _, assignment := range inventory.Repositories {
273-
if assignment.MatchedSelector == "personal-forks" ||
274-
assignment.MatchedSelector == "organization-forks" {
275-
count++
281+
assignments[assignment.ProviderID] = assignment
282+
}
283+
count := 0
284+
for _, fixture := range fixtures {
285+
if !fixture.Observed.Fork {
286+
continue
287+
}
288+
assignment, found := assignments[fixture.Observed.ProviderID]
289+
if !found || assignment.MatchedSelector == "" || len(assignment.Portfolios) == 0 {
290+
continue
276291
}
292+
count++
277293
}
278294
return count
279295
}

core/estate/compiler.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ func Compile(
8585
assignment.RolloutRing = selected.Assign.RolloutRing
8686
assignment.MatchedSelector = selected.Selector.ID
8787
} else {
88-
if repository.Fork {
89-
assignment.Portfolios = []string{owner.Classification.ForkPortfolio}
90-
} else {
91-
assignment.Portfolios = []string{owner.Classification.SourcePortfolio}
92-
}
88+
// A repository belongs to the account that holds it. Whether GitHub
89+
// calls it a fork is a property of how it was created, not of who
90+
// is responsible for it, and it used to send unmatched forks to a
91+
// portfolio of their own. Owner classification is now the single
92+
// fallback; `fork_portfolio` is deprecated and no longer read.
93+
assignment.Portfolios = []string{owner.Classification.SourcePortfolio}
9394
}
9495
sort.Strings(assignment.Portfolios)
9596
sort.Strings(assignment.PolicyProfiles)
@@ -121,9 +122,6 @@ func matchedSelectors(selectors []Selector, ownerID string, repository ObservedR
121122
!matchesAnyNamePrefix(repository.Name, selector.Match.NamePrefixes) {
122123
continue
123124
}
124-
if selector.Match.Fork != nil && *selector.Match.Fork != repository.Fork {
125-
continue
126-
}
127125
if selector.Match.Archived != nil && *selector.Match.Archived != repository.Archived {
128126
continue
129127
}

core/estate/compiler_test.go

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestLoadCanonicalControlledMutationEstate(t *testing.T) {
1616
if config.Root.Rollout.MutationMode != "pull-request" ||
1717
config.Root.Discovery.DefaultManagementMode != "observe-only" ||
1818
len(config.Installations) != 5 || len(config.Mutations) != 4 ||
19-
len(config.Owners) != 5 || len(config.Selectors) != 9 {
19+
len(config.Owners) != 5 || len(config.Selectors) != 7 {
2020
t.Fatalf("config = %#v", config)
2121
}
2222
}
@@ -40,7 +40,6 @@ func TestCompileTwoThousandRepositoriesAndForksDeterministically(t *testing.T) {
4040
if len(findings) != 0 || len(compiled.Repositories) != 2000 {
4141
t.Fatalf("repositories=%d findings=%#v", len(compiled.Repositories), findings)
4242
}
43-
forks := 0
4443
managed := 0
4544
for index, assignment := range compiled.Repositories {
4645
if assignment.ProviderID != int64(index+1000) ||
@@ -55,16 +54,22 @@ func TestCompileTwoThousandRepositoriesAndForksDeterministically(t *testing.T) {
5554
} else if assignment.ManagementMode != "observe-only" {
5655
t.Fatalf("observe-only assignment[%d] = %#v", index, assignment)
5756
}
58-
if assignment.MatchedSelector == "personal-forks" ||
59-
assignment.MatchedSelector == "organization-forks" {
60-
forks++
57+
// Half the observations are forks. None of them is classified as
58+
// one: a repository belongs to the account that holds it, so a fork
59+
// lands in exactly the selector its non-fork sibling would.
60+
if assignment.Owner == "example-user" && assignment.MatchedSelector != "personal-sources" {
61+
t.Fatalf("personal assignment[%d] = %#v", index, assignment)
62+
}
63+
if assignment.Owner == "example-org" && assignment.MatchedSelector != "organization-sources" {
64+
t.Fatalf("organization assignment[%d] = %#v", index, assignment)
6165
}
6266
}
63-
if forks != 1000 {
64-
t.Fatalf("fork assignments = %d, want 1000", forks)
65-
}
66-
if managed != 500 {
67-
t.Fatalf("managed assignments = %d, want 500", managed)
67+
// Every organization repository is managed now, forks included. That is
68+
// the consequence of dropping fork classification: a managed account
69+
// manages everything it holds, and a fork stops being a way to sit
70+
// outside that. Half of these observations are forks.
71+
if managed != 1000 {
72+
t.Fatalf("managed assignments = %d, want 1000", managed)
6873
}
6974

7075
for left, right := 0, len(repositories)-1; left < right; left, right = left+1, right-1 {
@@ -81,7 +86,7 @@ func TestCompileTwoThousandRepositoriesAndForksDeterministically(t *testing.T) {
8186
func TestCompileRejectsSelectorConflictAndUnknownOwner(t *testing.T) {
8287
t.Parallel()
8388
config := loadCanonical(t)
84-
conflict := organizationForksSelector(t, config)
89+
conflict := organizationSourcesSelector(t, config)
8590
conflict.Selector.ID = "conflicting-selector"
8691
config.Selectors = append(config.Selectors, conflict)
8792
_, findings := Compile(config, []ObservedRepository{{
@@ -147,8 +152,9 @@ func TestCompileRoutesServerRepositoriesByNamePrefix(t *testing.T) {
147152
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" {
148153
t.Fatalf("personal server repository = %#v", got)
149154
}
150-
if got := byID[13]; got.MatchedSelector != "organization-forks" ||
151-
!containsString(got.Portfolios, "portfolio:forks") {
155+
// The name prefix decides, and being a fork no longer overrides it.
156+
if got := byID[13]; got.MatchedSelector != "organization-servers" ||
157+
!containsString(got.Portfolios, "portfolio:servers") {
152158
t.Fatalf("server-named organization fork repository = %#v", got)
153159
}
154160
}
@@ -196,13 +202,13 @@ func TestCompilePreservesArchivedObservation(t *testing.T) {
196202
}
197203
}
198204

199-
func organizationForksSelector(t *testing.T, config Config) Selector {
205+
func organizationSourcesSelector(t *testing.T, config Config) Selector {
200206
t.Helper()
201207
for _, selector := range config.Selectors {
202-
if selector.Selector.ID == "organization-forks" {
208+
if selector.Selector.ID == "organization-sources" {
203209
return selector
204210
}
205211
}
206-
t.Fatalf("organization-forks selector not found in %#v", config.Selectors)
212+
t.Fatalf("organization-sources selector not found in %#v", config.Selectors)
207213
return Selector{}
208214
}

core/validation/estate.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
176176
}
177177

178178
ownerByID := map[string]estateDocument{}
179-
ownerPortfolio := map[string]map[bool]string{}
179+
ownerPortfolio := map[string]string{}
180180
for _, document := range owners {
181181
identity := nestedObject(document.value, "owner")
182182
id := stringField(identity, "id")
@@ -211,10 +211,7 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
211211
})
212212
}
213213
classification := nestedObject(document.value, "classification")
214-
ownerPortfolio[id] = map[bool]string{
215-
false: stringField(classification, "source_portfolio"),
216-
true: stringField(classification, "fork_portfolio"),
217-
}
214+
ownerPortfolio[id] = stringField(classification, "source_portfolio")
218215
}
219216

220217
selectorIDs := map[string]estateDocument{}
@@ -237,7 +234,6 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
237234
})
238235
continue
239236
}
240-
fork, hasFork := match["fork"].(bool)
241237
for _, rawProfile := range arrayField(nestedObject(document.value, "assign"), "policy_profiles") {
242238
profile, _ := rawProfile.(string)
243239
if _, found := policyByID[profile]; !found {
@@ -248,10 +244,15 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
248244
})
249245
}
250246
}
251-
if !hasFork || selectorHasSpecializedMatch(match) {
247+
// A selector that narrows by name, visibility or lifecycle is
248+
// deliberately routing a subset elsewhere. Every other selector is the
249+
// owner's general one and must assign the portfolio the owner declares.
250+
// This rule used to be keyed on `match.fork` being present, which meant
251+
// dropping that field would have silently retired the check.
252+
if selectorHasSpecializedMatch(match) {
252253
continue
253254
}
254-
expectedPortfolio := ownerPortfolio[ownerID][fork]
255+
expectedPortfolio := ownerPortfolio[ownerID]
255256
assigned := arrayField(nestedObject(document.value, "assign"), "portfolios")
256257
if !stringArrayContains(assigned, expectedPortfolio) {
257258
findings = append(findings, domain.Finding{

core/validation/estate_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestCanonicalEstateTreePasses(t *testing.T) {
1717
summary, findings := set.ValidateEstateTree(repositoryRoot(t))
1818
if len(findings) != 0 || summary.Installations != 5 || summary.Mutations != 4 ||
1919
summary.Owners != 5 ||
20-
summary.Selectors != 9 || summary.Devices != 3 {
20+
summary.Selectors != 7 || summary.Devices != 3 {
2121
t.Fatalf("summary=%#v findings=%#v", summary, findings)
2222
}
2323
}
@@ -58,13 +58,13 @@ func TestEstateTreeRejectsCanonicalSelectorPortfolioMismatch(t *testing.T) {
5858
sourceRoot := repositoryRoot(t)
5959
copyEstateTree(t, filepath.Join(sourceRoot, "estate"), filepath.Join(root, "estate"))
6060
copyEstateTree(t, filepath.Join(sourceRoot, "policies"), filepath.Join(root, "policies"))
61-
selectorPath := filepath.Join(root, "estate", "selectors", "personal-forks.yaml")
61+
selectorPath := filepath.Join(root, "estate", "selectors", "guild-sources.yaml")
6262
raw, err := os.ReadFile(selectorPath)
6363
if err != nil {
6464
t.Fatal(err)
6565
}
6666
raw = []byte(strings.Replace(
67-
string(raw), "portfolio:forks", "portfolio:personal-servers", 1,
67+
string(raw), "portfolio:organization-projects", "portfolio:servers", 1,
6868
))
6969
if err := os.WriteFile(selectorPath, raw, 0o600); err != nil {
7070
t.Fatal(err)
@@ -87,7 +87,7 @@ func TestEstateTreeRejectsUnknownSelectorOwner(t *testing.T) {
8787
sourceRoot := repositoryRoot(t)
8888
copyEstateTree(t, filepath.Join(sourceRoot, "estate"), filepath.Join(root, "estate"))
8989
copyEstateTree(t, filepath.Join(sourceRoot, "policies"), filepath.Join(root, "policies"))
90-
selectorPath := filepath.Join(root, "estate", "selectors", "personal-forks.yaml")
90+
selectorPath := filepath.Join(root, "estate", "selectors", "personal-sources.yaml")
9191
raw, err := os.ReadFile(selectorPath)
9292
if err != nil {
9393
t.Fatal(err)
@@ -318,7 +318,7 @@ func TestEstateTreeRejectsPolicyReferencesThatResolveToNothing(t *testing.T) {
318318
},
319319
{
320320
name: "portfolio",
321-
old: ` - "portfolio:forks"`,
321+
old: ` - "portfolio:servers"`,
322322
new: ` - "portfolio:no-selector-assigns-this"`,
323323
code: "GDS_ESTATE_POLICY_PORTFOLIO_MISSING",
324324
},

estate/devices/example-user-mac2.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,13 @@ device:
1616

1717
workspace_roots:
1818
projects: "${HOME}/Developer/example-user"
19-
forks: "${HOME}/Developer/forks"
2019

2120
materialization:
2221
default_mode: "absent"
2322
include:
2423
- selector: "portfolio:personal-projects"
2524
workspace_root: "projects"
2625
mode: "active"
27-
- selector: "portfolio:forks"
28-
workspace_root: "forks"
29-
mode: "active"
3026

3127
harnesses:
3228
- "claude-code"

estate/devices/example-user-ubuntu-1.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,13 @@ device:
1717

1818
workspace_roots:
1919
projects: "${HOME}/Developer/projects"
20-
forks: "${HOME}/Developer/forks"
2120

2221
materialization:
2322
default_mode: "absent"
2423
include:
2524
- selector: "portfolio:organization-projects"
2625
workspace_root: "projects"
2726
mode: "active"
28-
- selector: "portfolio:forks"
29-
workspace_root: "forks"
30-
mode: "active"
3127

3228
harnesses:
3329
- "claude-code"

0 commit comments

Comments
 (0)