Add Gitea release page transport
This commit is contained in:
@@ -51,6 +51,8 @@ failure recovery.
|
|||||||
read their embedded version through `buildinfo`.
|
read their embedded version through `buildinfo`.
|
||||||
- [Build guidance](docs/consumers/build.md): compile selected Go targets with an
|
- [Build guidance](docs/consumers/build.md): compile selected Go targets with an
|
||||||
injected application version.
|
injected application version.
|
||||||
|
- [Gitea integration](docs/integrations/gitea.md): hosted release-page
|
||||||
|
operations and their failure behavior.
|
||||||
- [Candidate check guidance](docs/consumers/check.md): local candidate validation
|
- [Candidate check guidance](docs/consumers/check.md): local candidate validation
|
||||||
through the Go API and CLI.
|
through the Go API and CLI.
|
||||||
- [Maintainer release procedure](docs/release.md): ReleaseKit's source-only
|
- [Maintainer release procedure](docs/release.md): ReleaseKit's source-only
|
||||||
|
|||||||
33
docs/integrations/gitea.md
Normal file
33
docs/integrations/gitea.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Gitea release pages
|
||||||
|
|
||||||
|
ReleaseKit's internal Gitea client verifies a hosted source tag and finds or
|
||||||
|
creates its stable release page. The public delivery facade and CLI workflow
|
||||||
|
are planned separately; this integration guide describes the implemented
|
||||||
|
internal operation.
|
||||||
|
|
||||||
|
The caller supplies the Gitea base URL, owner, repository name, and token
|
||||||
|
explicitly. The client does not read ambient credentials. The token is sent in
|
||||||
|
the `Authorization: token …` request header and is not included in errors or
|
||||||
|
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
||||||
|
a query, or a fragment.
|
||||||
|
|
||||||
|
For version `vMAJOR.MINOR.PATCH`, the client first reads
|
||||||
|
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
||||||
|
SHA to equal the captured source commit. It then reads
|
||||||
|
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
||||||
|
must have that tag, the complete committed release note as its body, and stable
|
||||||
|
non-draft state. Its existing title is retained. ReleaseKit does not edit a
|
||||||
|
conflicting page.
|
||||||
|
|
||||||
|
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
|
||||||
|
with the selected tag and commit, the release note's H1 as title, the complete
|
||||||
|
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
||||||
|
response, or malformed create response triggers one lookup. An exact page found
|
||||||
|
there confirms the existing content; a differing page is a conflict. If that
|
||||||
|
lookup cannot determine the result after a possibly successful write, the
|
||||||
|
client returns an unknown outcome. Other HTTP failures retain their structured
|
||||||
|
failure kind and status code.
|
||||||
|
|
||||||
|
The integration tests use a local HTTP fixture and assert request paths,
|
||||||
|
payloads, authorization, and failure handling. They do not contact a live Gitea
|
||||||
|
service or use real credentials.
|
||||||
@@ -16,6 +16,8 @@ Implemented internal packages:
|
|||||||
overlays.
|
overlays.
|
||||||
- `internal/build` compiles explicit Go targets with shared version injection,
|
- `internal/build` compiles explicit Go targets with shared version injection,
|
||||||
verifies native version output, and returns generated file digests.
|
verifies native version output, and returns generated file digests.
|
||||||
|
- `internal/gitea` verifies hosted source tags and finds or creates matching
|
||||||
|
stable release pages through the Gitea REST API.
|
||||||
- `internal/candidate` captures and validates one clean local candidate, checks
|
- `internal/candidate` captures and validates one clean local candidate, checks
|
||||||
its committed release note and local tag state, runs validation once, and
|
its committed release note and local tag state, runs validation once, and
|
||||||
rechecks mutable repository state.
|
rechecks mutable repository state.
|
||||||
@@ -30,5 +32,7 @@ Implemented internal packages:
|
|||||||
|
|
||||||
The executable controller uses the runtime facade and the command-owned CLI
|
The executable controller uses the runtime facade and the command-owned CLI
|
||||||
adapter. The adapter uses the process runner and imports neither public facade.
|
adapter. The adapter uses the process runner and imports neither public facade.
|
||||||
The publication operation coordinates the candidate and Git packages. Build
|
The publication operation coordinates the candidate and Git packages. The
|
||||||
and Gitea implementation packages will be added as those responsibilities land.
|
internal Gitea client is documented in the
|
||||||
|
[Gitea integration guide](../integrations/gitea.md); delivery orchestration will
|
||||||
|
use it when that facade and CLI work lands.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReleaseKit staged implementation plan
|
# ReleaseKit staged implementation plan
|
||||||
|
|
||||||
Status: In progress. Stages 1–11 are complete; later stages remain pending.
|
Status: In progress. Stages 1–12 are complete; later stages remain pending.
|
||||||
|
|
||||||
Date: 2026-10-05
|
Date: 2026-10-05
|
||||||
|
|
||||||
@@ -736,7 +736,7 @@ no publication or consumer pilot is needed to complete this stage.
|
|||||||
|
|
||||||
## Stage 12: Find and create Gitea release pages
|
## Stage 12: Find and create Gitea release pages
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 11.
|
Status: Complete. Depends on Stage 11.
|
||||||
|
|
||||||
1. Implement the small internal REST transport using the served API operations,
|
1. Implement the small internal REST transport using the served API operations,
|
||||||
explicit destination/token inputs, context cancellation, and structured
|
explicit destination/token inputs, context cancellation, and structured
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
|
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
|
||||||
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
|
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
|
||||||
current implemented boundary; delivery and broader integrations remain
|
current implemented boundary; the internal Gitea page client is implemented,
|
||||||
|
while asset delivery, facade/CLI integration, and broader integrations remain
|
||||||
planned. The public facade architecture is accepted in
|
planned. The public facade architecture is accepted in
|
||||||
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
|
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
|
||||||
candidate validation, and source publication.
|
candidate validation, and source publication.
|
||||||
@@ -55,9 +56,9 @@ cleanup.
|
|||||||
The [architecture policy](../policy/architecture.md) owns the adopted
|
The [architecture policy](../policy/architecture.md) owns the adopted
|
||||||
responsibility and dependency rules, including the runtime version boundary.
|
responsibility and dependency rules, including the runtime version boundary.
|
||||||
The root `releasekit` package provides the public facade for candidate
|
The root `releasekit` package provides the public facade for candidate
|
||||||
validation, source publication, and selected-target builds. Delivery remains
|
validation, source publication, and selected-target builds. The internal
|
||||||
planned. The separate public `buildinfo` package provides lightweight runtime
|
Gitea page client is implemented; delivery orchestration remains planned. The
|
||||||
version access.
|
separate public `buildinfo` package provides lightweight runtime version access.
|
||||||
|
|
||||||
Each facade owns its public named types and documented contracts. Public
|
Each facade owns its public named types and documented contracts. Public
|
||||||
operations, inputs, outcomes, errors, and callbacks express deliberately
|
operations, inputs, outcomes, errors, and callbacks express deliberately
|
||||||
|
|||||||
367
internal/gitea/release.go
Normal file
367
internal/gitea/release.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// Package gitea implements the internal Gitea REST operations used by delivery.
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FailureKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FailureInvalid FailureKind = "invalid_request"
|
||||||
|
FailureNotFound FailureKind = "not_found"
|
||||||
|
FailureUnauthorized FailureKind = "unauthorized"
|
||||||
|
FailureConflict FailureKind = "conflict"
|
||||||
|
FailureMalformed FailureKind = "malformed_response"
|
||||||
|
FailureTransport FailureKind = "transport"
|
||||||
|
FailureRejected FailureKind = "rejected"
|
||||||
|
FailureUnknown FailureKind = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Failure describes how a Gitea operation failed. StatusCode is zero when no
|
||||||
|
// HTTP response was received.
|
||||||
|
type Failure struct {
|
||||||
|
Kind FailureKind
|
||||||
|
Operation string
|
||||||
|
StatusCode int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Failure) Error() string {
|
||||||
|
if f.StatusCode != 0 {
|
||||||
|
return fmt.Sprintf("gitea %s: %s (HTTP %d)", f.Operation, f.Kind, f.StatusCode)
|
||||||
|
}
|
||||||
|
if f.Err != nil {
|
||||||
|
return fmt.Sprintf("gitea %s: %s: %v", f.Operation, f.Kind, f.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("gitea %s: %s", f.Operation, f.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Failure) Unwrap() error { return f.Err }
|
||||||
|
|
||||||
|
type Destination struct {
|
||||||
|
BaseURL string
|
||||||
|
Owner string
|
||||||
|
Repository string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Destination Destination
|
||||||
|
Version string
|
||||||
|
Commit string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
ID int64
|
||||||
|
URL string
|
||||||
|
TagName string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
Draft bool
|
||||||
|
Prerelease bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Release Release
|
||||||
|
Created bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagResponse struct {
|
||||||
|
Commit struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
TargetCommitish string `json:"target_commitish"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createReleaseRequest struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
TargetCommitish string `json:"target_commitish"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
||||||
|
// page. It never edits a release that already exists.
|
||||||
|
func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) {
|
||||||
|
if err := validateRequest(request); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
base, err := parseBaseURL(request.Destination.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||||
|
}
|
||||||
|
client := c.httpClient()
|
||||||
|
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||||
|
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version)
|
||||||
|
|
||||||
|
var tag tagResponse
|
||||||
|
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if tag.Commit.SHA != request.Commit {
|
||||||
|
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version)
|
||||||
|
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||||
|
if err == nil {
|
||||||
|
return acceptExisting(existing, request)
|
||||||
|
}
|
||||||
|
if status != http.StatusNotFound {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
title := noteTitle(request.Note)
|
||||||
|
body, err := json.Marshal(createReleaseRequest{
|
||||||
|
TagName: request.Version, TargetCommitish: request.Commit,
|
||||||
|
Name: title, Body: request.Note,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, failure(FailureInvalid, "encode release", 0, err)
|
||||||
|
}
|
||||||
|
created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body)
|
||||||
|
if postErr == nil {
|
||||||
|
return acceptCreated(created, request)
|
||||||
|
}
|
||||||
|
if !shouldReconcileCreate(postStatus, postErr) {
|
||||||
|
return Result{}, postErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// A conflict or interrupted server response may have followed a successful
|
||||||
|
// create. One lookup resolves only a page with the exact selected content.
|
||||||
|
retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||||
|
if retryErr == nil {
|
||||||
|
result, matchErr := acceptExisting(retry, request)
|
||||||
|
if matchErr == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr)
|
||||||
|
}
|
||||||
|
if retryStatus == http.StatusNotFound {
|
||||||
|
if postStatus == http.StatusConflict {
|
||||||
|
return Result{}, failure(FailureConflict, "create release", postStatus, postErr)
|
||||||
|
}
|
||||||
|
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr)
|
||||||
|
}
|
||||||
|
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldReconcileCreate(status int, err error) bool {
|
||||||
|
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var failure *Failure
|
||||||
|
return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNotFound {
|
||||||
|
return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||||
|
}
|
||||||
|
var decoded releaseResponse
|
||||||
|
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||||
|
return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||||
|
}
|
||||||
|
return decoded.Release(), response.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return failure(FailureInvalid, operation, 0, err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return failure(FailureTransport, operation, 0, err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return responseFailure(operation, response.StatusCode)
|
||||||
|
}
|
||||||
|
if err := decodeJSON(response.Body, destination); err != nil {
|
||||||
|
return failure(FailureMalformed, operation, response.StatusCode, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||||
|
}
|
||||||
|
var decoded releaseResponse
|
||||||
|
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||||
|
return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||||
|
}
|
||||||
|
return decoded.Release(), response.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) httpClient() *http.Client {
|
||||||
|
client := http.DefaultClient
|
||||||
|
if c.HTTPClient != nil {
|
||||||
|
clientCopy := *c.HTTPClient
|
||||||
|
clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||||
|
return &clientCopy
|
||||||
|
}
|
||||||
|
clientCopy := *client
|
||||||
|
clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||||
|
return &clientCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRequest(request Request) error {
|
||||||
|
if !versioncontract.ValidStableVersion(request.Version) {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required"))
|
||||||
|
}
|
||||||
|
if !validObjectID(request.Commit) {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.Destination.Token) == "" {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required"))
|
||||||
|
}
|
||||||
|
if noteTitle(request.Note) == "" {
|
||||||
|
return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBaseURL(raw string) (*url.URL, error) {
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return nil, errors.New("base URL must be an absolute HTTP(S) origin or path without credentials, query, or fragment")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptExisting(release Release, request Request) (Result, error) {
|
||||||
|
if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||||
|
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||||
|
}
|
||||||
|
return Result{Release: release}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptCreated(release Release, request Request) (Result, error) {
|
||||||
|
if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||||
|
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||||
|
}
|
||||||
|
return Result{Release: release, Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response releaseResponse) Release() Release {
|
||||||
|
return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteTitle(note string) string {
|
||||||
|
first := strings.SplitN(note, "\n", 2)[0]
|
||||||
|
if !strings.HasPrefix(first, "# ") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validObjectID(value string) bool {
|
||||||
|
if len(value) != 40 && len(value) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFailure(operation string, status int) error {
|
||||||
|
kind := FailureRejected
|
||||||
|
switch status {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
kind = FailureUnauthorized
|
||||||
|
case http.StatusConflict:
|
||||||
|
kind = FailureConflict
|
||||||
|
case http.StatusNotFound:
|
||||||
|
kind = FailureNotFound
|
||||||
|
}
|
||||||
|
return failure(kind, operation, status, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func failure(kind FailureKind, operation string, status int, err error) error {
|
||||||
|
return &Failure{Kind: kind, Operation: operation, StatusCode: status, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(reader io.Reader, destination any) error {
|
||||||
|
decoder := json.NewDecoder(reader)
|
||||||
|
if err := decoder.Decode(destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("response contains multiple JSON values")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
341
internal/gitea/release_test.go
Normal file
341
internal/gitea/release_test.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testCommit = "0123456789012345678901234567890123456789"
|
||||||
|
const testNote = "# Example v1.2.3\n\nRelease details.\n"
|
||||||
|
const testToken = "fixture-secret-token"
|
||||||
|
|
||||||
|
func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
||||||
|
server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false)
|
||||||
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Created || result.Release.Title != "Historical title" {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||||
|
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||||
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
|
||||||
|
server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false)
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureConflict {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
if got := state.posts; got != 0 {
|
||||||
|
t.Fatalf("release creates = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureUnauthorized {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) {
|
||||||
|
t.Fatalf("error exposes unexpected content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
||||||
|
posts := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
posts++
|
||||||
|
}
|
||||||
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
|
_, _ = w.Write([]byte("{"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureMalformed || posts != 0 {
|
||||||
|
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
||||||
|
posts := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
posts++
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureConflict || posts != 0 {
|
||||||
|
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
||||||
|
posts := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
posts++
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureNotFound || posts != 0 {
|
||||||
|
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||||
|
defer server.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
||||||
|
if !errors.Is(err, context.Canceled) || called {
|
||||||
|
t.Fatalf("error = %v, server called = %t", err, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
||||||
|
created := false
|
||||||
|
lookups := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertAuth(t, r)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
writeTag(t, w, testCommit)
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
lookups++
|
||||||
|
if !created {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRelease(t, w, releaseFixture{title: "Example v1.2.3"})
|
||||||
|
case "/api/v1/repos/owner/project/releases":
|
||||||
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
|
var payload createReleaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||||
|
t.Fatalf("create payload = %#v", payload)
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("HTTP writer does not support hijacking")
|
||||||
|
}
|
||||||
|
connection, _, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Created || lookups != 2 {
|
||||||
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
||||||
|
lookups := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertAuth(t, r)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
writeTag(t, w, testCommit)
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
lookups++
|
||||||
|
if lookups == 1 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRelease(t, w, releaseFixture{title: "Created by another request"})
|
||||||
|
case "/api/v1/repos/owner/project/releases":
|
||||||
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Created || result.Release.Title != "Created by another request" || lookups != 2 {
|
||||||
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
||||||
|
lookups := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertAuth(t, r)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
writeTag(t, w, testCommit)
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
lookups++
|
||||||
|
if lookups == 1 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||||
|
case "/api/v1/repos/owner/project/releases":
|
||||||
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
|
if failureKind(t, err) != FailureUnknown || lookups != 2 {
|
||||||
|
t.Fatalf("error = %v, release lookups = %d", err, lookups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseFixture struct {
|
||||||
|
title string
|
||||||
|
body string
|
||||||
|
draft bool
|
||||||
|
pre bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixtureState struct{ posts int }
|
||||||
|
|
||||||
|
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
||||||
|
t.Helper()
|
||||||
|
state := &fixtureState{}
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertAuth(t, r)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
writeTag(t, w, testCommit)
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
if existing.title == "" && existing.body == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRelease(t, w, existing)
|
||||||
|
case "/api/v1/repos/owner/project/releases":
|
||||||
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
|
state.posts++
|
||||||
|
var payload createReleaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||||
|
t.Fatalf("create payload = %#v", payload)
|
||||||
|
}
|
||||||
|
if !createMissing {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRelease(t, w, releaseFixture{title: payload.Name})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
return server, state
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTag(t *testing.T, w http.ResponseWriter, commit string) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixture releaseFixture) bodyOrDefault() string {
|
||||||
|
if fixture.body == "" {
|
||||||
|
return testNote
|
||||||
|
}
|
||||||
|
return fixture.body
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if request.Method != method || request.URL.Path != path {
|
||||||
|
t.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
||||||
|
}
|
||||||
|
assertAuth(t, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAuth(t *testing.T, request *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
||||||
|
t.Fatalf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClient(server *httptest.Server) Client {
|
||||||
|
return Client{HTTPClient: server.Client()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequest(baseURL string) Request {
|
||||||
|
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureKind(t *testing.T, err error) FailureKind {
|
||||||
|
t.Helper()
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error type = %T (%v), want *Failure", err, err)
|
||||||
|
}
|
||||||
|
return failure.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(value, substring string) bool {
|
||||||
|
return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user