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 @@ -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"
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.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
Expand Down
25 changes: 20 additions & 5 deletions .github/workflows/release-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,28 +83,43 @@ 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;
if (y[i] + 0 < x[i] + 0) exit 1;
}
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
Expand Down
6 changes: 5 additions & 1 deletion core/app/module_pin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions core/app/module_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
15 changes: 15 additions & 0 deletions core/app/module_verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"context"
"os"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion core/operations/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -499,6 +541,7 @@ func (engine *Engine) apply(
map[string]any{
"repository_id": step.RepositoryID,
"mismatches": mismatches,
"cause": preconditionFailureCause(observeErr),
},
)
return engine.blockOnStepPreconditionDrift(
Expand Down
59 changes: 58 additions & 1 deletion core/operations/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
type fakeChecker struct {
observations map[string]Observation
err error
failAt int
calls int
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading