From ddac9e3c730e43d1d32bb4eb8dc5734a5dc9864b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 18:02:05 +0000 Subject: [PATCH] Return write evidence from Gitea operations --- docs/roadmap/implementation.md | 4 +- internal/gitea/backend.go | 89 +++++++++++++++++ internal/gitea/backend_test.go | 168 +++++++++++++++++++++++++++++++++ internal/gitea/upload.go | 41 ++++---- 4 files changed, 280 insertions(+), 22 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 77f337d..50e81f9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation plan: neutral forge delivery and credential preflight -Status: implementation in progress; Stages 1–4 are complete and Stages 5–10 +Status: implementation in progress; Stages 1–5 are complete and Stages 6–10 are pending. ## Purpose and execution @@ -327,7 +327,7 @@ evidence. It uses no extra account/admin credentials or destructive probe. ## Stage 5: Extract Gitea write operations and evidence -Status: pending. +Status: complete. 1. Implement `CreateRelease` from the current release POST and `UploadAsset` from multipart upload. Keep explicit target commit, stable flags, full note, diff --git a/internal/gitea/backend.go b/internal/gitea/backend.go index 502137a..3be5749 100644 --- a/internal/gitea/backend.go +++ b/internal/gitea/backend.go @@ -2,6 +2,7 @@ package gitea import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -75,6 +76,92 @@ func (b *Backend) Preflight(ctx context.Context) error { return nil } +func (b *Backend) CreateRelease(ctx context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) { + if strings.TrimSpace(request.Tag) == "" || !validObjectID(request.Commit) { + return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{ + Kind: forge.FailureInvalid, Err: errors.New("a full tag and lowercase Git commit ID are required"), + } + } + _, apiRoot, err := b.destinationAPI("create release") + if err != nil { + return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err + } + body, err := json.Marshal(createReleaseRequest{ + TagName: request.Tag, TargetCommitish: request.Commit, + Name: request.Title, Body: request.Body, + Draft: request.Draft, Prerelease: request.Prerelease, + }) + if err != nil { + return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: err} + } + created, status, err := b.client.postRelease(ctx, b.client.httpClient(), b.destination.Token, apiRoot+"/releases", body) + if err == nil && created.ID <= 0 { + err = failure(FailureMalformed, "verify created release", status, errors.New("release response does not contain a positive ID")) + } + evidence := writeEvidence(status, err) + if err != nil { + return forge.Release{}, evidence, neutralFailure(err) + } + return forge.Release{ + URL: created.URL, + Tag: created.TagName, + Title: created.Title, + Body: created.Body, + Draft: created.Draft, + Prerelease: created.Prerelease, + Handle: releaseHandle{id: created.ID}, + }, evidence, nil +} + +func (b *Backend) UploadAsset(ctx context.Context, handle any, filename string, content io.Reader) (forge.WriteEvidence, error) { + release, ok := handle.(releaseHandle) + if !ok || release.id <= 0 { + return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")} + } + if strings.TrimSpace(filename) == "" || content == nil { + return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset filename and content reader are required")} + } + _, apiRoot, err := b.destinationAPI("upload release asset") + if err != nil { + return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err + } + status, err := b.client.uploadAsset(ctx, b.client.httpClient(), b.destination.Token, apiRoot, release.id, filename, content) + evidence := writeEvidence(status, err) + if err != nil { + return evidence, neutralFailure(err) + } + return evidence, nil +} + +func writeEvidence(status int, err error) forge.WriteEvidence { + evidence := forge.WriteEvidence{Attempted: status != 0, Disposition: forge.WriteNotAttempted} + var backendFailure *Failure + if err != nil && errors.As(err, &backendFailure) && backendFailure.Kind == FailureTransport { + evidence.Attempted = true + } + if !evidence.Attempted { + return evidence + } + if err == nil { + evidence.Disposition = forge.WriteAccepted + return evidence + } + if status == http.StatusConflict { + evidence.Disposition = forge.WriteConflict + return evidence + } + if status >= http.StatusInternalServerError || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + evidence.Disposition = forge.WriteUncertain + return evidence + } + if errors.As(err, &backendFailure) && (backendFailure.Kind == FailureTransport || backendFailure.Kind == FailureMalformed) { + evidence.Disposition = forge.WriteUncertain + return evidence + } + evidence.Disposition = forge.WriteRejected + return evidence +} + func (repository repositoryResponse) matches(destination Destination) bool { if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) { return false @@ -237,3 +324,5 @@ func neutralFailure(err error) error { } return &forge.Failure{Kind: kind, Err: err} } + +var _ forge.Backend = (*Backend)(nil) diff --git a/internal/gitea/backend_test.go b/internal/gitea/backend_test.go index 1c466eb..b2c1234 100644 --- a/internal/gitea/backend_test.go +++ b/internal/gitea/backend_test.go @@ -245,3 +245,171 @@ func TestBackendPreflightPreservesCancellation(t *testing.T) { t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err) } } + +func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + var request createReleaseRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease { + t.Errorf("release request = %#v", request) + } + _ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{ + Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title", + Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true, + }) + if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) { + t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) + } + if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease { + t.Fatalf("CreateRelease() release = %#v", release) + } +} + +func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + _, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest()) + var failure *forge.Failure + if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) { + t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) + } +} + +func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) { + for _, test := range []struct { + status int + disposition forge.WriteDisposition + }{ + {status: http.StatusBadRequest, disposition: forge.WriteRejected}, + {status: http.StatusConflict, disposition: forge.WriteConflict}, + {status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain}, + } { + t.Run(http.StatusText(test.status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + _, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest()) + if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) { + t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) + } + }) + } +} + +func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) { + var uploaded string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" { + t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + } + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + file, header, err := r.FormFile("attachment") + if err != nil { + t.Errorf("attachment: %v", err) + } else { + defer file.Close() + content, readErr := io.ReadAll(file) + if readErr != nil { + t.Errorf("read attachment: %v", readErr) + } + if header.Filename != "app.bin" { + t.Errorf("multipart filename = %q", header.Filename) + } + uploaded = string(content) + } + _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + reader := &trackedReader{Reader: strings.NewReader("streamed bytes")} + + evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader) + if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) { + t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) + } + if uploaded != "streamed bytes" || reader.closed { + t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed) + } +} + +func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) + var failure *forge.Failure + if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) { + t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) + } +} + +func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) { + for _, test := range []struct { + status int + disposition forge.WriteDisposition + }{ + {status: http.StatusBadRequest, disposition: forge.WriteRejected}, + {status: http.StatusConflict, disposition: forge.WriteConflict}, + {status: http.StatusInternalServerError, disposition: forge.WriteUncertain}, + } { + t.Run(http.StatusText(test.status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) + if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) { + t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) + } + }) + } +} + +func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) + if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) { + t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) + } +} + +func validCreateReleaseRequest() forge.CreateReleaseRequest { + return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"} +} + +type trackedReader struct { + io.Reader + closed bool +} + +func (reader *trackedReader) Close() error { + reader.closed = true + return nil +} diff --git a/internal/gitea/upload.go b/internal/gitea/upload.go index 1f3cdd7..275ab46 100644 --- a/internal/gitea/upload.go +++ b/internal/gitea/upload.go @@ -146,7 +146,12 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele if _, needsUpload := missing[item.Filename]; !needsUpload { continue } - status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item) + content, openErr := assetContent(item) + if openErr != nil { + return result, failure(FailureInvalid, "open local release asset", 0, openErr) + } + status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item.Filename, content) + _ = content.Close() if uploadErr == nil { result.Uploaded = append(result.Uploaded, identity(item)) result.Pending = removeIdentity(result.Pending, item.Filename) @@ -198,36 +203,25 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele return result, nil } -func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset artifact.ExpectedAsset) (int, error) { +func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) { + if content == nil { + return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required")) + } uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" parsed, err := url.Parse(uploadURL) if err != nil { return 0, failure(FailureInvalid, "prepare asset upload", 0, err) } query := parsed.Query() - query.Set("name", asset.Filename) + query.Set("name", filename) parsed.RawQuery = query.Encode() reader, writer := io.Pipe() multipartWriter := multipart.NewWriter(writer) writeDone := make(chan error, 1) go func() { - part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename) + part, partErr := multipartWriter.CreateFormFile("attachment", filename) if partErr == nil { - var source io.Reader - if len(asset.Content) > 0 { - source = bytes.NewReader(asset.Content) - } else { - file, openErr := os.Open(asset.Path) - if openErr != nil { - partErr = openErr - } else { - defer file.Close() - source = file - } - } - if partErr == nil { - _, partErr = io.Copy(part, source) - } + _, partErr = io.Copy(part, content) } closeErr := multipartWriter.Close() if partErr == nil { @@ -261,7 +255,7 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api if err := decodeJSON(response.Body, &uploaded); err != nil { return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err) } - if uploaded.ID <= 0 || uploaded.Name != asset.Filename { + if uploaded.ID <= 0 || uploaded.Name != filename { return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset")) } if writeErr != nil { @@ -270,6 +264,13 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api return response.StatusCode, nil } +func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) { + if len(asset.Content) > 0 { + return io.NopCloser(bytes.NewReader(asset.Content)), nil + } + return os.Open(asset.Path) +} + func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset { result := make([]artifact.ExpectedAsset, 0, len(observations)) for _, observation := range observations {