From cfe6281cb688e2050484562b8dff1ba3e9403dca Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 7 Sep 2026 18:20:17 +0500 Subject: [PATCH] feat: verify version artifacts in module consumer transactions Signed-off-by: rldyourmnd --- core/app/module_consumers.go | 3 + core/app/module_pin.go | 135 +++++---- core/app/module_pin_artifact.go | 154 ++++++++++ core/cli/module_consumers_test.go | 30 ++ core/cli/module_pin_artifact_test.go | 281 ++++++++++++++++++ core/cli/root.go | 8 +- core/providers/git/tag_read.go | 56 ++++ core/providers/git/tag_read_test.go | 35 +++ core/providers/github/mutation_release.go | 7 +- core/providers/github/release_observation.go | 10 +- docs/contracts/lifecycles-v1.md | 39 ++- schemas/v1/plan.schema.json | 140 ++++++++- .../gds-update-consumer-pins/SKILL.md | 18 +- 13 files changed, 846 insertions(+), 70 deletions(-) create mode 100644 core/app/module_pin_artifact.go create mode 100644 core/cli/module_pin_artifact_test.go create mode 100644 core/providers/git/tag_read.go create mode 100644 core/providers/git/tag_read_test.go diff --git a/core/app/module_consumers.go b/core/app/module_consumers.go index 9a3c85c..a13ac6d 100644 --- a/core/app/module_consumers.go +++ b/core/app/module_consumers.go @@ -12,6 +12,8 @@ import ( type ModuleConsumerPlanOptions struct { ProjectionOperationOptions + Version string + RuntimeConfig string ModulePath string InventoryRoot string MaxDepth int @@ -152,6 +154,7 @@ func (services *Services) PlanModuleConsumerUpdates( envelope := services.PlanModuleUpdatePin(ctx, path, ModulePinOptions{ ProjectionOperationOptions: options.ProjectionOperationOptions, ModulePath: moduleInfo.WorktreeRoot, GitmodulesName: name, + Version: options.Version, RuntimeConfig: options.RuntimeConfig, }) result := ModuleConsumerSubplan{ ConsumerID: consumerID, Mode: mode, Path: path, diff --git a/core/app/module_pin.go b/core/app/module_pin.go index b857943..c17b88f 100644 --- a/core/app/module_pin.go +++ b/core/app/module_pin.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/json" "errors" "path/filepath" "strings" @@ -22,18 +23,21 @@ type ModulePinOptions struct { ProjectionOperationOptions ModulePath string GitmodulesName string + Version string + RuntimeConfig string } type ModulePinAssessment struct { - ConsumerID string `json:"consumer_id"` - ModuleID string `json:"module_id"` - ConsumerRoot string `json:"consumer_root"` - ModuleRoot string `json:"module_root"` - GitmodulesName string `json:"gitmodules_name"` - GitlinkPath string `json:"gitlink_path"` - ExpectedOldOID string `json:"expected_old_oid"` - TargetOID string `json:"target_oid"` - TargetRef string `json:"target_ref"` + ConsumerID string `json:"consumer_id"` + ModuleID string `json:"module_id"` + ConsumerRoot string `json:"consumer_root"` + ModuleRoot string `json:"module_root"` + GitmodulesName string `json:"gitmodules_name"` + GitlinkPath string `json:"gitlink_path"` + ExpectedOldOID string `json:"expected_old_oid"` + TargetOID string `json:"target_oid"` + TargetRef string `json:"target_ref"` + Artifact *ModulePinArtifact `json:"artifact,omitempty"` } type ModulePinPlanData struct { @@ -48,10 +52,12 @@ type modulePinContext struct { } type modulePinObserver struct { - services *Services - consumer string - module string - name string + services *Services + consumer string + module string + name string + version string + runtimeConfig string } func (observer modulePinObserver) Observe( @@ -59,7 +65,7 @@ func (observer modulePinObserver) Observe( repositoryID string, ) (operations.Observation, error) { current, findings := observer.services.modulePinContext( - ctx, observer.consumer, observer.module, observer.name, + ctx, observer.consumer, observer.module, observer.name, observer.version, observer.runtimeConfig, ) if len(findings) != 0 || current.assessment.ConsumerID != repositoryID { return operations.Observation{}, errors.New("module pin precondition is no longer proven") @@ -75,7 +81,7 @@ func (services *Services) PlanModuleUpdatePin( if finding := validateLocalOperationIdentity(options.ProjectionOperationOptions); finding != nil { return domain.NewEnvelope("gds module update-pin plan", domain.ExitInput, nil, *finding) } - current, findings := services.modulePinContext(ctx, path, options.ModulePath, options.GitmodulesName) + current, findings := services.modulePinContext(ctx, path, options.ModulePath, options.GitmodulesName, options.Version, options.RuntimeConfig) if len(findings) != 0 { return domain.NewEnvelope( "gds module update-pin plan", classifyFindings(findings), nil, findings..., @@ -111,15 +117,7 @@ func (services *Services) PlanModuleUpdatePin( // this repository: provider lifecycle, rulesets, releases and anchors. Action: gitops.UpdateGitlinkAction, RequiresApproval: false, Compensation: operations.Compensation{Mode: "explicit-plan", Action: gitops.UpdateGitlinkAction}, - Parameters: map[string]any{"gitlink_pin": map[string]any{ - "consumer_root": current.assessment.ConsumerRoot, - "module_root": current.assessment.ModuleRoot, - "module_id": current.assessment.ModuleID, - "gitmodules_name": current.assessment.GitmodulesName, - "expected_old_oid": current.assessment.ExpectedOldOID, - "target_oid": current.assessment.TargetOID, - "target_ref": current.assessment.TargetRef, - }}, + Parameters: map[string]any{"gitlink_pin": modulePinParameters(current.assessment)}, }}, ApprovalClass: "update-module-gitlink-pin", }) @@ -128,7 +126,7 @@ func (services *Services) PlanModuleUpdatePin( } engine := operations.NewDefaultEngine( store, services.Schemas, - modulePinObserver{services: services, consumer: current.assessment.ConsumerRoot, module: current.assessment.ModuleRoot, name: current.assessment.GitmodulesName}, + modulePinObserver{services: services, consumer: current.assessment.ConsumerRoot, module: current.assessment.ModuleRoot, name: current.assessment.GitmodulesName, version: options.Version, runtimeConfig: options.RuntimeConfig}, nil, options.DeviceID, options.SessionID, ) engine.Now = services.Now @@ -173,8 +171,8 @@ func (services *Services) ApplyModuleUpdatePin( } engine := operations.NewDefaultEngine( store, services.Schemas, - modulePinObserver{services: services, consumer: assessment.ConsumerRoot, module: assessment.ModuleRoot, name: assessment.GitmodulesName}, - map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: handler}, + modulePinObserver{services: services, consumer: assessment.ConsumerRoot, module: assessment.ModuleRoot, name: assessment.GitmodulesName, version: modulePinVersion(assessment), runtimeConfig: options.RuntimeConfig}, + map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: services.modulePinHandler(handler, assessment, plan, options.RuntimeConfig)}, options.DeviceID, options.SessionID, ) engine.Now = services.Now @@ -225,7 +223,7 @@ func (services *Services) VerifyModuleUpdatePin( } engine := operations.NewDefaultEngine( store, services.Schemas, modulePinObserver{}, - map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: handler}, + map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: services.modulePinHandler(handler, assessment, plan, options.RuntimeConfig)}, options.DeviceID, options.SessionID, ) engine.Now = services.Now @@ -247,6 +245,8 @@ func (services *Services) modulePinContext( consumerPath string, modulePath string, gitmodulesName string, + version string, + runtimeConfig string, ) (modulePinContext, []domain.Finding) { if strings.TrimSpace(modulePath) == "" || strings.TrimSpace(gitmodulesName) == "" { return modulePinContext{}, []domain.Finding{modulePinFinding( @@ -306,10 +306,11 @@ func (services *Services) modulePinContext( "GDS_MODULE_PIN_IDENTITY_MISMATCH", "Selected module boundary does not match the typed consumer relationship.", )} } - if moduleAnchor.Module.PinPolicy != "default-branch-commit" { - return modulePinContext{}, []domain.Finding{modulePinFinding( - "GDS_MODULE_PIN_RELEASE_REQUIRED", "This module pin policy requires a verified versioned release before consumer update.", - )} + if moduleAnchor.Module.PinPolicy != "default-branch-commit" && moduleAnchor.Module.PinPolicy != "version-tag" { + return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_RELEASE_REQUIRED", "Module pin policy requires an unsupported publication provider.")} + } + if (moduleAnchor.Module.PinPolicy == "version-tag") != (strings.TrimSpace(version) != "") { + return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_VERSION_REQUIRED", "Select --version exactly when the module pin policy is version-tag.")} } moduleInfo, err := services.Git.RepositoryInfo(ctx, modulePath) if err != nil { @@ -317,26 +318,27 @@ func (services *Services) modulePinContext( } moduleRoot := moduleInfo.WorktreeRoot moduleStatus, err := services.Git.InspectStatus(ctx, moduleRoot) - if err != nil || moduleStatus.Head.Mode != "branch" || - moduleStatus.Branch.Name != moduleAnchor.Git.DefaultBranch || !checkoutStatusIsClean(moduleStatus) { - return modulePinContext{}, []domain.Finding{modulePinFinding( - "GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean on its default branch.", - )} + if err != nil || !checkoutStatusIsClean(moduleStatus) || moduleStatus.Head.OID == "" { + return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean at the selected commit.")} } - // The module's origin is observed, never written. `LocalPushSupported` used - // to guard this line, which asks whether the module's remote accepts a push - // from this device -- a question this operation never needs, since the only - // mutation is a gitlink rewrite in the consumer. It refuses every remote that - // is not a local path, so on a real estate it refused every module, and the - // pin could not advance for that reason alone. `ObserveRemoteBranchOptional` - // is an `ls-remote`, and proving the target commit is published is exactly - // what this step is for. targetRef := "refs/heads/" + moduleAnchor.Git.DefaultBranch - targetOID, found, err := services.GitMutations.ObserveRemoteBranchOptional(ctx, moduleRoot, "origin", targetRef) - if err != nil || !found || targetOID != moduleStatus.Head.OID { - return modulePinContext{}, []domain.Finding{modulePinFinding( - "GDS_MODULE_PIN_TARGET_NOT_PUBLISHED", "Module default commit is not exactly published on its configured origin.", - )} + targetOID := "" + var artifact *ModulePinArtifact + if moduleAnchor.Module.PinPolicy == "version-tag" { + artifact, err = services.observeModulePinArtifact(ctx, moduleRoot, version, runtimeConfig) + if err != nil { + return modulePinContext{}, []domain.Finding{modulePinArtifactFinding(err)} + } + targetRef, targetOID = artifact.Tag.TagRef, artifact.Tag.CommitOID + } else { + if moduleStatus.Head.Mode != "branch" || moduleStatus.Branch.Name != moduleAnchor.Git.DefaultBranch { + return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean on its default branch.")} + } + var found bool + targetOID, found, err = services.GitMutations.ObserveRemoteBranchOptional(ctx, moduleRoot, "origin", targetRef) + if err != nil || !found || targetOID != moduleStatus.Head.OID { + return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_TARGET_NOT_PUBLISHED", "Module default commit is not exactly published on its configured origin.")} + } } // Resolve cheap eligibility and policy failures before materializing a // throwaway checkout or invoking any module command. Rejected pins must @@ -401,8 +403,9 @@ func (services *Services) modulePinContext( // Verification joins the fingerprint so the plan is bound to the evidence // that justified it. A plan approved while a lane passed must not stay // applicable after that lane stops passing. - Verification string `json:"verification"` - }{consumerStatus, *submodule, moduleAnchor.Repository.ID, moduleStatus.Head.OID, moduleManifestDigest, targetRef, targetOID, verificationDigest}) + Verification string `json:"verification"` + Artifact *ModulePinArtifact `json:"artifact,omitempty"` + }{consumerStatus, *submodule, moduleAnchor.Repository.ID, moduleStatus.Head.OID, moduleManifestDigest, targetRef, targetOID, verificationDigest, artifact}) if err != nil { return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_FINGERPRINT_FAILED", err.Error())} } @@ -411,7 +414,7 @@ func (services *Services) modulePinContext( ConsumerID: consumer.Repository.ID, ModuleID: moduleAnchor.Repository.ID, ConsumerRoot: consumerInfo.WorktreeRoot, ModuleRoot: moduleRoot, GitmodulesName: gitmodulesName, GitlinkPath: submodule.Path, - ExpectedOldOID: submodule.GitlinkOID, TargetOID: targetOID, TargetRef: targetRef, + ExpectedOldOID: submodule.GitlinkOID, TargetOID: targetOID, TargetRef: targetRef, Artifact: artifact, }, observation: operations.Observation{ RepositoryID: consumer.Repository.ID, HeadOID: consumerStatus.Head.OID, @@ -468,6 +471,19 @@ func loadModulePinPlan( assessment.ExpectedOldOID, _ = raw["expected_old_oid"].(string) assessment.TargetOID, _ = raw["target_oid"].(string) assessment.TargetRef, _ = raw["target_ref"].(string) + if raw["artifact"] != nil { + encoded, marshalErr := json.Marshal(raw["artifact"]) + if marshalErr != nil || json.Unmarshal(encoded, &assessment.Artifact) != nil || + assessment.Artifact == nil || assessment.Artifact.Version == "" || + assessment.Artifact.Tag.TagRef != assessment.TargetRef || + assessment.Artifact.Tag.CommitOID != assessment.TargetOID || + assessment.Artifact.Tag.TagOID == "" || assessment.Artifact.ManifestDigest == "" { + return operations.Plan{}, ModulePinAssessment{}, errors.New("module artifact parameters are invalid") + } + } + if strings.HasPrefix(assessment.TargetRef, "refs/tags/") && assessment.Artifact == nil { + return operations.Plan{}, ModulePinAssessment{}, errors.New("version pin plan lacks artifact evidence") + } if assessment.ConsumerRoot == "" || assessment.ModuleRoot == "" || assessment.ModuleID == "" || assessment.GitmodulesName == "" || assessment.ExpectedOldOID == "" || assessment.TargetOID == "" || assessment.TargetRef == "" { @@ -535,3 +551,16 @@ func pinWorktreeStateIsEligible(submodule gitprovider.Submodule, targetOID strin return false } } + +func modulePinParameters(assessment ModulePinAssessment) map[string]any { + result := map[string]any{ + "consumer_root": assessment.ConsumerRoot, "module_root": assessment.ModuleRoot, + "module_id": assessment.ModuleID, "gitmodules_name": assessment.GitmodulesName, + "expected_old_oid": assessment.ExpectedOldOID, "target_oid": assessment.TargetOID, + "target_ref": assessment.TargetRef, + } + if assessment.Artifact != nil { + result["artifact"] = assessment.Artifact + } + return result +} diff --git a/core/app/module_pin_artifact.go b/core/app/module_pin_artifact.go new file mode 100644 index 0000000..909bffc --- /dev/null +++ b/core/app/module_pin_artifact.go @@ -0,0 +1,154 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "path/filepath" + "sort" + "strings" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/canonicaljson" + "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/estate" + "github.com/NDDev-OpenNetwork/github-device-sync/core/githubruntime" + "github.com/NDDev-OpenNetwork/github-device-sync/core/operations" + gitprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/git" + githubprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/github" +) + +// ModulePinArtifact records publication identity, not mutable release prose or +// observation timestamps. Every field participates in the plan precondition. +type ModulePinArtifact struct { + Version string `json:"version"` + Tag gitprovider.VersionArtifact `json:"tag"` + ManifestDigest string `json:"manifest_digest"` + ReleaseID int64 `json:"release_id,omitempty"` + ReleaseImmutable bool `json:"release_immutable,omitempty"` + ReleasePrerelease bool `json:"release_prerelease,omitempty"` + Assets []githubprovider.ReleaseAsset `json:"assets,omitempty"` +} + +func (services *Services) observeModulePinArtifact(ctx context.Context, moduleRoot, version, runtimeConfig string) (*ModulePinArtifact, error) { + estateRoot, anchor, findings := services.policyInputs(ctx, moduleRoot) + if len(findings) != 0 || anchor.Module == nil || anchor.Module.PinPolicy != "version-tag" || !hasRole(anchor.Repository.Roles, "module") { + return nil, errors.New("version artifact module policy is not proven") + } + tagRef, err := gitprovider.VersionTagRefWithStyle(version, anchor.Release.TagStyle) + if err != nil { + return nil, err + } + tag, err := services.GitMutations.ObserveVersionArtifact(ctx, moduleRoot, tagRef) + if err != nil { + return nil, err + } + status, err := services.Git.InspectStatus(ctx, moduleRoot) + if err != nil || !checkoutStatusIsClean(status) || status.Head.OID != tag.CommitOID { + return nil, errors.New("module checkout must be clean at the selected published version commit") + } + manifest, err := fileDigest(filepath.Join(moduleRoot, ".gds", "repository.yaml")) + if err != nil { + return nil, err + } + artifact := &ModulePinArtifact{Version: version, Tag: tag, ManifestDigest: manifest} + if anchor.Module.Publication.GitHubRelease != "required" && anchor.Release.Mode != "github-release" { + return artifact, nil + } + desired, findings := estate.Load(estateRoot, services.Schemas) + if len(findings) != 0 { + return nil, errors.New("version artifact estate is not proven") + } + config, err := githubruntime.Load(runtimeConfig, desired, services.Schemas) + if err != nil { + return nil, err + } + readers, err := githubruntime.BuildReaders(config, desired, services.GitHubRuntimeBuildOptions) + if err != nil { + return nil, err + } + reader, found := readers[anchor.Provider.Installation] + if !found { + return nil, errors.New("module installation has no configured GitHub reader") + } + repository, _, _, err := reader.GetRepository(ctx, anchor.Provider.Owner, anchor.Provider.Name, "") + if err != nil || repository.ID != anchor.Provider.RepositoryID { + return nil, errors.New("version artifact repository identity is not proven") + } + tagName := strings.TrimPrefix(tagRef, "refs/tags/") + providerTag, _, found, err := reader.GetVersionTagRefOptional(ctx, anchor.Provider.Owner, anchor.Provider.Name, tagName) + if err != nil || !found || providerTag.SHA != tag.TagOID { + return nil, errors.New("GitHub tag does not match the selected origin artifact") + } + release, err := reader.GetReleaseByTag(ctx, anchor.Provider.Owner, anchor.Provider.Name, tagName) + if err != nil || release.Draft { + return nil, errors.New("selected artifact requires a published GitHub release") + } + assets, err := reader.ListReleaseAssets(ctx, anchor.Provider.Owner, anchor.Provider.Name, release.ID) + if err != nil || len(assets) == 0 { + return nil, errors.New("selected release requires a complete digest-bearing asset inventory") + } + sort.Slice(assets, func(i, j int) bool { return assets[i].Name < assets[j].Name }) + assetIDs := map[int64]bool{} + for i, asset := range assets { + parsed, parseErr := url.Parse(asset.BrowserDownloadURL) + expectedPath := "/" + anchor.Provider.Owner + "/" + anchor.Provider.Name + "/releases/download/" + tagName + "/" + asset.Name + if (i > 0 && assets[i-1].Name == asset.Name) || assetIDs[asset.ID] || parseErr != nil || parsed.Path != expectedPath { + return nil, errors.New("release asset identity is ambiguous or belongs to another tag") + } + assetIDs[asset.ID] = true + } + artifact.ReleaseID, artifact.ReleaseImmutable, artifact.ReleasePrerelease, artifact.Assets = release.ID, release.Immutable, release.Prerelease, assets + return artifact, nil +} + +// Artifact verification is part of the journaled handler: both immediate +// postconditions and later explicit verify re-read the selected publication. +type modulePinArtifactHandler struct { + operations.ActionHandler + services *Services + assessment ModulePinAssessment + consumerManifest string + runtimeConfig string +} + +func (handler modulePinArtifactHandler) Verify(ctx context.Context, step operations.Step, after json.RawMessage) error { + if err := handler.ActionHandler.Verify(ctx, step, after); err != nil { + return err + } + manifest, err := fileDigest(filepath.Join(handler.assessment.ConsumerRoot, ".gds", "repository.yaml")) + if err != nil || manifest != handler.consumerManifest { + return errors.New("consumer relationship manifest changed after the artifact plan") + } + current, err := handler.services.observeModulePinArtifact(ctx, handler.assessment.ModuleRoot, handler.assessment.Artifact.Version, handler.runtimeConfig) + if err != nil { + return err + } + want, err := canonicaljson.Digest(handler.assessment.Artifact) + if err != nil { + return err + } + got, err := canonicaljson.Digest(current) + if err != nil || got != want { + return errors.New("version artifact no longer matches the immutable pin plan") + } + return nil +} + +func modulePinArtifactFinding(err error) domain.Finding { + return modulePinFinding("GDS_MODULE_PIN_ARTIFACT_NOT_PROVEN", err.Error()) +} + +func modulePinVersion(assessment ModulePinAssessment) string { + if assessment.Artifact == nil { + return "" + } + return assessment.Artifact.Version +} + +func (services *Services) modulePinHandler(handler operations.ActionHandler, assessment ModulePinAssessment, plan operations.Plan, runtimeConfig string) operations.ActionHandler { + if assessment.Artifact == nil { + return handler + } + return modulePinArtifactHandler{ActionHandler: handler, services: services, assessment: assessment, consumerManifest: plan.Preconditions[0].ManifestDigest, runtimeConfig: runtimeConfig} +} diff --git a/core/cli/module_consumers_test.go b/core/cli/module_consumers_test.go index 41c9acf..03003a2 100644 --- a/core/cli/module_consumers_test.go +++ b/core/cli/module_consumers_test.go @@ -165,3 +165,33 @@ func moduleConsumerFixture( } return consumer } + +func TestModuleConsumerPlanningForwardsVersionArtifact(t *testing.T) { + root := t.TempDir() + moduleRoot, _, oldOID := moduleConsumerModuleFixture(t, root) + anchorPath := filepath.Join(moduleRoot, ".gds/repository.yaml") + raw, err := os.ReadFile(anchorPath) + if err != nil { + t.Fatal(err) + } + source := strings.Replace(string(raw), `pin_policy: "default-branch-commit"`, `pin_policy: "version-tag"`, 1) + source = strings.Replace(source, `github_release: "required"`, `github_release: "optional"`, 1) + source = strings.Replace(source, `mode: "none"`, `mode: "version-tag"`, 1) + if err := os.WriteFile(anchorPath, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + runSessionGit(t, moduleRoot, "commit", "-qam", "versioned module") + runSessionGit(t, moduleRoot, "tag", "v1.2.3") + runSessionGit(t, moduleRoot, "push", "-q", "origin", "main", "refs/tags/v1.2.3") + const consumerID = "repo_01JEXAMPZ0000000000000000D" + moduleConsumerFixture(t, root, "git-consumer", consumerID, 223456789, "git-submodule-consumer", oldOID) + t.Setenv("GDS_ESTATE_ROOT", testEstateRoot(t)) + code, result, stderr := executeJSON(t, "--json", "module", "update-consumers", "--plan", "--module", moduleRoot, "--inventory-root", root, "--consumer-id", consumerID, "--version", "1.2.3", "--state-path", sessionStatePath(t), "--device-id", syncTestDeviceID, "--session-id", "version-consumers") + if code != 0 { + t.Fatalf("version consumers=%#v stderr=%s", result, stderr) + } + data, _ := result.Data.(map[string]any) + if data["planned"] != float64(1) || data["blocked"] != float64(0) { + t.Fatalf("versioned subplan not created: %#v", data) + } +} diff --git a/core/cli/module_pin_artifact_test.go b/core/cli/module_pin_artifact_test.go new file mode 100644 index 0000000..2be3664 --- /dev/null +++ b/core/cli/module_pin_artifact_test.go @@ -0,0 +1,281 @@ +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/app" + "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/githubruntime" +) + +type versionPinFixture struct { + module, consumer sessionFixtureState + target, tagOID, state string + services *app.Services + runtime string +} + +func newVersionPinFixture(t *testing.T, publication bool) versionPinFixture { + t.Helper() + module := sessionFixtureWithPolicies(t, "never", "direct", false) + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), "tests/fixtures/schemas/v1/valid-module-fork-repository.yaml")) + if err != nil { + t.Fatal(err) + } + const moduleID = "repo_01JEXAMPZ0000000000000000D" + source := strings.Replace(string(raw), "repo_01JEXAMPZ0000000000000000C", moduleID, 1) + if !publication { + source = strings.Replace(source, `github_release: "required"`, `github_release: "optional"`, 1) + } + source += "\nverification:\n commands:\n compatibility:\n - \"git diff --exit-code\"\n required:\n - \"compatibility\"\n" + if err := os.WriteFile(filepath.Join(module.client, ".gds/repository.yaml"), []byte(source), 0o644); err != nil { + t.Fatal(err) + } + runSessionGit(t, module.client, "add", ".gds/repository.yaml") + runSessionGit(t, module.client, "commit", "-qm", "versioned module contract") + runSessionGit(t, module.client, "tag", "-a", "v1.2.3", "-m", "artifact one") + runSessionGit(t, module.client, "push", "-q", "origin", "main", "refs/tags/v1.2.3") + target := runSessionGit(t, module.client, "rev-parse", "HEAD") + tagOID := runSessionGit(t, module.client, "rev-parse", "refs/tags/v1.2.3") + runSessionGit(t, module.client, "checkout", "-q", "--detach", "v1.2.3") + consumer := sessionFixtureWithPolicies(t, "never", "direct", false) + anchorPath := filepath.Join(consumer.client, ".gds/repository.yaml") + raw, err = os.ReadFile(anchorPath) + if err != nil { + t.Fatal(err) + } + source = strings.Replace(string(raw), "\nrelease:\n", "\nrelationships:\n - type: \"git-submodule-consumer\"\n target: \""+moduleID+"\"\n gitmodules_name: \"module\"\n\nrelease:\n", 1) + if err := os.WriteFile(anchorPath, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(consumer.client, ".gitmodules"), []byte("[submodule \"module\"]\n path = modules/module\n url = https://github.com/example-org/public-module-fork.git\n"), 0o644); err != nil { + t.Fatal(err) + } + runSessionGit(t, consumer.client, "add", ".gds/repository.yaml", ".gitmodules") + runSessionGit(t, consumer.client, "update-index", "--add", "--cacheinfo", "160000,"+module.firstOID+",modules/module") + runSessionGit(t, consumer.client, "commit", "-qm", "typed consumer") + runSessionGit(t, consumer.client, "switch", "-qc", "task/version-pin") + if err := os.MkdirAll(filepath.Join(consumer.client, "modules/module"), 0o755); err != nil { + t.Fatal(err) + } + services, err := app.NewServices(app.DefaultClock) + if err != nil { + t.Fatal(err) + } + t.Setenv("GDS_ESTATE_ROOT", testEstateRoot(t)) + return versionPinFixture{module: module, consumer: consumer, target: target, tagOID: tagOID, state: sessionStatePath(t), services: services} +} + +func (f versionPinFixture) command(t *testing.T, mode, id string, extra ...string) (int, domain.Envelope, string) { + t.Helper() + args := []string{"--json", "--cwd", f.consumer.client, "module", "update-pin", mode} + if mode == "--plan" { + args = append(args, "--module", f.module.client, "--name", "module", "--version", "1.2.3") + } else { + args = append(args, id) + } + args = append(args, "--state-path", f.state, "--device-id", syncTestDeviceID, "--session-id", "version-artifact") + if f.runtime != "" { + args = append(args, "--runtime-config", f.runtime) + } + return executeJSONWithServices(t, f.services, append(args, extra...)...) +} + +func TestVersionArtifactPinLifecycle(t *testing.T) { + f := newVersionPinFixture(t, false) + // A published version remains selectable after origin/main advances. + runSessionGit(t, f.module.client, "switch", "-q", "main") + runSessionGit(t, f.module.client, "commit", "--allow-empty", "-qm", "later development") + runSessionGit(t, f.module.client, "push", "-q", "origin", "main") + runSessionGit(t, f.module.client, "checkout", "-q", "--detach", "v1.2.3") + code, plan, stderr := f.command(t, "--plan", "") + if code != 0 { + t.Fatalf("plan=%#v stderr=%s", plan, stderr) + } + code, applied, stderr := f.command(t, "--apply", syncPlanID(t, plan.Data)) + if code != 0 || !applied.Mutation.Completed { + t.Fatalf("apply=%#v stderr=%s", applied, stderr) + } + if diff := runSessionGit(t, f.consumer.client, "diff", "--cached", "--name-only"); diff != "modules/module" { + t.Fatalf("unexpected mutation %q", diff) + } + if pinned := runSessionGit(t, f.consumer.client, "rev-parse", ":modules/module"); pinned != f.target { + t.Fatalf("pinned %s instead of selected version %s", pinned, f.target) + } + code, verified, stderr := f.command(t, "--verify", applied.OperationID) + if code != 0 { + t.Fatalf("verify=%#v stderr=%s", verified, stderr) + } + // Retag at the same commit: peeled SHA alone would wrongly verify this. + runSessionGit(t, f.module.client, "tag", "-f", "-a", "v1.2.3", "-m", "replacement artifact") + runSessionGit(t, f.module.client, "push", "-q", "--force", "origin", "refs/tags/v1.2.3") + code, verified, _ = f.command(t, "--verify", applied.OperationID) + if code == 0 || verified.Mutation.Attempted { + t.Fatalf("retag accepted by verify: %#v", verified) + } +} + +func TestVersionArtifactPinRefusesStaleAndUnsafePlans(t *testing.T) { + for _, scenario := range []string{"moved-tag", "missing-tag", "dirty-module", "dirty-consumer", "changed-consumer-head", "version-override"} { + t.Run(scenario, func(t *testing.T) { + f := newVersionPinFixture(t, false) + code, plan, stderr := f.command(t, "--plan", "") + if code != 0 { + t.Fatalf("plan=%#v stderr=%s", plan, stderr) + } + extra := []string{} + switch scenario { + case "moved-tag": + runSessionGit(t, f.module.client, "tag", "-f", "-a", "v1.2.3", "-m", "changed object") + runSessionGit(t, f.module.client, "push", "-q", "--force", "origin", "refs/tags/v1.2.3") + case "missing-tag": + runSessionGit(t, f.module.client, "push", "-q", "origin", ":refs/tags/v1.2.3") + case "dirty-module": + if err := os.WriteFile(filepath.Join(f.module.client, "unrelated.txt"), []byte("dirty"), 0o644); err != nil { + t.Fatal(err) + } + case "dirty-consumer": + if err := os.WriteFile(filepath.Join(f.consumer.client, "unrelated.txt"), []byte("dirty"), 0o644); err != nil { + t.Fatal(err) + } + case "changed-consumer-head": + runSessionGit(t, f.consumer.client, "commit", "--allow-empty", "-qm", "unrelated new head") + case "version-override": + extra = []string{"--version", "2.0.0"} + } + code, result, _ := f.command(t, "--apply", syncPlanID(t, plan.Data), extra...) + if code == 0 || result.Mutation.Completed { + t.Fatalf("unsafe apply accepted: %#v", result) + } + if diff := runSessionGit(t, f.consumer.client, "diff", "--cached", "--name-only"); diff != "" { + t.Fatalf("refusal modified index: %q", diff) + } + }) + } +} + +func TestVersionArtifactPinBindsPublishedReleaseAssets(t *testing.T) { + f := newVersionPinFixture(t, true) + // Reuse the read-only runtime credential fixture; replace its transport only. + f.services, f.runtime = moduleReleaseReadServices(t) + var mode atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" && !strings.HasPrefix(r.URL.Path, "/app/installations/") { + t.Errorf("unexpected mutation %s %s", r.Method, r.URL.Path) + w.WriteHeader(405) + return + } + switch r.URL.Path { + case "/app/installations/900001/access_tokens": + fmt.Fprint(w, `{"token":"ghs_read","expires_at":"2099-01-01T00:00:00Z","permissions":{"actions":"read","administration":"read","checks":"read","contents":"read","metadata":"read","pull_requests":"read"},"repository_selection":"all"}`) + case "/repos/example-org/public-module-fork": + fmt.Fprint(w, `{"id":123456789,"node_id":"R_fixture","name":"public-module-fork","full_name":"example-org/public-module-fork","private":false,"visibility":"public","fork":false,"archived":false,"disabled":false,"default_branch":"main","html_url":"https://github.com/example-org/public-module-fork","owner":{"login":"example-org"}}`) + case "/repos/example-org/public-module-fork/git/ref/tags/v1.2.3": + fmt.Fprintf(w, `{"ref":"refs/tags/v1.2.3","object":{"sha":%q,"type":"tag"}}`, f.tagOID) + case "/repos/example-org/public-module-fork/releases/tags/v1.2.3": + if mode.Load() == 1 { + http.NotFound(w, r) + return + } + fmt.Fprintf(w, `{"id":71,"node_id":"RE_fixture","tag_name":"v1.2.3","target_commitish":"main","name":"v1.2.3","body":"","html_url":"https://github.com/example-org/public-module-fork/releases/tag/v1.2.3","draft":%t,"prerelease":false,"immutable":true}`, mode.Load() == 2) + case "/repos/example-org/public-module-fork/releases/71/assets": + if mode.Load() == 3 { + fmt.Fprint(w, `[]`) + return + } + digest := strings.Repeat("a", 64) + if mode.Load() == 4 { + digest = strings.Repeat("b", 64) + } + if mode.Load() == 5 { + digest = "" + } + if mode.Load() == 6 { + w.Header().Set("Link", `; rel="next"`) + } + fmt.Fprintf(w, `[{"id":72,"name":"module.tar.gz","size":12,"state":"uploaded","browser_download_url":"https://github.com/example-org/public-module-fork/releases/download/v1.2.3/module.tar.gz","digest":"sha256:%s"}]`, digest) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := server.Client() + client.Timeout = 5 * time.Second + f.services.GitHubRuntimeBuildOptions = githubruntime.BuildOptions{BaseURL: server.URL + "/", HTTPClient: client, AllowInsecureLoopback: true} + for _, failure := range []int32{1, 2, 3, 5, 6} { + mode.Store(failure) + code, result, _ := f.command(t, "--plan", "") + if code == 0 { + t.Fatalf("publication failure %d accepted: %#v", failure, result) + } + } + mode.Store(0) + code, plan, stderr := f.command(t, "--plan", "") + if code != 0 { + t.Fatalf("valid release plan=%#v stderr=%s", plan, stderr) + } + mode.Store(4) + code, result, _ := f.command(t, "--apply", syncPlanID(t, plan.Data)) + if code == 0 || result.Mutation.Completed { + t.Fatalf("changed asset accepted: %#v", result) + } + mode.Store(0) + code, plan, stderr = f.command(t, "--plan", "") + if code != 0 { + t.Fatalf("fresh plan=%#v stderr=%s", plan, stderr) + } + code, applied, stderr := f.command(t, "--apply", syncPlanID(t, plan.Data)) + if code != 0 || !applied.Mutation.Completed { + t.Fatalf("apply=%#v stderr=%s", applied, stderr) + } + code, result, stderr = f.command(t, "--verify", applied.OperationID) + if code != 0 { + t.Fatalf("verify=%#v stderr=%s", result, stderr) + } + mode.Store(4) + code, result, _ = f.command(t, "--verify", applied.OperationID) + if code == 0 { + t.Fatalf("changed asset accepted by verify: %#v", result) + } +} + +func TestVersionArtifactPinRejectsUnselectedAndUnverifiedSources(t *testing.T) { + for _, scenario := range []string{"missing-version", "wrong-checkout", "failed-compatibility"} { + t.Run(scenario, func(t *testing.T) { + f := newVersionPinFixture(t, false) + extra := []string{} + switch scenario { + case "missing-version": + extra = []string{"--version", ""} + case "wrong-checkout": + runSessionGit(t, f.module.client, "switch", "-q", "main") + runSessionGit(t, f.module.client, "commit", "--allow-empty", "-qm", "not the released commit") + case "failed-compatibility": + path := filepath.Join(f.module.client, ".gds/repository.yaml") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + raw = []byte(strings.Replace(string(raw), "git diff --exit-code", "false", 1)) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + runSessionGit(t, f.module.client, "commit", "-qam", "failing compatibility contract") + runSessionGit(t, f.module.client, "tag", "-f", "-a", "v1.2.3", "-m", "incompatible version") + runSessionGit(t, f.module.client, "push", "-q", "--force", "origin", "refs/tags/v1.2.3") + } + code, result, _ := f.command(t, "--plan", "", extra...) + if code == 0 || result.Mutation.Attempted { + t.Fatalf("unverified source accepted: %#v", result) + } + }) + } +} diff --git a/core/cli/root.go b/core/cli/root.go index 0d39cec..1e20842 100644 --- a/core/cli/root.go +++ b/core/cli/root.go @@ -1775,7 +1775,7 @@ func (executor *executor) moduleConsumerPlanCommand() *cobra.Command { Use: "update-consumers", Short: "Plan independent updates for explicitly selected module consumers", Args: cobra.NoArgs, RunE: func(child *cobra.Command, _ []string) error { - return executor.run(child, func(ctx context.Context) domain.Envelope { + return executor.runLanes(child, func(ctx context.Context) domain.Envelope { if !plan { return domain.NewEnvelope("gds module update-consumers", domain.ExitInput, nil, domain.Finding{ Code: "GDS_MODULE_CONSUMER_PLAN_REQUIRED", Severity: domain.SeverityHigh, @@ -1796,6 +1796,8 @@ func (executor *executor) moduleConsumerPlanCommand() *cobra.Command { command.Flags().StringVar(&options.StatePath, "state-path", "", "local GDS state database path") command.Flags().StringVar(&options.DeviceID, "device-id", "", "canonical current device identity") command.Flags().StringVar(&options.SessionID, "session-id", "", "bounded non-secret session identity") + command.Flags().StringVar(&options.Version, "version", "", "exact SemVer artifact required for version-tag modules") + command.Flags().StringVar(&options.RuntimeConfig, "runtime-config", "", "private device-local GitHub read runtime for required publication") return command } @@ -1872,7 +1874,7 @@ func (executor *executor) modulePinCommand() *cobra.Command { Message: "Use exactly one of --plan, --apply, or --verify.", }) } - if !plan && (child.Flags().Changed("module") || child.Flags().Changed("name")) { + if !plan && (child.Flags().Changed("module") || child.Flags().Changed("name") || child.Flags().Changed("version")) { return domain.NewEnvelope("gds module update-pin", domain.ExitInput, nil, domain.Finding{ Code: "GDS_MODULE_PIN_INPUT_CONFLICT", Severity: domain.SeverityHigh, Message: "Module identity flags cannot alter a stored pin plan.", @@ -1898,6 +1900,8 @@ func (executor *executor) modulePinCommand() *cobra.Command { command.Flags().StringVar(&options.DeviceID, "device-id", "", "canonical current device identity") command.Flags().StringVar(&options.SessionID, "session-id", "", "bounded non-secret session identity") command.Flags().StringVar(&options.ApprovalReference, "approval-ref", "", "signed exact-plan approval JSON file") + command.Flags().StringVar(&options.Version, "version", "", "exact SemVer artifact required for version-tag modules; plan only") + command.Flags().StringVar(&options.RuntimeConfig, "runtime-config", "", "private device-local GitHub read runtime for required publication") return command } diff --git a/core/providers/git/tag_read.go b/core/providers/git/tag_read.go new file mode 100644 index 0000000..94223f9 --- /dev/null +++ b/core/providers/git/tag_read.go @@ -0,0 +1,56 @@ +package git + +import ( + "context" + "errors" + "strings" +) + +// VersionArtifact binds both the tag object and its peeled commit. Retagging an +// annotated tag at the same commit still changes the artifact's identity. +type VersionArtifact struct { + TagRef string `json:"tag_ref"` + TagOID string `json:"tag_oid"` + CommitOID string `json:"commit_oid"` +} + +// ObserveVersionArtifact reads origin and the fetched local tag without moving +// refs or requiring push access. The caller must materialize the selected tag. +func (runner *MutationRunner) ObserveVersionArtifact(ctx context.Context, directory, tagRef string) (VersionArtifact, error) { + if !safeVersionTagRef.MatchString(tagRef) { + return VersionArtifact{}, errors.New("invalid version artifact tag") + } + root, remoteURL, err := runner.validatedRemoteURL(ctx, directory, "origin") + if err != nil { + return VersionArtifact{}, err + } + result, err := runner.runWithEnvironment(ctx, root, map[int]struct{}{0: {}}, nil, + "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", + "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always", + "ls-remote", remoteURL, tagRef, tagRef+"^{}") + if err != nil { + return VersionArtifact{}, err + } + refs := map[string]string{} + for _, line := range nonEmptyLines(result.Stdout) { + oid, ref, ok := strings.Cut(line, "\t") + if !ok || (ref != tagRef && ref != tagRef+"^{}") || validateOID(oid, false) != nil || refs[ref] != "" { + return VersionArtifact{}, errors.New("ambiguous version artifact response") + } + refs[ref] = oid + } + if refs[tagRef] == "" { + return VersionArtifact{}, errors.New("version tag is not published") + } + commit := refs[tagRef+"^{}"] + if commit == "" { + commit = refs[tagRef] + } + for ref, want := range map[string]string{tagRef: refs[tagRef], tagRef + "^{commit}": commit} { + local, readErr := runner.run(ctx, root, map[int]struct{}{0: {}}, "rev-parse", "--verify", ref) + if readErr != nil || strings.TrimSpace(string(local.Stdout)) != want { + return VersionArtifact{}, errors.New("local version tag does not match published artifact") + } + } + return VersionArtifact{TagRef: tagRef, TagOID: refs[tagRef], CommitOID: commit}, nil +} diff --git a/core/providers/git/tag_read_test.go b/core/providers/git/tag_read_test.go new file mode 100644 index 0000000..7e7771d --- /dev/null +++ b/core/providers/git/tag_read_test.go @@ -0,0 +1,35 @@ +package git + +import ( + "context" + "testing" +) + +func TestObserveVersionArtifactReadsLightweightAndAnnotatedTags(t *testing.T) { + for _, annotated := range []bool{false, true} { + t.Run(map[bool]string{false: "lightweight", true: "annotated"}[annotated], func(t *testing.T) { + fixture := fastForwardFixture(t) + runner, err := NewMutationRunner() + if err != nil { + t.Fatal(err) + } + args := []string{"tag", "v1.2.3", fixture.firstOID} + if annotated { + args = []string{"tag", "-a", "v1.2.3", fixture.firstOID, "-m", "version artifact"} + } + runFetchGit(t, fixture.client, args...) + runFetchGit(t, fixture.client, "push", "-q", "origin", "refs/tags/v1.2.3") + artifact, err := runner.ObserveVersionArtifact(context.Background(), fixture.client, "refs/tags/v1.2.3") + if err != nil || artifact.CommitOID != fixture.firstOID || artifact.TagRef != "refs/tags/v1.2.3" { + t.Fatalf("artifact=%#v err=%v", artifact, err) + } + if annotated == (artifact.TagOID == artifact.CommitOID) { + t.Fatalf("tag object was not preserved: %#v", artifact) + } + runFetchGit(t, fixture.client, "tag", "-d", "v1.2.3") + if _, err := runner.ObserveVersionArtifact(context.Background(), fixture.client, "refs/tags/v1.2.3"); err == nil { + t.Fatal("unmaterialized tag accepted") + } + }) + } +} diff --git a/core/providers/github/mutation_release.go b/core/providers/github/mutation_release.go index d33314f..4f1d120 100644 --- a/core/providers/github/mutation_release.go +++ b/core/providers/github/mutation_release.go @@ -280,6 +280,10 @@ func validateReleaseInput(input ReleaseInput) error { } func normalizeRelease(raw releaseResponse, owner string, name string) (Release, error) { + return normalizeReleaseResponse(raw, owner, name, true) +} + +func normalizeReleaseResponse(raw releaseResponse, owner string, name string, exactTarget bool) (Release, error) { releaseURL, urlErr := url.Parse(raw.HTMLURL) if urlErr != nil || releaseURL == nil { return Release{}, responseContractError{code: "release-url-invalid"} @@ -296,7 +300,8 @@ func normalizeRelease(raw releaseResponse, owner string, name string) (Release, } } if raw.ID < 1 || raw.NodeID == "" || - !releaseTagNamePattern.MatchString(raw.TagName) || !validGitOID(raw.TargetCommitish) || + !releaseTagNamePattern.MatchString(raw.TagName) || + !boundedProviderText(raw.TargetCommitish, 256) || (exactTarget && !validGitOID(raw.TargetCommitish)) || !boundedProviderText(raw.Name, 256) || len(raw.Body) > 64<<10 || strings.ContainsRune(raw.Body, '\x00') || releaseURL.Scheme != "https" || !strings.EqualFold(releaseURL.Host, "github.com") || diff --git a/core/providers/github/release_observation.go b/core/providers/github/release_observation.go index 6093910..f6ce702 100644 --- a/core/providers/github/release_observation.go +++ b/core/providers/github/release_observation.go @@ -8,6 +8,12 @@ import ( "strings" ) +// GitHub may retain a branch in target_commitish after a tag exists; that +// field is not the released commit identity. Read observers preserve it as +// metadata. Artifact consumers resolve the tag itself; mutation verification +// still compares the target against its explicitly requested commit. +// https://docs.github.com/en/rest/releases/releases#create-a-release +// // Release observation is read-only and therefore lives on the read client rather // than the repository mutator. Verification and release planning must not need // mutation credentials merely to observe the provider state. @@ -44,7 +50,7 @@ func (client *Client) GetReleaseByTag( if err := decodeJSON(response.Body, &raw); err != nil { return Release{}, fmt.Errorf("decode GitHub release: %w", err) } - release, err := normalizeRelease(raw, owner, name) + release, err := normalizeReleaseResponse(raw, owner, name, false) if err != nil || release.TagName != tagName { return Release{}, fmt.Errorf("GitHub release response is invalid") } @@ -167,7 +173,7 @@ func (client *Client) GetReleaseByTagOptional( if err := decodeJSON(response.Body, &raw); err != nil { return Release{}, response.Meta, false, invalidGovernanceResponse(response, err) } - release, err := normalizeRelease(raw, owner, name) + release, err := normalizeReleaseResponse(raw, owner, name, false) if err != nil || release.TagName != tagName { return Release{}, response.Meta, false, invalidGovernanceResponse(response, err) } diff --git a/docs/contracts/lifecycles-v1.md b/docs/contracts/lifecycles-v1.md index 99f81c3..336c1f7 100644 --- a/docs/contracts/lifecycles-v1.md +++ b/docs/contracts/lifecycles-v1.md @@ -124,10 +124,41 @@ blocked while the gitlink contract still exists. `update-pin` accepts one non-default consumer task branch and one exact stage-zero gitlink whose checkout is either absent or already at the target -commit. The selected module must match the typed relationship, be clean on its -default branch, and have that exact commit published on its origin. The current -handler supports `default-branch-commit`; version and package policies require -their release providers first. +commit. The selected module must match the typed relationship. For +`default-branch-commit`, it must be clean on its default branch with that exact +commit published on origin. + +For `version-tag`, supply `--version 1.2.3` at plan time and materialize that +published tag in a clean module checkout (detached HEAD is supported). The +configured `release.tag_style` determines whether the tag is `v1.2.3` or +`1.2.3`. GDS reads origin without fetching or changing module refs and requires +the fetched local tag object and peeled commit to match origin exactly. The +module checkout must hold that commit so its identity, policy and verification +commands come from the selected artifact. All declared required lanes, +including compatibility when required, run at that exact commit. + +A required GitHub Release, or `release.mode: github-release`, additionally +requires `--runtime-config` (or the default device-local read runtime). GDS +proves the provider repository identity and exact tag object, a published +non-draft release, and a complete nonempty asset inventory with uploaded state, +IDs, names, sizes and SHA-256 digests. It binds these to the stored plan; it does +not download or execute release assets. The existing release reader's bounded +inventory contract applies (100 assets, each at most 64 MiB). A release's +`target_commitish` can name a branch and is metadata; the tag proves the commit, +as specified by the [GitHub release API](https://docs.github.com/en/rest/releases/releases#create-a-release). + +Apply re-observes the chosen artifact and lanes before staging the gitlink. +Journaled postconditions and explicit verify re-read the artifact, source +manifest and consumer relationship manifest. Changed tags (including a new +annotated tag object at the same commit), replaced release/assets, missing +publication and unrelated checkout changes refuse. This proves the observed +artifact at each transaction phase; it does not enable provider-side tag +protection. Version flags cannot override an existing plan. Package consumers +still require their registry and dependency-manifest provider. + +`update-consumers --plan --version 1.2.3` forwards the same explicit artifact +selection and read runtime to each selected consumer's independent subplan. +Both consumer planning commands use the module-lane deadline. Accepting the second checkout shape is what makes the command usable. The consumer is otherwise clean, but an advanced submodule reports its gitlink as diff --git a/schemas/v1/plan.schema.json b/schemas/v1/plan.schema.json index 83c0752..798a598 100644 --- a/schemas/v1/plan.schema.json +++ b/schemas/v1/plan.schema.json @@ -828,6 +828,117 @@ } } }, + "modulePinArtifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "tag", + "manifest_digest" + ], + "properties": { + "version": { + "type": "string", + "minLength": 5, + "maxLength": 128 + }, + "tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag_ref", + "tag_oid", + "commit_oid" + ], + "properties": { + "tag_ref": { + "type": "string", + "pattern": "^refs/tags/", + "maxLength": 512 + }, + "tag_oid": { + "$ref": "common.schema.json#/$defs/gitOid" + }, + "commit_oid": { + "$ref": "common.schema.json#/$defs/gitOid" + } + } + }, + "manifest_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "release_id": { + "type": "integer", + "minimum": 1 + }, + "release_immutable": { + "type": "boolean" + }, + "release_prerelease": { + "type": "boolean" + }, + "assets": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "size", + "state", + "browser_download_url", + "sha256" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + }, + "state": { + "const": "uploaded" + }, + "browser_download_url": { + "type": "string", + "format": "uri", + "maxLength": 4096 + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + } + }, + "dependentRequired": { + "release_id": [ + "assets" + ], + "assets": [ + "release_id" + ], + "release_immutable": [ + "release_id" + ], + "release_prerelease": [ + "release_id" + ] + } + }, "gitlinkPinParameters": { "type": "object", "additionalProperties": false, @@ -841,20 +952,39 @@ "target_ref" ], "properties": { - "consumer_root": {"type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/"}, - "module_root": {"type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/"}, - "module_id": {"$ref": "common.schema.json#/$defs/repositoryId"}, + "consumer_root": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^/" + }, + "module_root": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^/" + }, + "module_id": { + "$ref": "common.schema.json#/$defs/repositoryId" + }, "gitmodules_name": { "description": "The .gitmodules entry name, which is a submodule name and not a GitHub repository name. Every module in this estate names it by path, so the repository anchor has always typed it as a safe relative path; this said repositoryName, which forbids the slash, and no module pin could be planned at all.", "$ref": "common.schema.json#/$defs/safeRelativePath" }, - "expected_old_oid": {"$ref": "common.schema.json#/$defs/gitOid"}, - "target_oid": {"$ref": "common.schema.json#/$defs/gitOid"}, + "expected_old_oid": { + "$ref": "common.schema.json#/$defs/gitOid" + }, + "target_oid": { + "$ref": "common.schema.json#/$defs/gitOid" + }, "target_ref": { "type": "string", "minLength": 12, "maxLength": 512, "pattern": "^refs/(heads|tags)/[A-Za-z0-9][A-Za-z0-9._/-]*$" + }, + "artifact": { + "$ref": "#/$defs/modulePinArtifact" } } }, diff --git a/skills/canonical/gds-update-consumer-pins/SKILL.md b/skills/canonical/gds-update-consumer-pins/SKILL.md index e8515bf..46f5afd 100644 --- a/skills/canonical/gds-update-consumer-pins/SKILL.md +++ b/skills/canonical/gds-update-consumer-pins/SKILL.md @@ -20,17 +20,26 @@ independent repository transactions. ## Inputs - Module repository ID and exact eligible artifact. -- Consumer selector, compatibility evidence, rollout rings, and approval. +- Explicit consumer IDs, compatibility evidence, and rollout rings. +- For `version-tag`, the exact SemVer version and a clean module checkout at + that fetched tag; read runtime configuration when GitHub publication is required. ## Preconditions 1. Verify artifact publication, reachability, compatibility, and provenance. 2. Resolve current consumers from typed relationships. -3. Run `gds module update-consumers --plan` and obtain approval. +3. Run `gds module update-consumers --plan` with exact consumer selectors. + For `version-tag`, pass `--version` and, where needed, `--runtime-config`. + A default-branch module must remain clean at its published default commit. + Package consumers require a registry provider and are not handled by gitlink + transactions. ## Workflow -1. Apply representative canary consumer updates. +1. Inspect each stored subplan's artifact and target OID, then apply representative + canary updates with `gds module update-pin --apply `. Local gitlink + rewrites require no signed approval; provider publication remains a separate + transaction. Do not change version or identity flags when applying a plan. 2. Run each consumer's required verification. 3. Advance bounded waves only when gates pass. 4. Preserve consumers intentionally pinned to older compatible artifacts. @@ -47,6 +56,9 @@ fall back to a generic gitlink-only update. ## Verification Run `gds module update-pin --verify --json` per consumer. +Supply the read runtime again when required. Versioned verification re-observes +the exact tag object, peeled commit, source manifest and required release/assets; +it refuses moved tags or replaced assets even if the staged gitlink still matches. `gds module update-consumers` plans only and has no apply or verify mode. ## Output