Return write evidence from Gitea operations

This commit is contained in:
2026-11-01 18:02:05 +00:00
parent 02bd120aed
commit ddac9e3c73
4 changed files with 280 additions and 22 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {