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
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ bundle:
version: "0.8.0-dev"
release_sequence: 0
channel: "development"
source_tree_digest: "sha256:383828ba805b05a5abf42b530c1cd067b23c2559be0026090ae955a2a8bd5c9a"
digest: "sha256:e664eabadb2a55b913c39813c8ef77427ca501ca5f09a4bcdf7328cc53f835c6"
source_tree_digest: "sha256:fc35a6bc483600e82186f5a9f592f6818cd2aca3cb50dbd1e9b9bb1772ead851"
digest: "sha256:f4e6402e5d6729a944595ace0f2b81990cd562fd662cfa1f7adf369d8bfec896"

projection:
input_digest: "sha256:7d1612c99b393e2e34ba48c987cf2c87a5f93f82742cabdd49996d8701ac9328"
output_digest: "sha256:2b832569605daaaaec7dd7a477e6b59743b6d087411188df886d0621b34d25e3"
input_digest: "sha256:f7fa43557bb16cb97cbe7b4926a47af83ff32fec4c72a84a3be7dc8251447e2d"
output_digest: "sha256:4bb6f7dd03e8efc9ca96005abb42cb824723bafb8a67afc8c09b1770bc148cd7"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:b5517ed46f67866220c2b18dbfbda4a40d99f00327611e56742a118d0ac59d0b"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:9b8ae8db225032c36aea608c746eb87a8bd9875b5aea6870efa4053b602a180e"
digest: "sha256:aab129beab492fc47a89d991fca50d9d04fa90a242bea316e77c42e0d92fcb39"
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GENERATED FILE - DO NOT EDIT DIRECTLY
# generator: gds
# bundle: 0.8.0-dev
# source-tree-digest: sha256:383828ba805b05a5abf42b530c1cd067b23c2559be0026090ae955a2a8bd5c9a
# input-digest: sha256:7d1612c99b393e2e34ba48c987cf2c87a5f93f82742cabdd49996d8701ac9328
# source-tree-digest: sha256:fc35a6bc483600e82186f5a9f592f6818cd2aca3cb50dbd1e9b9bb1772ead851
# input-digest: sha256:f7fa43557bb16cb97cbe7b4926a47af83ff32fec4c72a84a3be7dc8251447e2d
# output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4
# edit-source:
# - .gds/repository.yaml
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Versioning.

## [Unreleased]

- Add opt-in `continuous-development` policy with explicit, journaled removal
intent for selected repository ruleset status checks and generated advisory CI
guidance. Preserve omitted rules and unknown status-check parameters across
updates; verify empty ruleset readback and replay without another write.


### Changed

- Ruleset reconciliation compares the complete owned required-check identities,
Expand Down
2 changes: 2 additions & 0 deletions core/app/github_governance_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type GitHubGovernancePlanData struct {
}

type githubGovernanceOperationContext struct {
policy compiler.CompiledPolicyDocument
root string
estateRoot string
runtime githubRuntime
Expand Down Expand Up @@ -433,6 +434,7 @@ func (services *Services) githubGovernanceContext(
ready, blocker := githubGovernanceMutationGate(runtime.desired, assignment, capability, anchor.Repository.Lifecycle)
return githubGovernanceOperationContext{
root: localRoot, estateRoot: estateRoot, runtime: runtime, reader: reader, snapshot: snapshot,
policy: compiled.Document,
comparison: comparison, remediation: remediation, assignment: assignment,
capability: capability, ready: ready, blocker: blocker,
observation: operations.Observation{
Expand Down
31 changes: 31 additions & 0 deletions core/app/github_ruleset_delivery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app

import (
"github.com/NDDev-OpenNetwork/github-device-sync/core/compiler"
githubprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/github"
"testing"
)

func TestDeliveryPolicyRemovesChecksAndRequiresObservedAbsence(t *testing.T) {
original := githubprovider.RepositoryRuleset{Name: "main", Enforcement: "active", Rules: []githubprovider.RulesetRule{
{Type: "required_signatures"}, {Type: "required_status_checks", RequiredStatusChecks: []githubprovider.RequiredStatusCheck{{Context: "background"}}},
}}
for _, profile := range []string{"", "standard", "continuous-development"} {
desired := rulesetForDeliveryPolicy(original, compiler.CompiledPolicyDocument{Effective: map[string]any{"delivery": map[string]any{"profile": profile}}})
advisory := profile == "continuous-development"
if desired.RemoveRequiredStatusChecks != advisory {
t.Fatal("removal is not opt-in")
}
observed := githubprovider.RepositoryRulesetState{Enforcement: "active", Rules: original.Rules}
if rulesetOwnedStateMatches(observed, desired) == advisory {
t.Fatal("comparison did not account for existing checks")
}
observed.Rules = []githubprovider.RulesetRule{{Type: "required_signatures"}}
if rulesetOwnedStateMatches(observed, desired) != advisory {
t.Fatal("comparison did not account for absent checks")
}
}
if len(original.Rules) != 2 || original.Rules[1].Type != "required_status_checks" {
t.Fatal("projection changed its input")
}
}
25 changes: 24 additions & 1 deletion core/app/github_ruleset_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"

"github.com/NDDev-OpenNetwork/github-device-sync/core/compiler"
"github.com/NDDev-OpenNetwork/github-device-sync/core/domain"
"github.com/NDDev-OpenNetwork/github-device-sync/core/githubmutationruntime"
"github.com/NDDev-OpenNetwork/github-device-sync/core/githubruleset"
Expand Down Expand Up @@ -311,6 +312,7 @@ func (services *Services) githubRulesetContext(
})
return githubRulesetOperationContext{}, &failure
}
desired = rulesetForDeliveryPolicy(desired, governanceContext.policy)
capability, found := mutationCapabilityForInstallation(
governanceContext.runtime.desired, options.InstallationID,
)
Expand Down Expand Up @@ -364,7 +366,7 @@ func (services *Services) githubRulesetContext(
if rulesetID == 0 {
return githubRulesetOperationContext{
governance: governanceContext, privileged: privileged,
desired: desired, exists: false, inSync: false,
desired: desired, exists: false, inSync: desired.RemoveRequiredStatusChecks && len(desired.Rules) == 0,
providerRepositoryID: providerRepositoryID,
mutationCapabilityID: capability.Mutation.ID,
}, nil
Expand Down Expand Up @@ -400,6 +402,11 @@ func rulesetOwnedStateMatches(
for _, rule := range observed.Rules {
byType[rule.Type] = rule
}
if desired.RemoveRequiredStatusChecks {
if _, present := byType["required_status_checks"]; present {
return false
}
}
for _, wanted := range desired.Rules {
actual, present := byType[wanted.Type]
if !present {
Expand Down Expand Up @@ -590,3 +597,19 @@ func githubRulesetPlanInvalid(command string) domain.Envelope {
Message: "The referenced plan is not a valid stored GitHub ruleset plan.",
})
}

// Policy projection is explicit and bound by the plan's compiled-policy digest.
func rulesetForDeliveryPolicy(desired githubprovider.RepositoryRuleset, policy compiler.CompiledPolicyDocument) githubprovider.RepositoryRuleset {
if !compiler.AdvisoryCI(policy) {
return desired
}
desired.RemoveRequiredStatusChecks = true
rules := make([]githubprovider.RulesetRule, 0, len(desired.Rules))
for _, rule := range desired.Rules {
if rule.Type != "required_status_checks" {
rules = append(rules, rule)
}
}
desired.Rules = rules
return desired
}
7 changes: 7 additions & 0 deletions core/compiler/delivery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package compiler

// AdvisoryCI is opt-in. An absent profile retains the existing policy behavior.
func AdvisoryCI(document CompiledPolicyDocument) bool {
delivery, _ := document.Effective["delivery"].(map[string]any)
return delivery["profile"] == "continuous-development"
}
30 changes: 30 additions & 0 deletions core/compiler/delivery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package compiler

import "testing"

func TestContinuousDevelopmentProfileIsOptIn(t *testing.T) {
for _, enabled := range []bool{false, true} {
anchor := testAnchor("repository-default")
if enabled {
anchor.Policy.Profiles = append(anchor.Policy.Profiles, "continuous-development")
}
result := New(testSchemas(t)).CompileDirectory(testRepositoryRoot(t), anchor, DevelopmentBundleVersion)
if len(result.Findings) != 0 {
t.Fatalf("findings: %#v", result.Findings)
}
if AdvisoryCI(result.Document) != enabled {
t.Fatalf("advisory=%v want %v", AdvisoryCI(result.Document), enabled)
}
if enabled && result.Document.Provenance["/effective/delivery/profile"].Source != "continuous-development" {
t.Fatal("delivery profile lacks provenance")
}
}
}

func TestDeliveryProfileRejectsUnknownValue(t *testing.T) {
source := testSource("bad-delivery", "stack", 500, map[string]any{"delivery": map[string]any{"profile": "pretend-green"}})
result := New(testSchemas(t)).Compile(testAnchor("bad-delivery"), map[string]PolicySource{"bad-delivery": source}, DevelopmentBundleVersion)
if len(result.Findings) == 0 {
t.Fatal("invalid delivery profile accepted")
}
}
34 changes: 26 additions & 8 deletions core/githubruleset/ruleset.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,12 @@ func normalizeDesired(value githubprovider.RepositoryRuleset) (githubprovider.Re
if value.ID < 0 || value.Name == "" || len(value.Name) > 256 || strings.ContainsAny(value.Name, "\x00\r\n") ||
(value.Target != "" && value.Target != "branch") ||
(value.Enforcement != "" && value.Enforcement != "active") ||
len(value.Rules) == 0 || len(value.Rules) > 32 {
(len(value.Rules) == 0 && !(value.RemoveRequiredStatusChecks && value.ID > 0)) || len(value.Rules) > 32 {
return githubprovider.RepositoryRuleset{}, errors.New("desired GitHub ruleset is invalid")
}
value.Target = "branch"
value.Enforcement = "active"
value.Rules = append([]githubprovider.RulesetRule(nil), value.Rules...)
value.Rules = append([]githubprovider.RulesetRule{}, value.Rules...)
seen := map[string]struct{}{}
for index := range value.Rules {
rule := &value.Rules[index]
Expand Down Expand Up @@ -314,6 +314,9 @@ func normalizeDesired(value githubprovider.RepositoryRuleset) (githubprovider.Re
}
rule.AllowedMergeMethods = methods
case "required_status_checks":
if value.RemoveRequiredStatusChecks {
return githubprovider.RepositoryRuleset{}, errors.New("required status checks conflict with explicit removal")
}
if len(rule.RequiredStatusChecks) == 0 || len(rule.RequiredStatusChecks) > 50 ||
rule.RequiredApprovingReviewCount != 0 {
return githubprovider.RepositoryRuleset{}, errors.New("GitHub status-check rule is invalid")
Expand Down Expand Up @@ -343,7 +346,7 @@ func normalizeDesired(value githubprovider.RepositoryRuleset) (githubprovider.Re
func normalizeState(value githubprovider.RepositoryRulesetState) (githubprovider.RepositoryRulesetState, error) {
if value.ID <= 0 || value.Name == "" || value.Target != "branch" || value.SourceType != "Repository" ||
value.Source == "" || (value.Enforcement != "active" && value.Enforcement != "disabled" && value.Enforcement != "evaluate") || len(value.ConditionIncludes) == 0 ||
len(value.Rules) == 0 {
value.Rules == nil {
return githubprovider.RepositoryRulesetState{}, errors.New("GitHub ruleset state is invalid")
}
includes := make([]string, len(value.ConditionIncludes))
Expand Down Expand Up @@ -377,7 +380,8 @@ func normalizeState(value githubprovider.RepositoryRulesetState) (githubprovider
knownRules = append(knownRules, rule)
}
desired, err := normalizeDesired(githubprovider.RepositoryRuleset{
ID: value.ID, Name: value.Name, Target: value.Target,
RemoveRequiredStatusChecks: len(knownRules) == 0,
ID: value.ID, Name: value.Name, Target: value.Target,
Enforcement: "active", Rules: knownRules,
})
if err != nil {
Expand Down Expand Up @@ -472,7 +476,18 @@ func ownedStateEqual(current githubprovider.RepositoryRulesetState, desired gith
if desired.Enforcement == "" && current.Enforcement != "active" {
return false
}
return reflect.DeepEqual(ownedRules(current.Rules), ownedRules(desired.Rules))
currentOwned := ownedRulesByType(current.Rules)
if desired.RemoveRequiredStatusChecks {
if _, present := currentOwned["required_status_checks"]; present {
return false
}
}
for kind, wanted := range ownedRulesByType(desired.Rules) {
if actual, present := currentOwned[kind]; !present || !reflect.DeepEqual(actual, wanted) {
return false
}
}
return true
}

func applyOwnedState(current githubprovider.RepositoryRulesetState, desired githubprovider.RepositoryRuleset) githubprovider.RepositoryRulesetState {
Expand All @@ -483,12 +498,15 @@ func applyOwnedState(current githubprovider.RepositoryRulesetState, desired gith
owned := ownedRulesByType(desired.Rules)
result := make([]githubprovider.RulesetRule, 0, len(current.Rules)+len(owned))
for _, rule := range current.Rules {
if desired.RemoveRequiredStatusChecks && rule.Type == "required_status_checks" {
continue
}
if rule.Type == "required_status_checks" || rule.Type == "pull_request" {
if replacement, exists := owned[rule.Type]; exists {
if rule.Type == "pull_request" {
replacement.ExternalParameters = append(json.RawMessage(nil), rule.ExternalParameters...)
}
replacement.ExternalParameters = append(json.RawMessage(nil), rule.ExternalParameters...)
result = append(result, replacement)
} else {
result = append(result, rule)
}
delete(owned, rule.Type)
continue
Expand Down
98 changes: 98 additions & 0 deletions core/githubruleset/ruleset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package githubruleset
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -268,3 +269,100 @@ func TestRulesetSemanticPostconditionIgnoresOnlyWritableSerialization(t *testing
t.Fatal("semantic ruleset drift was hidden with serialization evidence")
}
}

func TestRemovalApplyVerifyAndReplay(t *testing.T) {
for _, keepOther := range []bool{false, true} {
t.Run(fmt.Sprint(keepOther), func(t *testing.T) {
fixture := newRulesetFixture()
state := desiredState(Scope{Owner: "example", Name: "repository"}, desiredRuleset(9), 9)
state.WritablePayload = json.RawMessage(`{"fixture":"before"}`)
if keepOther {
state.Rules = append(state.Rules, githubprovider.RulesetRule{Type: "required_signatures"})
}
fixture.state = &state
expected := visibleState(state)
desired := githubprovider.RepositoryRuleset{ID: 9, Name: state.Name, Target: "branch", Enforcement: "active", Rules: []githubprovider.RulesetRule{}, RemoveRequiredStatusChecks: true}
step := rulesetStep(&expected, desired)
handler := &Handler{Reader: fixture, Writer: fixture}
result, err := handler.Apply(context.Background(), step)
if err != nil {
t.Fatal(err)
}
if fixture.writes != 1 || requiredChecks(fixture.state.Rules) != nil {
t.Fatal("checks were not removed")
}
if (len(fixture.state.Rules) == 1) != keepOther {
t.Fatal("other rules changed")
}
raw, _ := json.Marshal(result.After)
if err := handler.Verify(context.Background(), step, raw); err != nil {
t.Fatal(err)
}
if _, err := handler.Apply(context.Background(), step); err != nil || fixture.writes != 1 {
t.Fatalf("replay wrote again: %v", err)
}
fixture.state.Rules = append(fixture.state.Rules, githubprovider.RulesetRule{Type: "required_status_checks", RequiredStatusChecks: []githubprovider.RequiredStatusCheck{{Context: "returned"}}})
if err := handler.Verify(context.Background(), step, raw); err == nil {
t.Fatal("verification accepted restored checks")
}
})
}
}

func TestOmittedRulePreservedByExpectedPostcondition(t *testing.T) {
state := desiredState(Scope{Owner: "example", Name: "repository"}, desiredRuleset(9), 9)
desired := githubprovider.RepositoryRuleset{ID: 9, Name: state.Name, Enforcement: "active", Rules: []githubprovider.RulesetRule{{Type: "required_signatures"}}}
after := applyOwnedState(state, desired)
if requiredChecks(after.Rules) == nil || !ownedStateEqual(after, desired) {
t.Fatal("omission must preserve existing status checks")
}
}

func TestRemovalPlanSchemaAndParameterValidation(t *testing.T) {
schemas, err := validation.NewSchemaSet()
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
id int64
remove, withChecks, valid bool
}{
{"bound-clear-only", 9, true, false, true},
{"ordinary-empty", 9, false, false, false},
{"empty-create", 0, true, false, false},
{"contradictory", 9, true, true, false},
{"default", 9, false, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
desired := desiredRuleset(tc.id)
desired.RemoveRequiredStatusChecks = tc.remove
if !tc.withChecks {
desired.Rules = []githubprovider.RulesetRule{}
}
state := desiredState(Scope{Owner: "example", Name: "repository"}, desiredRuleset(9), 9)
state.WritablePayload = json.RawMessage(`{"fixture":"plan"}`)
expected := visibleState(state)
step := rulesetStep(&expected, desired)
if tc.id == 0 {
step = rulesetStep(nil, desired)
}
now := time.Date(2026, 7, 11, 5, 0, 0, 0, time.UTC)
plan, err := operations.NewPlan("plan_01KX7BV07RHD6KRA4Z4J0KCHGR", now, now.Add(15*time.Minute), operations.PlanInput{
Operation: "reconcile-github-ruleset", Actor: operations.Actor{Type: "agent-session", SessionID: "test-session"},
Preconditions: []operations.Precondition{{RepositoryID: step.RepositoryID, HeadOID: strings.Repeat("a", 40), ManifestDigest: "sha256:" + strings.Repeat("a", 64), PolicyDigest: "sha256:" + strings.Repeat("b", 64)}},
Steps: []operations.Step{step}, ApprovalClass: "github-ruleset-write",
})
if err != nil {
t.Fatal(err)
}
if got := len(plan.Validate(schemas)) == 0; got != tc.valid {
t.Fatalf("schema valid=%v findings=%#v", got, plan.Validate(schemas))
}
_, err = StepParameters(step)
if (err == nil) != tc.valid {
t.Fatalf("parameters valid=%v err=%v", err == nil, err)
}
})
}
}
2 changes: 2 additions & 0 deletions core/projections/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type Generator struct {
}

type templateData struct {
AdvisoryCI bool
Purpose string
Capabilities []string
Entrypoints []domain.ProductEntrypoint
Expand Down Expand Up @@ -606,6 +607,7 @@ func projectionTemplateData(
product = &domain.ProductFacts{}
}
return templateData{
AdvisoryCI: compiler.AdvisoryCI(policy),
Purpose: product.Purpose,
Capabilities: product.Capabilities,
Entrypoints: product.Entrypoints,
Expand Down
Loading