diff --git a/README.md b/README.md index d8f5c6e..e5ad599 100644 --- a/README.md +++ b/README.md @@ -739,6 +739,12 @@ inside it. > curious.pub isn't taking this right now. +### site-withdrawn + +**addresses.** A person is waiting for a finished deploy to be given its public address. + +> This deploy's site has been withdrawn. + ### temp-dir-unusable **this machine.** A person is getting this machine ready before anything is sent: the project directory, where the login is kept, the API address or the temporary directory. diff --git a/catalog.json b/catalog.json index 937cdbf..ace568a 100644 --- a/catalog.json +++ b/catalog.json @@ -757,6 +757,20 @@ "this request" ] }, + { + "action": [ + "FreshDeploy" + ], + "family": null, + "headline": { + "addresses": "This deploy's site has been withdrawn." + }, + "headline_reason": {}, + "id": "site-withdrawn", + "stages": [ + "addresses" + ] + }, { "action": [ "FreshDeploy" diff --git a/internal/api/publish_test.go b/internal/api/publish_test.go index 2aee7ef..b0aacae 100644 --- a/internal/api/publish_test.go +++ b/internal/api/publish_test.go @@ -120,7 +120,7 @@ func TestDeployPublish_IsNeverRetried(t *testing.T) { } } -// TestDeployPublish_CarriesTheServersCodeThrough. The two codes this +// TestDeployPublish_CarriesTheServersCodeThrough. The codes this // endpoint answers with are what a client switches on, so an *APIError // that lost one would send the caller back to reading prose — which is // the thing these codes exist to stop. @@ -131,6 +131,7 @@ func TestDeployPublish_CarriesTheServersCodeThrough(t *testing.T) { }{ {"the build is finished with", wire.CodeDeployFailed}, {"the build has not finished", wire.CodeDeployNotReady}, + {"the site was withdrawn", wire.CodeSiteWithdrawn}, } { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/flow/create.go b/internal/flow/create.go index 13ead05..718faaa 100644 --- a/internal/flow/create.go +++ b/internal/flow/create.go @@ -88,9 +88,9 @@ var createRouting = map[wire.ErrorCode]createRoute{ wire.CodeForbidden: createStop, wire.CodeNotFound: createStop, wire.CodeInternal: createStop, - // THE PUBLISH STEP'S TWO CODES, declared here and not reachable from - // this call — the same shape the closed-capacity code already has. - // The create cannot receive them: they say a deploy is not in a + // TWO OF THE PUBLISH STEP'S CODES, declared here and not reachable + // from this call — the same shape the closed-capacity code already + // has. The create cannot receive them: they say a deploy is not in a // publishable state, and this call is what brings a deploy into // existence. Stated anyway, because the contract's list is what this // table is keyed to, and a code with no entry would be answered by a @@ -98,10 +98,14 @@ var createRouting = map[wire.ErrorCode]createRoute{ wire.CodeDeployFailed: createStop, wire.CodeDeployNotReady: createStop, // Not reachable from this call either: two travel only on a build's - // event stream, and the third is the publish step's full store. - wire.CodeBuildFailed: createStop, - wire.CodeLimitReached: createStop, - wire.CodeStoreFull: createStop, + // event stream, and the other two are the publish step's — its full + // store, and a site that has been withdrawn, which only a publish can + // be told about because only a publish names a deploy whose site + // could have gone. + wire.CodeBuildFailed: createStop, + wire.CodeLimitReached: createStop, + wire.CodeStoreFull: createStop, + wire.CodeSiteWithdrawn: createStop, } // deployCreator is the slice of the API client this step needs. It is diff --git a/internal/flow/login.go b/internal/flow/login.go index bdcd128..abe3118 100644 --- a/internal/flow/login.go +++ b/internal/flow/login.go @@ -249,13 +249,14 @@ var errorRouting = map[wire.ErrorCode]verifyRoute{ wire.CodeCapacityClosed: routeStop, // NOT REACHABLE FROM A LOGIN. Two say how a build stopped and travel - // only on the build's event stream; the third is the publish step's - // full store. Stated because this table is keyed to the contract's - // list, and a code with no entry would be answered by a fallback - // nobody chose. - wire.CodeBuildFailed: routeStop, - wire.CodeLimitReached: routeStop, - wire.CodeStoreFull: routeStop, + // only on the build's event stream; the other two are the publish + // step's — its full store, and a withdrawn site. Stated because this + // table is keyed to the contract's list, and a code with no entry + // would be answered by a fallback nobody chose. + wire.CodeBuildFailed: routeStop, + wire.CodeLimitReached: routeStop, + wire.CodeStoreFull: routeStop, + wire.CodeSiteWithdrawn: routeStop, // The kill switch. Worth retrying later, and carrying no reset the // server can honestly name — which is exactly why the contract's own diff --git a/internal/flow/publish.go b/internal/flow/publish.go index 1210a84..69af2ba 100644 --- a/internal/flow/publish.go +++ b/internal/flow/publish.go @@ -222,6 +222,11 @@ var publishRouting = map[wire.ErrorCode]publishRoute{ // fix, and trying again will not help until it has. wire.CodeStoreFull: publishStop, + // The site this deploy belongs to has been withdrawn, and will not be + // published again. Terminal: asking again cannot bring it back, so + // this stops rather than inviting a retry. + wire.CodeSiteWithdrawn: publishStop, + // Not reachable from this call: both travel only on a build's event // stream. wire.CodeBuildFailed: publishStop, @@ -563,6 +568,20 @@ func publishStopFailure(apiErr *api.APIError, deployID string, now time.Time) er "nothing to fix at this end and nothing here worth retrying. If it keeps\n"+ "happening, please get in touch.").Quoting(apiErr.Message) + case wire.CodeSiteWithdrawn: + // NO "NOTHING WAS DEPLOYED" LINE, and no retry advice. The deploy + // may have been live before its site was withdrawn, and this + // client cannot know which, so it does not claim either. Publishing + // again will not bring the site back; a fresh deploy is the way + // forward. It is a failure of this run, so it costs 1, and it is + // not the closed door. + return ui.NewFailure( + ui.IDSiteWithdrawn, + ui.StageAddresses, + "This deploy's site has been withdrawn.", + ui.Written("A withdrawn site is not published again.\n\nThe deploy is %s.", deployID), ui.NextFreshDeploy, + "Run `curious deploy` again to make a fresh one.").Quoting(apiErr.Message) + case wire.CodeInternal: // The server failed, and its own message says to try again, so // this copy says the same. The full-store case that used to share diff --git a/internal/flow/publish_test.go b/internal/flow/publish_test.go index c415249..87854de 100644 --- a/internal/flow/publish_test.go +++ b/internal/flow/publish_test.go @@ -638,6 +638,39 @@ func TestEachRefusalHasItsOwnCopyAndItsOwnCost(t *testing.T) { // contradicting the sentence above it. neverSay: []string{"Try again"}, }, + { + // A withdrawn site is refused without the "nothing was + // deployed" line, and that is deliberate: the deploy may have + // been live before the site was withdrawn, and this client + // cannot know which. Saying nothing was deployed would be + // asserting a thing it has no way to check. The cost is 1, a + // failure of this run, and not the closed-door cost: the + // capacity and maintenance rows in this same table exit 3 + // through the same harness, which is what makes the 1 here a + // measurement and not a default. + // + // REQUIRED MUTATION, run 2026-10-05: each of these was made + // to this code's copy alone and this row run, and each reds. + // Wrapping the failure in the closed-door constructor reds on + // "exit code = 3, want 1". Putting the nothing-was-deployed + // line into the body reds on neverSay, which names it. + // Replacing the fresh-deploy + // text with "Try again in a moment." reds on says (the fresh + // deploy line never appeared) and on neverSay. Dropping the + // quoted server message reds on says. Separately, changing the + // next action to a wait leaves THIS row green, because the + // terminal output does not print the action; the generated + // catalog is what reds for that, and the row does not claim it. + name: "the site was withdrawn", + outcome: fails(http.StatusGone, wire.CodeSiteWithdrawn, + "This site has been withdrawn and will not be published again."), + wantCode: 1, + says: []string{"This deploy's site has been withdrawn.", + "A withdrawn site is not published again.", "deploy-1", + "This site has been withdrawn and will not be published again.", + "Run `curious deploy` again to make a fresh one."}, + neverSay: []string{"Try again", nothingDeployed}, + }, { // REQUIRED MUTATION: restore "nothing here worth retrying" on // this code, and this row reds on its neverSay. diff --git a/internal/ui/failureid.go b/internal/ui/failureid.go index 46ff531..1487119 100644 --- a/internal/ui/failureid.go +++ b/internal/ui/failureid.go @@ -106,6 +106,7 @@ const ( IDPublishedAddressInvalid FailureID = "published-address-invalid" IDDeployUnknownToServer FailureID = "deploy-unknown-to-server" IDDeployNotCompletedByServer FailureID = "deploy-not-completed-by-server" + IDSiteWithdrawn FailureID = "site-withdrawn" // The waitlist, when the door is closed. IDWaitlistDeclined FailureID = "waitlist-declined" @@ -171,7 +172,7 @@ var ActiveFailureIDs = []FailureID{ IDBuildFailedUnexplained, IDBuildLogLost, IDBuildOutputRefused, IDPublishNotConfirmed, IDPublishedAddressInvalid, IDDeployUnknownToServer, - IDDeployNotCompletedByServer, + IDDeployNotCompletedByServer, IDSiteWithdrawn, IDWaitlistDeclined, IDWaitlistNeedsTerminal, IDWaitlistSignupFailed, diff --git a/pkg/wire/contract_guard_test.go b/pkg/wire/contract_guard_test.go index 1a33895..9ffcda1 100644 --- a/pkg/wire/contract_guard_test.go +++ b/pkg/wire/contract_guard_test.go @@ -260,6 +260,7 @@ func TestEveryErrorCodeConstantIsPinned(t *testing.T) { "CodeBuildFailed": "build_failed", "CodeLimitReached": "limit_reached", "CodeStoreFull": "store_full", + "CodeSiteWithdrawn": "site_withdrawn", } declared := declaredErrorCodeValues(t) @@ -620,6 +621,9 @@ func TestCarriesRetryAfterIsPinned(t *testing.T) { // A full store reopens when someone empties it, at no time the // server can name. CodeStoreFull: false, + // A withdrawn site is never coming back, so there is no time to + // wait for. + CodeSiteWithdrawn: false, } listed := map[ErrorCode]bool{} @@ -1172,6 +1176,8 @@ func TestStreamOnlyIsPinned(t *testing.T) { CodeBuildFailed: true, CodeLimitReached: true, CodeStoreFull: false, + // An HTTP refusal of the publish call, never a stream event. + CodeSiteWithdrawn: false, } for _, code := range AllErrorCodes { want, stated := pinned[code] diff --git a/pkg/wire/testdata/error_response_site_withdrawn.json b/pkg/wire/testdata/error_response_site_withdrawn.json new file mode 100644 index 0000000..ddbfad4 --- /dev/null +++ b/pkg/wire/testdata/error_response_site_withdrawn.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "site_withdrawn", + "message": "This site has been withdrawn and will not be published again. Deploy again to publish a fresh one." + } +} diff --git a/pkg/wire/wire.go b/pkg/wire/wire.go index d60089a..c3354c1 100644 --- a/pkg/wire/wire.go +++ b/pkg/wire/wire.go @@ -197,6 +197,19 @@ const ( // code carried opposite advice. A client could only tell them apart by // reading the message, which this contract refuses to make anyone do. CodeStoreFull ErrorCode = "store_full" + + // CodeSiteWithdrawn means the site this deploy belongs to has been + // withdrawn — it expired, or its owner or the service took it down — + // and it will not be published again. It is terminal: publishing + // again will not bring it back, so a client should not advise a + // retry; a fresh deploy is the way forward. It carries no + // Retry-After, and it is an HTTP refusal, never a stream event. + // + // It is a code of its own because CodeDeployNotReady and + // CodeDeployFailed would each say something false: one would tell a + // client to wait, the other would tell a person their build was + // refused, and neither is true of a withdrawn site. + CodeSiteWithdrawn ErrorCode = "site_withdrawn" ) // AllErrorCodes is every ErrorCode this contract defines, in declaration @@ -226,6 +239,7 @@ var AllErrorCodes = []ErrorCode{ CodeBuildFailed, CodeLimitReached, CodeStoreFull, + CodeSiteWithdrawn, } // retryAfterCodes is the set behind CarriesRetryAfter. It is unexported diff --git a/pkg/wire/wire_test.go b/pkg/wire/wire_test.go index 613e798..fc15ba6 100644 --- a/pkg/wire/wire_test.go +++ b/pkg/wire/wire_test.go @@ -87,6 +87,17 @@ func goldenCases() []goldenCase { }, newEmpty: func() any { return &ErrorResponse{} }, }, + { + name: "ErrorResponseSiteWithdrawn", + fixture: "error_response_site_withdrawn.json", + value: &ErrorResponse{ + Error: Error{ + Code: CodeSiteWithdrawn, + Message: "This site has been withdrawn and will not be published again. Deploy again to publish a fresh one.", + }, + }, + newEmpty: func() any { return &ErrorResponse{} }, + }, { // The deploy event stream's `error` event is a bare wire.Error // (see DoneEvent's doc), so a stream-only code is pinned in the @@ -397,6 +408,7 @@ func TestAllErrorCodesOrder(t *testing.T) { "rate_limited", "capacity_closed", "maintenance", "internal", "deploy_failed", "deploy_not_ready", "build_failed", "limit_reached", "store_full", + "site_withdrawn", } if !reflect.DeepEqual(AllErrorCodes, want) { t.Fatalf("AllErrorCodes = %v, want %v — declaration order, as its doc states", AllErrorCodes, want) @@ -425,6 +437,7 @@ func TestErrorCodeConstants(t *testing.T) { {CodeBuildFailed, "build_failed"}, {CodeLimitReached, "limit_reached"}, {CodeStoreFull, "store_full"}, + {CodeSiteWithdrawn, "site_withdrawn"}, } if len(cases) != len(AllErrorCodes) {