diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 2886898..c937d31 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:0b0bf68b9ab468748b19776ce40aaf9d1cbc8b3ebd764fceeec9f0135cc1457b" - digest: "sha256:42f16cda41db4b4a14b6b6f8bfb39286416c4a0b1cc1bbf4f2e780e10e9fd5f7" + source_tree_digest: "sha256:383828ba805b05a5abf42b530c1cd067b23c2559be0026090ae955a2a8bd5c9a" + digest: "sha256:e664eabadb2a55b913c39813c8ef77427ca501ca5f09a4bcdf7328cc53f835c6" projection: - input_digest: "sha256:fd431f29d3e475e8c26065692f17245c3dbace5dc8c1c98839eb5ffdac2f3499" - output_digest: "sha256:9026d8394956deebe4214d6cf2c1c8e2cb91d99216f59d9087364105c306723a" + input_digest: "sha256:7d1612c99b393e2e34ba48c987cf2c87a5f93f82742cabdd49996d8701ac9328" + output_digest: "sha256:2b832569605daaaaec7dd7a477e6b59743b6d087411188df886d0621b34d25e3" files: - path: ".gds/compiled-policy.json" digest: "sha256:b5517ed46f67866220c2b18dbfbda4a40d99f00327611e56742a118d0ac59d0b" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:7e03a6468d3d0322208874a3ed8433c41c9d2bf6755191b9e63f818f6204909d" + digest: "sha256:9b8ae8db225032c36aea608c746eb87a8bd9875b5aea6870efa4053b602a180e" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 75f98f5..6b7c570 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:0b0bf68b9ab468748b19776ce40aaf9d1cbc8b3ebd764fceeec9f0135cc1457b -# input-digest: sha256:fd431f29d3e475e8c26065692f17245c3dbace5dc8c1c98839eb5ffdac2f3499 +# source-tree-digest: sha256:383828ba805b05a5abf42b530c1cd067b23c2559be0026090ae955a2a8bd5c9a +# input-digest: sha256:7d1612c99b393e2e34ba48c987cf2c87a5f93f82742cabdd49996d8701ac9328 # output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4 # edit-source: # - .gds/repository.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc0d20..c1bb1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Versioning. ### Changed +- Ruleset reconciliation compares the complete owned required-check identities, + including integration IDs and strict policy. Retired extra contexts or a + different check producer no longer appear synchronized; external parameters + and unowned rule types remain preserved. + - Drakkars audit, triage, orientation and rollout skills now model the current OTEL/OTLP/OpenObserve boundary, classified host-signal metrics, host compliance coverage, external backend heartbeat and bounded alert-silence diff --git a/core/app/github_ruleset_external_test.go b/core/app/github_ruleset_external_test.go index 0e2b9b2..4650acd 100644 --- a/core/app/github_ruleset_external_test.go +++ b/core/app/github_ruleset_external_test.go @@ -135,6 +135,43 @@ func TestRulesetPlannerDetectsOwnedPullRequestDrift(t *testing.T) { } } +func TestRulesetPlannerRequiresExactCheckIdentityAndPolicy(t *testing.T) { + checks := []githubprovider.RequiredStatusCheck{ + {Context: "CI / build", IntegrationID: 123}, + {Context: "Analysis", IntegrationID: 456}, + } + desired := githubprovider.RepositoryRuleset{Enforcement: "active", Rules: []githubprovider.RulesetRule{{ + Type: "required_status_checks", RequiredStatusChecks: checks, + StrictRequiredStatusChecksPolicy: true, + }}} + for _, tc := range []struct { + name string + checks []githubprovider.RequiredStatusCheck + strict bool + match bool + }{ + {"equal", checks, true, true}, + {"reordered", []githubprovider.RequiredStatusCheck{checks[1], checks[0]}, true, true}, + {"retired-check-still-required", append(slices.Clone(checks), githubprovider.RequiredStatusCheck{Context: "Old gate"}), true, false}, + {"missing-check", checks[:1], true, false}, + {"wrong-integration", []githubprovider.RequiredStatusCheck{{Context: "CI / build", IntegrationID: 789}, checks[1]}, true, false}, + {"unbound-integration", []githubprovider.RequiredStatusCheck{{Context: "CI / build"}, checks[1]}, true, false}, + {"duplicate-replaces-check", []githubprovider.RequiredStatusCheck{checks[0], checks[0]}, true, false}, + {"strict-policy-drift", checks, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + observed := githubprovider.RepositoryRulesetState{Enforcement: "active", Rules: []githubprovider.RulesetRule{{ + Type: "required_status_checks", RequiredStatusChecks: tc.checks, + StrictRequiredStatusChecksPolicy: tc.strict, + ExternalParameters: []byte(`{"future_parameter":true}`), + }, {Type: "required_signatures"}}} + if got := rulesetOwnedStateMatches(observed, desired); got != tc.match { + t.Fatalf("owned state match = %t, want %t", got, tc.match) + } + }) + } +} + // A declaration that names a generated context would pin, as unowned, something // the generator governs -- so the two sources of truth would disagree with no // way to tell which won. diff --git a/core/app/github_ruleset_operations.go b/core/app/github_ruleset_operations.go index d35d89c..cfc3da0 100644 --- a/core/app/github_ruleset_operations.go +++ b/core/app/github_ruleset_operations.go @@ -420,14 +420,22 @@ func rulesetOwnedStateMatches( if wanted.Type != "required_status_checks" { continue } - observedContexts := map[string]struct{}{} + // The rule is replaced as a whole by the provider adapter. A subset + // comparison would leave retired required checks active forever and + // would ignore a check produced by the wrong integration. + if actual.StrictRequiredStatusChecksPolicy != wanted.StrictRequiredStatusChecksPolicy || + len(actual.RequiredStatusChecks) != len(wanted.RequiredStatusChecks) { + return false + } + observedContexts := map[githubprovider.RequiredStatusCheck]int{} for _, check := range actual.RequiredStatusChecks { - observedContexts[check.Context] = struct{}{} + observedContexts[check]++ } for _, check := range wanted.RequiredStatusChecks { - if _, ok := observedContexts[check.Context]; !ok { + if observedContexts[check] == 0 { return false } + observedContexts[check]-- } } return true