diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 9d65406..9d6b1a1 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:ff0bdb5a3aecc647e785a571e5b08f4d47afdf4b9865139b63670f7636ed0df0" - digest: "sha256:83833e3c808531d699accaa7bde3e31289a631da93553c4fae5d08d9ffe902cd" + source_tree_digest: "sha256:c8582e4ff6726dc451444553411d81bf681dbcb03db409987de1db35c016f723" + digest: "sha256:f8b44694166c4358ee247362f0d3ec725c421bc681018ad8bea526250440b16b" projection: - input_digest: "sha256:847d847bf309749351054160ec089ef7f7d22a1969c26a3eb82ca69fa58beb23" - output_digest: "sha256:469c17732e4509ca38c6f2903b590cc0e8670502f965ebf50b4b40833e3f2cb4" + input_digest: "sha256:033f6d802a41abddbad4bb0375ca2ad899c2c5651409c3e9541b229179a4248c" + output_digest: "sha256:3ff85f46591c1c6d1479bb305adf866d64537f94345fac5185acba78b0738a6f" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:75d8ce3e084bc5b3f1b33ce920b245d635915810cb97fb9887db0eba168d5f7a" + digest: "sha256:387375f2ea62d4971ae8051ad1d13aa3c0d42ef9e7695a6df40d636808cc06d6" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index c326ce5..c5bf77b 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.9.7-dev -# source-tree-digest: sha256:ff0bdb5a3aecc647e785a571e5b08f4d47afdf4b9865139b63670f7636ed0df0 -# input-digest: sha256:847d847bf309749351054160ec089ef7f7d22a1969c26a3eb82ca69fa58beb23 +# source-tree-digest: sha256:c8582e4ff6726dc451444553411d81bf681dbcb03db409987de1db35c016f723 +# input-digest: sha256:033f6d802a41abddbad4bb0375ca2ad899c2c5651409c3e9541b229179a4248c # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml diff --git a/core/app/projection_operations.go b/core/app/projection_operations.go index b16ab7a..63dde7e 100644 --- a/core/app/projection_operations.go +++ b/core/app/projection_operations.go @@ -277,9 +277,11 @@ func (services *Services) projectionOperationContext( if len(findings) != 0 { return projectionContext{}, findings } - compiled := services.Compiler.CompileDirectory( - root, anchor, version, - ) + compilePolicy := services.Compiler.CompileDirectory + if source.released() { + compilePolicy = services.Compiler.CompileReleasedPublicDirectory + } + compiled := compilePolicy(root, anchor, version) if len(compiled.Findings) != 0 { return projectionContext{}, compiled.Findings } diff --git a/core/app/released_project_projection_test.go b/core/app/released_project_projection_test.go index be0cb7d..63992a9 100644 --- a/core/app/released_project_projection_test.go +++ b/core/app/released_project_projection_test.go @@ -2,13 +2,78 @@ package app import ( "context" + "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/bundle" ) +func TestReleasedPublicProjectGeneratesFromVerifiedPortableArtifact(t *testing.T) { + root := releasedProjectFixture(t, "public") + services, err := NewServices(DefaultClock) + if err != nil { + t.Fatal(err) + } + engine := appTestRepositoryRoot(t) + command := exec.Command("git", "-C", engine, "ls-files", "--", + "policies", "schemas/v1", "schemas/migrations", "templates/agents", + "templates/github-actions", "templates/harnesses", "skills/canonical", + "skills/registry.yaml", "harnesses", "plugins/gds-core", + "plugins/gds-estate-admin", "plugins/gds-module") + tracked, err := command.Output() + if err != nil { + t.Fatal(err) + } + options := bundle.BuildOptions{ + BundleVersion: "1.0.0", ReleaseSequence: 1, + SourceCommit: strings.Repeat("a", 40), MinimumCLIVersion: "0.1.0", + Workflow: ".github/workflows/release-bundle.yml", SourceRef: "refs/heads/main", + TrackedSources: strings.Fields(string(tracked)), + } + trust := bundle.TrustPolicy{ + Source: bundle.TrustSource{Owner: "example-owner", Repository: "example-engine", + AllowedWorkflows: []string{options.Workflow}, AllowedRefs: []string{options.SourceRef}}, + Release: bundle.TrustRelease{MinimumReleaseSequence: 1}, + } + candidate, findings := bundle.Build(engine, options, trust, services.Schemas) + if len(findings) != 0 { + t.Fatalf("synthetic release build: %#v", findings) + } + directory := t.TempDir() + archive := filepath.Join(directory, "bundle.tar.gz") + envelope := filepath.Join(directory, "release-envelope.json") + raw, err := json.Marshal(candidate.Envelope) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archive, candidate.Artifact, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(envelope, raw, 0o600); err != nil { + t.Fatal(err) + } + source := ProjectionSourceOptions{BundleArchive: archive, ReleaseEnvelope: envelope} + t.Run("operation-plan-inputs", func(t *testing.T) { + resolved, findings := services.projectionOperationContext(context.Background(), root, source) + if len(findings) != 0 || len(resolved.candidate.Files) != 2 { + t.Fatalf("public project projection: files=%d findings=%#v", len(resolved.candidate.Files), findings) + } + }) + t.Run("read-only-generation", func(t *testing.T) { + envelope := services.GenerateRepository(context.Background(), root, false, source) + if envelope.ExitCode != 0 { + t.Fatalf("public project generation: %#v", envelope.Findings) + } + }) + if _, err := os.Stat(filepath.Join(root, ".gds", "compiled-policy.json")); !os.IsNotExist(err) { + t.Fatalf("candidate generation wrote a projection: %v", err) + } +} + func TestReleasedPublicProjectRequiresArtifactWithoutFallingBackToEstate(t *testing.T) { root := releasedProjectFixture(t, "public") services, err := NewServices(DefaultClock) diff --git a/core/app/services.go b/core/app/services.go index 693976b..a2e2fce 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -304,10 +304,10 @@ func (services *Services) GenerateRepository( return envelopeForError("gds generate repository", path, infoErr) } anchor, findings = manifest.NewLoader(services.Schemas).LoadRepository(repositoryInfo.WorktreeRoot) - if len(findings) == 0 && !isPublicModuleProjection(anchor) { + if len(findings) == 0 && anchor.Classification.VisibilityContract != "public" { findings = []domain.Finding{{ Code: "GDS_PROJECTION_RELEASE_TARGET_INVALID", Severity: domain.SeverityHigh, - Message: "Released projection sources are accepted only for public modules.", + Message: "Released projection sources are accepted only for public repositories.", }} } var releasedManifest bundle.Manifest @@ -323,9 +323,11 @@ func (services *Services) GenerateRepository( "gds generate repository", classifyFindings(findings), nil, findings..., ) } - compiled := services.Compiler.CompileDirectory( - root, anchor, version, - ) + compilePolicy := services.Compiler.CompileDirectory + if options.released() { + compilePolicy = services.Compiler.CompileReleasedPublicDirectory + } + compiled := compilePolicy(root, anchor, version) if len(compiled.Findings) != 0 { return domain.NewEnvelope( "gds generate repository", classifyFindings(compiled.Findings), nil, diff --git a/core/compiler/compiler.go b/core/compiler/compiler.go index 76435ef..555fbdf 100644 --- a/core/compiler/compiler.go +++ b/core/compiler/compiler.go @@ -54,6 +54,33 @@ func (compiler *Compiler) CompileDirectory( root string, anchor domain.RepositoryAnchor, bundleVersion string, +) CompileResult { + return compiler.compileDirectory(root, anchor, bundleVersion, isPublicModuleAnchor(anchor)) +} + +// CompileReleasedPublicDirectory compiles the portable policy inputs extracted +// from a verified public release. Those inputs deliberately omit an estate's +// owner register. Explicit owner selectors remain unresolvable and are rejected; +// this does not permit a development estate to lose its owner register. +func (compiler *Compiler) CompileReleasedPublicDirectory( + root string, + anchor domain.RepositoryAnchor, + bundleVersion string, +) CompileResult { + if anchor.Classification.VisibilityContract != "public" { + return CompileResult{Findings: []domain.Finding{{ + Code: "GDS_POLICY_RELEASE_TARGET_INVALID", Severity: domain.SeverityHigh, + Message: "Portable released policy requires a public target repository.", + }}} + } + return compiler.compileDirectory(root, anchor, bundleVersion, true) +} + +func (compiler *Compiler) compileDirectory( + root string, + anchor domain.RepositoryAnchor, + bundleVersion string, + allowMissingOwnerRegister bool, ) CompileResult { sources, findings := compiler.loader.Load(root) if len(findings) != 0 { @@ -63,7 +90,7 @@ func (compiler *Compiler) CompileDirectory( if len(ownerFindings) != 0 { ownerDirectory := filepath.Join(root, "estate", "owners") _, ownerErr := os.Stat(ownerDirectory) - if !(isPublicModuleAnchor(anchor) && os.IsNotExist(ownerErr) && + if !(allowMissingOwnerRegister && os.IsNotExist(ownerErr) && allFindingCodes(ownerFindings, "GDS_POLICY_OWNER_REGISTER_UNAVAILABLE")) { return CompileResult{Findings: ownerFindings} } diff --git a/core/compiler/released_public_directory_test.go b/core/compiler/released_public_directory_test.go new file mode 100644 index 0000000..ea805c6 --- /dev/null +++ b/core/compiler/released_public_directory_test.go @@ -0,0 +1,86 @@ +package compiler + +import ( + "os" + "path/filepath" + "testing" +) + +func releasedPolicyDirectory(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "policies", "base"), 0o755); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), "policies", "base", "repository-default.yaml")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "policies", "base", "repository-default.yaml"), raw, 0o644); err != nil { + t.Fatal(err) + } + return root +} + +func TestReleasedPublicProjectCompilesWithoutEstateOwners(t *testing.T) { + anchor := testAnchor("repository-default") + anchor.Classification.VisibilityContract = "public" + anchor.Repository.Roles = []string{"project"} + result := New(testSchemas(t)).CompileReleasedPublicDirectory(releasedPolicyDirectory(t), anchor, "1.0.0") + if len(result.Findings) != 0 { + t.Fatalf("released public project: %#v", result.Findings) + } +} + +func TestDevelopmentPublicProjectStillRequiresEstateOwners(t *testing.T) { + anchor := testAnchor("repository-default") + anchor.Classification.VisibilityContract = "public" + anchor.Repository.Roles = []string{"project"} + result := New(testSchemas(t)).CompileDirectory(releasedPolicyDirectory(t), anchor, DevelopmentBundleVersion) + assertFinding(t, result.Findings, "GDS_POLICY_OWNER_REGISTER_UNAVAILABLE") +} + +func TestReleasedDirectoryRejectsPrivateTarget(t *testing.T) { + anchor := testAnchor("repository-default") + anchor.Classification.VisibilityContract = "private" + result := New(testSchemas(t)).CompileReleasedPublicDirectory(releasedPolicyDirectory(t), anchor, "1.0.0") + assertFinding(t, result.Findings, "GDS_POLICY_RELEASE_TARGET_INVALID") +} + +func TestReleasedDirectoryRejectsCorruptExistingOwnerRegister(t *testing.T) { + root := releasedPolicyDirectory(t) + directory := filepath.Join(root, "estate", "owners") + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "invalid.yaml"), []byte("owner: ["), 0o644); err != nil { + t.Fatal(err) + } + anchor := testAnchor("repository-default") + anchor.Classification.VisibilityContract = "public" + result := New(testSchemas(t)).CompileReleasedPublicDirectory(root, anchor, "1.0.0") + assertFinding(t, result.Findings, "GDS_POLICY_OWNER_REGISTER_UNAVAILABLE") +} + +func TestReleasedDirectoryDoesNotInventAnOwnerIdentity(t *testing.T) { + root := releasedPolicyDirectory(t) + policy := `schema_version: 1 +policy: + id: owner-selected + tier: owner + priority: 100 + distribution: public +match: + owner: owner:example-declared +apply: + rollout: + mode: pull-request +` + if err := os.WriteFile(filepath.Join(root, "policies", "owner-selected.yaml"), []byte(policy), 0o644); err != nil { + t.Fatal(err) + } + anchor := testAnchor("repository-default", "owner-selected") + anchor.Classification.VisibilityContract = "public" + result := New(testSchemas(t)).CompileReleasedPublicDirectory(root, anchor, "1.0.0") + assertFinding(t, result.Findings, "GDS_POLICY_PROFILE_NOT_APPLICABLE") +} diff --git a/docs/contracts/projections-v1.md b/docs/contracts/projections-v1.md index 5049efb..f349ef7 100644 --- a/docs/contracts/projections-v1.md +++ b/docs/contracts/projections-v1.md @@ -91,6 +91,12 @@ content-set digest and attestation identity. Plan/apply/verify require the same two immutable input paths so precondition re-observation cannot change source. Private targets cannot use this standalone boundary. +Portable released compilation does not require the private estate owner +register. Profiles without an owner selector can apply; an explicit owner +selector remains unresolvable and is rejected rather than deriving an identity +from the GitHub login. A development public project still requires its estate +owner register, and a corrupt existing register is always rejected. + ## Manual drift Verification uses `lstat`, rejects symlinks and non-regular files, confines