diff --git a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md index d0ba485..90c605a 100644 --- a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md +++ b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md @@ -113,6 +113,9 @@ The same change also makes the forge itself the release authority: release tags - Keep the shared Linux runner work directory out of macOS bootstraps. The dispatcher normalizes macOS runners to `/tmp/forgejo-runner` when the shared config points at Linux state paths such as `/var/lib/forgejo-runner`. +- Do not destroy Namespace Linux runners until `nsc describe` shows a real + terminal container state or tombstone. Empty describe payloads mean the + environment is still starting, not that the one-job runner has finished. - Run forge Namespace services with the repo-owned `nsc` package and keep it current with the token format expected by Namespace. `nsc 0.0.520` is the first required baseline for revokable token-file auth. The dispatcher and diff --git a/services/forgejo-nsc/internal/nsc/dispatcher.go b/services/forgejo-nsc/internal/nsc/dispatcher.go index 49cb4ec..33721b6 100644 --- a/services/forgejo-nsc/internal/nsc/dispatcher.go +++ b/services/forgejo-nsc/internal/nsc/dispatcher.go @@ -284,22 +284,28 @@ func instanceStopped(output string) bool { if len(payload) == 0 { return false } + seenTerminalState := false for _, entry := range payload { + if len(entry.PerResource) == 0 { + return false + } for _, target := range entry.PerResource { if target.Tombstone != "" { + seenTerminalState = true return true } if len(target.Container) == 0 { - continue + return false } for _, container := range target.Container { if container.Status != "stopped" && container.TerminatedAt == "" { return false } + seenTerminalState = true } } } - return true + return seenTerminalState } func (d *Dispatcher) waitForInstanceStop(ctx context.Context, runnerName, instanceID string, timeout time.Duration) bool { diff --git a/services/forgejo-nsc/internal/nsc/dispatcher_test.go b/services/forgejo-nsc/internal/nsc/dispatcher_test.go new file mode 100644 index 0000000..bcf450e --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/dispatcher_test.go @@ -0,0 +1,33 @@ +package nsc + +import "testing" + +func TestInstanceStoppedRequiresObservedTerminalState(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + output string + want bool + }{ + {name: "empty output", output: "", want: false}, + {name: "empty response", output: "[]", want: false}, + {name: "no resources yet", output: `[{"per_resource":{}}]`, want: false}, + {name: "resource without containers", output: `[{"per_resource":{"runner":{}}}]`, want: false}, + {name: "running container", output: `[{"per_resource":{"runner":{"container":[{"status":"running"}]}}}]`, want: false}, + {name: "stopped container", output: `[{"per_resource":{"runner":{"container":[{"status":"stopped"}]}}}]`, want: true}, + {name: "terminated container", output: `[{"per_resource":{"runner":{"container":[{"status":"running","terminated_at":"2026-06-07T15:00:00Z"}]}}}]`, want: true}, + {name: "tombstone", output: `[{"per_resource":{"runner":{"tombstone":"destroyed"}}}]`, want: true}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := instanceStopped(tc.output) + if got != tc.want { + t.Fatalf("instanceStopped(%s) = %v, want %v", tc.output, got, tc.want) + } + }) + } +}