diff --git a/cmd/start.go b/cmd/start.go index 5a62231..463edca 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1294,15 +1294,20 @@ func handlePolicyFetchFailure(cmd *StartCmd, configMgr *config.Manager, auditLog // fail-fast purpose; the intended flow (the watcher fails the job // on this sentinel, so no build steps run) makes the pre-attach // window harmless. + // + // ORDER: the downgrade record is written BEFORE the sentinel. The + // action classifies a sentinel as lockdown from this record, so the + // sentinel is the commit signal and is published only once the + // record describing it exists — a reader between the two writes + // would otherwise see a generic startup crash (issue #102). The + // record also tells the dashboard, via the summary push, that the + // run was locked down and why. + writeDowngradeFile(downgradeRecord(datapb.CargoWallDowngradeType_CARGO_WALL_DOWNGRADE_TYPE_LOCKDOWN, fe, reason), logger) if cmd.FailureFile != "" { if werr := writeFailureSentinel(cmd.FailureFile, reason); werr != nil { logger.Warn("Failed to write failure sentinel", "path", cmd.FailureFile, "error", werr) } } - // Also recorded as a structured downgrade: if the action lets the - // job proceed (or the post step runs before teardown), the summary - // push tells the dashboard the run was locked down and why. - writeDowngradeFile(downgradeRecord(datapb.CargoWallDowngradeType_CARGO_WALL_DOWNGRADE_TYPE_LOCKDOWN, fe, reason), logger) // No mode file: lockdown is not a SaaS-resolved posture, and the // failure sentinel already carries the state the action needs. return diff --git a/cmd/start_test.go b/cmd/start_test.go index 1fad96f..a5f22bd 100644 --- a/cmd/start_test.go +++ b/cmd/start_test.go @@ -243,6 +243,40 @@ func TestLoadCIConfig_LockdownSkipsLocalConfig(t *testing.T) { } } +// TestLoadCIConfig_LockdownPublishesSentinelLast: consumers poll for the +// failure sentinel and classify lockdown from the downgrade record, so the +// sentinel must be the last write — published before the record, a reader +// between the two sees a lockdown with no record and treats it as a generic +// startup crash (issue #102). +func TestLoadCIConfig_LockdownPublishesSentinelLast(t *testing.T) { + setFastPolicyRetries(t) + redirectStateFiles(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + // Both writes atomically replace the same path, so whichever lands last + // is what remains: a deterministic order check with no goroutine racing + // two writes microseconds apart. + sharedPath := filepath.Join(t.TempDir(), "cargowall-state") + downgradeFile = sharedPath // restored by redirectStateFiles' cleanup + + cmd := &StartCmd{ + GithubAction: true, + ApiUrl: srv.URL, + Token: "test-token", + ApiFailureMode: ApiFailureModeFail, + FailureFile: sharedPath, + } + loadCIConfig(context.Background(), cmd, config.NewConfigManager(), nil, quietLogger()) + + require.True(t, cmd.policyLockdown) + data, err := os.ReadFile(sharedPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(data), "pid="), "the failure sentinel must be written after the downgrade record, got: %s", data) +} + // TestLoadCIConfig_CancelledContextSkipsPostureHandling guards the SIGTERM // unwind path: a fetch killed by shutdown-signal cancellation must not be // misreported as an outage — no lockdown, no audit downgrade, and no