Expose tagged-source delivery through facade and CLI

This commit is contained in:
2026-10-05 20:39:33 +00:00
parent eb2cab78bb
commit bdf6a155b5
23 changed files with 1215 additions and 40 deletions

View File

@@ -268,6 +268,11 @@ func validateNote(note []byte, product, version string) error {
return nil
}
// ValidateReleaseNote checks the title contract for a committed release note.
func ValidateReleaseNote(note []byte, product, version string) error {
return validateNote(note, product, version)
}
func containsSequence(elements, sequence []string) bool {
if len(sequence) == 0 || len(sequence) > len(elements) {
return false

View File

@@ -0,0 +1,177 @@
package delivery
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
type State string
const (
NotAttempted State = "not_attempted"
Complete State = "complete"
Partial State = "partial"
Conflict State = "conflict"
Unknown State = "unknown"
)
type BuildIdentity struct {
RepositoryDir string
CandidateCommit string
Version string
MainPackage string
BinaryName string
Targets []string
}
type Request struct {
RepositoryDir string
Product string
Version string
Destination gitea.Destination
Build *BuildIdentity
Assets []gitea.ExpectedAsset
}
type Result struct {
CandidateCommit string
ReleaseURL string
State State
Matching []gitea.AssetIdentity
Uploaded []gitea.AssetIdentity
Pending []gitea.AssetIdentity
Conflicting []gitea.AssetIdentity
Uncertain []gitea.AssetIdentity
}
func Deliver(ctx context.Context, request Request) (Result, error) {
result := Result{State: NotAttempted}
if !versioncontract.ValidStableVersion(request.Version) {
return result, candidate.ErrInvalidVersion
}
if strings.TrimSpace(request.Product) == "" {
return result, errors.New("product is required")
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err)
}
source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
if err != nil {
return result, fmt.Errorf("inspect tagged source: %w", err)
}
result.CandidateCommit = source.TagCommit
if source.Status != "" {
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
}
if source.Head != source.TagCommit {
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit)
}
if err := validateBuild(repositoryDir, source.TagCommit, request.Version, request.Build, request.Assets); err != nil {
return result, err
}
if len(request.Assets) > 0 {
if err := gitea.VerifyLocalAssets(request.Assets); err != nil {
return result, fmt.Errorf("verify build assets: %w", err)
}
}
notePath := "docs/releases/" + request.Version + ".md"
note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath)
if err != nil {
return result, err
}
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
return result, fmt.Errorf("release note %q: %w", notePath, err)
}
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
Destination: request.Destination,
Version: request.Version,
Commit: source.TagCommit,
Note: string(note),
})
if err != nil {
result.State = stateForError(err, false)
return result, err
}
result.ReleaseURL = releaseResult.Release.URL
if len(request.Assets) == 0 {
result.State = Complete
return result, nil
}
inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, request.Assets)
if err != nil {
result.State = NotAttempted
if releaseResult.Created {
result.State = Partial
}
result.Uncertain = assetIdentities(request.Assets)
return result, err
}
uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
result.Matching = uploadResult.Matching
result.Uploaded = uploadResult.Uploaded
result.Pending = uploadResult.Pending
result.Conflicting = uploadResult.Conflicting
result.Uncertain = uploadResult.Uncertain
result.State = State(uploadResult.State)
return result, uploadErr
}
func validateBuild(repositoryDir, commit, version string, build *BuildIdentity, assets []gitea.ExpectedAsset) error {
if build == nil {
if len(assets) != 0 {
return errors.New("assets require a build identity")
}
return nil
}
if len(build.Targets) == 0 {
if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets")
}
return nil
}
buildRepository, err := filepath.Abs(build.RepositoryDir)
if err != nil {
return fmt.Errorf("resolve build repository: %w", err)
}
if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version {
return errors.New("build result does not identify the selected tagged source")
}
if strings.TrimSpace(build.MainPackage) == "" || strings.TrimSpace(build.BinaryName) == "" || len(assets) != len(build.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
}
return nil
}
func stateForError(err error, pageCreated bool) State {
var failure *gitea.Failure
if errors.As(err, &failure) {
switch failure.Kind {
case gitea.FailureConflict:
return Conflict
case gitea.FailureUnknown:
return Unknown
}
}
if pageCreated {
return Partial
}
return NotAttempted
}
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
result := make([]gitea.AssetIdentity, 0, len(assets))
for _, asset := range assets {
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
}
return result
}

76
internal/git/source.go Normal file
View File

@@ -0,0 +1,76 @@
package git
import (
"context"
"fmt"
"path"
"strings"
)
type TaggedSource struct {
Head string
Status string
TagObject string
TagCommit string
TagObjectType string
}
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
// Both lightweight historical tags and annotated tags are supported.
func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) {
if !validVersionRef(version) {
return TaggedSource{}, fmt.Errorf("invalid tag version %q", version)
}
var source TaggedSource
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err)
}
source.Head = strings.TrimSpace(head)
if !validObjectID(source.Head) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head)
}
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
}
source.Status = strings.TrimSpace(status)
ref := "refs/tags/" + version
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil {
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err)
}
source.TagObject = strings.TrimSpace(object)
if !validObjectID(source.TagObject) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid tag object %q", source.TagObject)
}
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
if err != nil {
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err)
}
source.TagCommit = strings.TrimSpace(peeled)
if !validObjectID(source.TagCommit) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid tagged commit %q", source.TagCommit)
}
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil {
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err)
}
source.TagObjectType = strings.TrimSpace(tagType)
if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType)
}
return source, nil
}
// ReadCommitFile reads a repository-relative file from a specific commit.
func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]byte, error) {
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") {
return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit)
}
content, err := run(ctx, repositoryDir, nil, "show", commit+":"+name)
if err != nil {
return nil, fmt.Errorf("read committed file %q: %w", name, err)
}
return []byte(content), nil
}

View File

@@ -0,0 +1,46 @@
package git
import (
"context"
"strings"
"testing"
)
func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "tag", "v1.2.3")
gitRun(t, fixture.work, "checkout", "--detach", "HEAD")
lightweight, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3")
if err != nil {
t.Fatal(err)
}
if lightweight.Head != fixture.commit || lightweight.TagCommit != fixture.commit || lightweight.TagObject != fixture.commit || lightweight.TagObjectType != "commit" || lightweight.Status != "" {
t.Fatalf("lightweight source = %#v", lightweight)
}
gitRun(t, fixture.work, "tag", "-d", "v1.2.3")
gitRun(t, fixture.work, "tag", "-a", "v1.2.3", "-m", "Historical release")
annotated, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3")
if err != nil {
t.Fatal(err)
}
if annotated.Head != fixture.commit || annotated.TagCommit != fixture.commit || annotated.TagObject == fixture.commit || annotated.TagObjectType != "tag" || annotated.Status != "" {
t.Fatalf("annotated source = %#v", annotated)
}
}
func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "tag", "v1.2.3")
content, err := ReadCommitFile(context.Background(), fixture.work, fixture.commit, "source.txt")
if err != nil || string(content) != "candidate" {
t.Fatalf("committed content = %q, error = %v", content, err)
}
writeRepoFile(t, fixture.work, "untracked.txt", "dirty")
source, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(source.Status, "untracked.txt") {
t.Fatalf("dirty status = %q", source.Status)
}
}

View File

@@ -33,12 +33,26 @@ type AssetIdentity struct {
}
type UploadResult struct {
State DeliveryState
Matching []AssetIdentity
Uploaded []AssetIdentity
Pending []AssetIdentity
State DeliveryState
Matching []AssetIdentity
Uploaded []AssetIdentity
Pending []AssetIdentity
Conflicting []AssetIdentity
Uncertain []AssetIdentity
Uncertain []AssetIdentity
}
// VerifyLocalAssets checks that every expected file still matches its declared
// size and digest before delivery begins.
func VerifyLocalAssets(assets []ExpectedAsset) error {
if err := validateExpectedAssets(assets); err != nil {
return err
}
for _, asset := range assets {
if err := verifyLocalAsset(asset); err != nil {
return err
}
}
return nil
}
// UploadMissing uploads assets classified missing by InspectAssets. It stops