diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index ae43fa0..8da297e 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:86106022a9d268cbf92facb4b37f483d3a9e54e4286c9a2d2e0f322dbf8031c5" - digest: "sha256:d455f8eb189678391db807db5355c001d4034f2361b7376f8773dbd5ab8ac7d5" + source_tree_digest: "sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952" + digest: "sha256:d7e3c5193688aece7f043ee30612f68992af8af3c8fae18e035a6d8050903cc5" projection: - input_digest: "sha256:e285c5b042cc7063f98a9a5f0d9ef4c01f04e95f7e322990733d2091a9a6dec5" - output_digest: "sha256:c44ea2fe00938863c53a093c1229740a3af1f34f9cffe25195c660de508c80f1" + input_digest: "sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f" + output_digest: "sha256:3bc62bba5a5405bedb483ffa1e03dc74ff22991ba153035a41306f53f368c3ed" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:c00a0da0ba94629cf9858de9e7094fb40f892285e8896ca70723cc4701660be2" + digest: "sha256:d5fe07fdc246ebe55b9c243db242342fb6f09b4c265a0d26a89838be33c7a7a5" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 26a2195..671b77c 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:86106022a9d268cbf92facb4b37f483d3a9e54e4286c9a2d2e0f322dbf8031c5 -# input-digest: sha256:e285c5b042cc7063f98a9a5f0d9ef4c01f04e95f7e322990733d2091a9a6dec5 +# source-tree-digest: sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952 +# input-digest: sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml index 643b4d5..6e1caf4 100644 --- a/.github/workflows/release-bundle.yml +++ b/.github/workflows/release-bundle.yml @@ -83,17 +83,32 @@ jobs: previous_sequence="$(jq -r '.release_sequence' "$work/release-envelope.json")" previous_floor="$(jq -r '.minimum_cli_version' "$work/manifest.json")" [[ "$previous_sequence" =~ ^[0-9]+$ ]] || { echo "previous release sequence is not an integer" >&2; exit 1; } - previous_version="${previous#gds-v}" + # Failed releases keep their tag and sequence. Read the latest tag's + # success or failure envelope so the next merge never reuses either + # immutable identity (for example, failed 0.9.17 / sequence 72). + if [ "$latest_tag" = "$previous" ]; then + latest_sequence="$previous_sequence" + elif gh release download "$latest_tag" --repo "$GITHUB_REPOSITORY" \ + -p release-failure-envelope.json --dir "$work" --clobber >/dev/null 2>&1; then + latest_sequence="$(jq -r '.release_sequence' "$work/release-failure-envelope.json")" + test "$(jq -r '.bundle_version' "$work/release-failure-envelope.json")" = "${latest_tag#gds-v}" + else + echo "Latest tag $latest_tag has no success or failure release envelope" >&2 + exit 1 + fi + [[ "$latest_sequence" =~ ^[0-9]+$ ]] || { echo "latest release sequence is not an integer" >&2; exit 1; } + [ "$latest_sequence" -ge "$previous_sequence" ] || { echo "latest release sequence regressed" >&2; exit 1; } + latest_version="${latest_tag#gds-v}" if [ -n "$VERSION_OVERRIDE" ]; then next_version="$VERSION_OVERRIDE" else - next_version="$(awk -F. -v v="$previous_version" 'BEGIN{split(v,p,"."); printf "%d.%d.%d", p[1], p[2], p[3]+1}')" + next_version="$(awk -F. -v v="$latest_version" 'BEGIN{split(v,p,"."); printf "%d.%d.%d", p[1], p[2], p[3]+1}')" fi # Monotonicity is structural, not assumed: the new identity must be # strictly greater than the release it follows. Compare components # numerically -- sort -n reads "9.10" as the float 9.1, which orders # a double-digit patch below its predecessor. - if ! awk -F. -v a="$previous_version" -v b="$next_version" 'BEGIN{ + if ! awk -F. -v a="$latest_version" -v b="$next_version" 'BEGIN{ split(a, x, "."); split(b, y, "."); for (i = 1; i <= 3; i++) { if (y[i] + 0 > x[i] + 0) exit 0; @@ -101,10 +116,10 @@ jobs: } exit 1 }'; then - echo "next version $next_version is not greater than $previous_version" >&2 + echo "next version $next_version is not greater than $latest_version" >&2 exit 1 fi - next_sequence=$((previous_sequence + 1)) + next_sequence=$((latest_sequence + 1)) next_tag="gds-v$next_version" if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$next_tag" >/dev/null 2>&1; then echo "tag $next_tag already exists" >&2 diff --git a/core/app/module_pin.go b/core/app/module_pin.go index 4578042..b169333 100644 --- a/core/app/module_pin.go +++ b/core/app/module_pin.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "path/filepath" "strings" "time" @@ -72,7 +73,10 @@ func (observer modulePinObserver) Observe( current, findings := observer.services.modulePinContext( ctx, observer.consumer, observer.module, observer.name, observer.version, observer.runtimeConfig, ) - if len(findings) != 0 || current.assessment.ConsumerID != repositoryID { + if len(findings) != 0 { + return operations.Observation{}, fmt.Errorf("module pin precondition findings: %w", operations.NewObservationFailure(findings)) + } + if current.assessment.ConsumerID != repositoryID { return operations.Observation{}, errors.New("module pin precondition is no longer proven") } return current.observation, nil diff --git a/core/app/module_verify.go b/core/app/module_verify.go index 18268d9..e108098 100644 --- a/core/app/module_verify.go +++ b/core/app/module_verify.go @@ -318,12 +318,18 @@ func runDeclaredCommand( // their own source checkout, and must not silently select its consumer's // estate. A declared command can still explicitly select an estate itself. environment := os.Environ() - command.Env = make([]string, 0, len(environment)) + command.Env = make([]string, 0, len(environment)+2) for _, value := range environment { - if !strings.HasPrefix(value, "GDS_ESTATE_ROOT=") { + if !strings.HasPrefix(value, "GDS_ESTATE_ROOT=") && + !strings.HasPrefix(value, "GIT_CONFIG_GLOBAL=") && + !strings.HasPrefix(value, "GIT_CONFIG_NOSYSTEM=") { command.Env = append(command.Env, value) } } + // Verification runs in a throwaway checkout of the pinned gitlink. Host + // Git config (notably fsmonitor hooks) can start background processes and + // make an otherwise passing lane appear to leave unjoined descendants. + command.Env = append(command.Env, "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1") // Bound inherited output pipes as well as the command itself. command.WaitDelay = 2 * time.Second diagnostic := &moduleCommandOutput{} diff --git a/core/app/module_verify_test.go b/core/app/module_verify_test.go index 61c22da..022583e 100644 --- a/core/app/module_verify_test.go +++ b/core/app/module_verify_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "os" "strings" "testing" "time" @@ -85,6 +86,20 @@ func TestDeclaredCommandDoesNotInheritControllerEstateSelection(t *testing.T) { } } +func TestDeclaredCommandDoesNotInheritHostGitConfiguration(t *testing.T) { + config := t.TempDir() + "/gitconfig" + if err := os.WriteFile(config, []byte("[core]\n\tfsmonitor = true\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("GIT_CONFIG_GLOBAL", config) + t.Setenv("GIT_CONFIG_NOSYSTEM", "0") + report := runDeclaredCommand(context.Background(), t.TempDir(), + `test "$(git config --global --get core.fsmonitor || :)" = "" && test "$GIT_CONFIG_NOSYSTEM" = 1`, 30*time.Second) + if report.Status != "passed" { + t.Fatalf("host Git configuration leaked into module lane: %#v", report) + } +} + func TestDeclaredCommandPreservesStdoutFailure(t *testing.T) { t.Parallel() report := runDeclaredCommand(context.Background(), t.TempDir(), `printf 'FAIL: module assertion\n'; exit 1`, 30*time.Second) diff --git a/core/operations/engine.go b/core/operations/engine.go index 7d695f2..05b0d2a 100644 --- a/core/operations/engine.go +++ b/core/operations/engine.go @@ -36,6 +36,48 @@ type PreconditionChecker interface { Observe(context.Context, string) (Observation, error) } +// ObservationFailure carries stable finding codes across a failed +// re-observation. The journal may record these codes without persisting +// arbitrary diagnostics from an external command or provider. +type ObservationFailure struct { + FindingCodes []string +} + +func NewObservationFailure(findings []domain.Finding) *ObservationFailure { + seen := make(map[string]struct{}, len(findings)) + codes := make([]string, 0, len(findings)) + for _, finding := range findings { + if finding.Code == "" { + continue + } + if _, exists := seen[finding.Code]; exists { + continue + } + seen[finding.Code] = struct{}{} + codes = append(codes, finding.Code) + } + sort.Strings(codes) + return &ObservationFailure{FindingCodes: codes} +} + +func (failure *ObservationFailure) Error() string { + if len(failure.FindingCodes) == 0 { + return "precondition observation has no proven finding code" + } + return "precondition observation findings: " + strings.Join(failure.FindingCodes, ", ") +} + +func preconditionFailureCause(observeErr error) map[string]any { + if observeErr == nil { + return map[string]any{"kind": "field-mismatch"} + } + var findingError *ObservationFailure + if errors.As(observeErr, &findingError) { + return map[string]any{"kind": "observation-findings", "finding_codes": findingError.FindingCodes} + } + return map[string]any{"kind": "observation-error"} +} + type ApplyEvidence struct { Before any `json:"before,omitempty"` After any `json:"after,omitempty"` @@ -458,7 +500,7 @@ func (engine *Engine) apply( } _, _ = engine.Store.AppendEvent( ctx, operationID, planID, "", "preconditions-stale", engine.now(), - map[string]any{"mismatches": mismatches}, + map[string]any{"mismatches": mismatches, "cause": preconditionFailureCause(observeErr)}, ) return engine.blockBeforeMutation( ctx, plan, operationID, locks, "GDS_STALE_PLAN", @@ -499,6 +541,7 @@ func (engine *Engine) apply( map[string]any{ "repository_id": step.RepositoryID, "mismatches": mismatches, + "cause": preconditionFailureCause(observeErr), }, ) return engine.blockOnStepPreconditionDrift( diff --git a/core/operations/engine_test.go b/core/operations/engine_test.go index 91aed64..f88f4da 100644 --- a/core/operations/engine_test.go +++ b/core/operations/engine_test.go @@ -24,6 +24,7 @@ import ( type fakeChecker struct { observations map[string]Observation err error + failAt int calls int } @@ -81,7 +82,7 @@ func TestApplySignedVerifiesAndConsumesExactPlanEnablement(t *testing.T) { func (checker *fakeChecker) Observe(_ context.Context, repositoryID string) (Observation, error) { checker.calls++ - if checker.err != nil { + if checker.err != nil && (checker.failAt == 0 || checker.calls == checker.failAt) { return Observation{}, checker.err } return checker.observations[repositoryID], nil @@ -276,6 +277,62 @@ func TestApplyRejectsStalePlanBeforeHandler(t *testing.T) { } } +func TestApplyJournalsTypedObservationCauseWithoutRawDiagnostics(t *testing.T) { + for _, trial := range []struct { + name string + failAt int + eventType string + }{ + {"before-mutation", 1, "preconditions-stale"}, + {"before-step", 2, "step-preconditions-stale"}, + } { + t.Run(trial.name, func(t *testing.T) { + engine, store, checker, handler, plan := testEngine(t) + checker.failAt = trial.failAt + checker.err = fmt.Errorf("private diagnostic: %w", NewObservationFailure([]domain.Finding{ + {Code: "GDS_MODULE_PIN_LANE_FAILED"}, + {Code: "GDS_MODULE_PIN_SOURCE_NOT_PROVEN"}, + {Code: "GDS_MODULE_PIN_LANE_FAILED"}, + })) + result, err := engine.Apply(context.Background(), plan.PlanID, "approval:owner:cause") + operationError(t, err, "GDS_STALE_PLAN", domain.ExitStale) + if result.MutationAttempted || handler.applyCalls != 0 { + t.Fatalf("failed observation reached mutation handler: result=%+v calls=%d", result, handler.applyCalls) + } + events, err := store.ListEvents(context.Background(), result.OperationID) + if err != nil { + t.Fatal(err) + } + found := false + for _, event := range events { + if event.EventType != trial.eventType { + continue + } + found = true + if strings.Contains(string(event.Payload), "private diagnostic") { + t.Fatalf("journal leaked raw observation error: %s", event.Payload) + } + var payload struct { + Cause struct { + Kind string `json:"kind"` + FindingCodes []string `json:"finding_codes"` + } `json:"cause"` + } + if err := json.Unmarshal(event.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.Cause.Kind != "observation-findings" || + strings.Join(payload.Cause.FindingCodes, ",") != "GDS_MODULE_PIN_LANE_FAILED,GDS_MODULE_PIN_SOURCE_NOT_PROVEN" { + t.Fatalf("journal lost sorted finding codes: %+v", payload.Cause) + } + } + if !found { + t.Fatalf("%s event missing", trial.eventType) + } + }) + } +} + func TestApplyRechecksEachRepositoryImmediatelyBeforeItsFirstMutation(t *testing.T) { engine, store, checker, handler, _ := testEngine(t) created := time.Date(2026, 7, 11, 5, 0, 0, 0, time.UTC)