Publish the captured release tag

This commit is contained in:
2026-10-05 18:24:37 +00:00
parent 7d2c0c10d4
commit 3757d40ac4
10 changed files with 642 additions and 22 deletions

View File

@@ -21,6 +21,7 @@ type Candidate struct {
RepositoryDir string
Commit string
Version string
Note string
}
type Request struct {
@@ -32,6 +33,22 @@ type Request struct {
}
func Check(ctx context.Context, request Request) (Candidate, error) {
captured, err := Capture(ctx, request)
if err != nil {
return Candidate{}, err
}
if err := Validate(ctx, request, captured); err != nil {
return Candidate{}, err
}
if err := Recheck(ctx, captured, ""); err != nil {
return Candidate{}, err
}
return captured, nil
}
// Capture validates request identity and records one clean local candidate,
// including the note bytes from the captured commit. It does not run checks.
func Capture(ctx context.Context, request Request) (Candidate, error) {
if !validStableVersion(request.Version) {
return Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
}
@@ -68,36 +85,61 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
return Candidate{}, fmt.Errorf("local tag %q already exists", request.Version)
}
captured := Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version}
return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil
}
// Validate runs the complete selected validation once for the captured
// candidate. A supplied callback replaces the default offline Go validation.
func Validate(ctx context.Context, request Request, captured Candidate) error {
var err error
if request.Validate == nil {
err = runDefaultValidation(ctx, captured.RepositoryDir, request.Output)
} else {
err = request.Validate(ctx, captured)
}
if err != nil {
return Candidate{}, fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
}
return nil
}
current, err := inspectLocalState(ctx, repositoryDir)
// Recheck verifies that the captured local source is unchanged. When
// expectedTagObject is empty, the selected local version tag must still be
// absent; otherwise the existing annotated tag must still have that object ID.
func Recheck(ctx context.Context, captured Candidate, expectedTagObject string) error {
current, err := inspectLocalState(ctx, captured.RepositoryDir)
if err != nil {
return Candidate{}, fmt.Errorf("recheck local candidate state: %w", err)
return fmt.Errorf("recheck local candidate state: %w", err)
}
if current.branch != "main" {
return Candidate{}, fmt.Errorf("release candidate branch changed during validation to %q", current.branch)
return fmt.Errorf("release candidate branch changed to %q", current.branch)
}
if current.commit != captured.Commit {
return Candidate{}, fmt.Errorf("HEAD changed during validation from %s to %s", captured.Commit, current.commit)
return fmt.Errorf("HEAD changed from %s to %s", captured.Commit, current.commit)
}
if current.status != "" {
return Candidate{}, fmt.Errorf("worktree or index changed during validation:\n%s", current.status)
return fmt.Errorf("worktree or index changed:\n%s", current.status)
}
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
return Candidate{}, fmt.Errorf("recheck local tag %q: %w", request.Version, err)
} else if exists {
return Candidate{}, fmt.Errorf("local tag %q appeared during validation", request.Version)
if expectedTagObject == "" {
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil {
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err)
} else if exists {
return fmt.Errorf("local tag %q appeared during validation", captured.Version)
}
return nil
}
return captured, nil
object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Version)
if err != nil {
return fmt.Errorf("read local tag %q: %w", captured.Version, err)
}
tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Version)
if err != nil {
return fmt.Errorf("inspect local tag %q: %w", captured.Version, err)
}
if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" {
return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Version, expectedTagObject)
}
return nil
}
type localState struct {

View File

@@ -213,7 +213,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
gitRun(t, repository, "add", "later.txt")
gitRun(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "advance HEAD")
},
want: "HEAD changed during validation",
want: "HEAD changed from",
},
{
name: "worktree changes",
@@ -223,7 +223,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
t.Fatal(err)
}
},
want: "changed during validation",
want: "worktree or index changed",
},
}
for _, test := range tests {

105
internal/git/tag.go Normal file
View File

@@ -0,0 +1,105 @@
package git
import (
"bytes"
"context"
"fmt"
"os"
"strings"
)
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
// on the captured commit and returns the tag object's ID.
func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit string, note []byte) (string, error) {
if !validVersionRef(version) {
return "", fmt.Errorf("invalid version tag name %q", version)
}
if !validObjectID(commit) {
return "", fmt.Errorf("invalid candidate commit object ID %q", commit)
}
message, err := os.CreateTemp("", "releasekit-tag-message-*")
if err != nil {
return "", fmt.Errorf("create temporary release note: %w", err)
}
messagePath := message.Name()
defer os.Remove(messagePath)
if _, err := message.Write(note); err != nil {
_ = message.Close()
return "", fmt.Errorf("write temporary release note: %w", err)
}
if err := message.Close(); err != nil {
return "", fmt.Errorf("close temporary release note: %w", err)
}
if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, version, commit); err != nil {
return "", fmt.Errorf("create annotated tag %s: %w", version, err)
}
ref := "refs/tags/" + version
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil {
return "", fmt.Errorf("inspect created tag %s: %w", version, err)
}
if strings.TrimSpace(tagType) != "tag" {
return "", fmt.Errorf("created tag %s has object type %q, want tag", version, strings.TrimSpace(tagType))
}
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil {
return "", fmt.Errorf("read created tag object %s: %w", version, err)
}
tagObject = strings.TrimSpace(tagObject)
if !validObjectID(tagObject) {
return "", fmt.Errorf("created tag %s has invalid object ID %q", version, tagObject)
}
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
if err != nil {
return "", fmt.Errorf("read peeled commit for tag %s: %w", version, err)
}
if strings.TrimSpace(peeled) != commit {
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", version, strings.TrimSpace(peeled), commit)
}
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
if err != nil {
return "", fmt.Errorf("read annotated tag %s: %w", version, err)
}
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", version)
}
return tagObject, nil
}
// InspectLocalTag confirms the selected tag still points to the exact
// annotated tag object captured before publication.
func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject string) error {
if !validVersionRef(version) || !validObjectID(expectedObject) {
return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version)
}
ref := "refs/tags/" + version
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil {
return fmt.Errorf("inspect local tag %s: %w", version, err)
}
if strings.TrimSpace(tagType) != "tag" {
return fmt.Errorf("local tag %s has type %q, want annotated tag", version, strings.TrimSpace(tagType))
}
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil {
return fmt.Errorf("read local tag %s: %w", version, err)
}
if strings.TrimSpace(object) != expectedObject {
return fmt.Errorf("local tag %s identifies %s, want %s", version, strings.TrimSpace(object), expectedObject)
}
return nil
}
// PushTag publishes only the captured tag object to its exact tag ref.
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
if !validVersionRef(version) || !validObjectID(tagObject) {
return fmt.Errorf("invalid tag publication identity %q at %q", tagObject, version)
}
refspec := tagObject + ":refs/tags/" + version
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
return fmt.Errorf("push annotated tag %s to %s: %w", version, endpoint.PushURL, err)
}
return nil
}

143
internal/publish/publish.go Normal file
View File

@@ -0,0 +1,143 @@
// Package publish coordinates candidate validation and exact tag publication.
package publish
import (
"context"
"errors"
"fmt"
"io"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
type State string
const (
NotAttempted State = "not_attempted"
NotPublished State = "not_published"
Published State = "published"
Conflict State = "conflict"
Unknown State = "unknown"
)
type Phase string
const (
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Complete Phase = "complete"
)
type Request struct {
RepositoryDir string
Product string
Version string
Remote string
Validate func(context.Context, candidate.Candidate) error
Output io.Writer
}
type Result struct {
Version string
CandidateCommit string
Endpoint string
Phase Phase
TagObject string
State State
}
// Publish validates one captured candidate and pushes only its annotated tag
// object. A failed push remains Unknown until authoritative reconciliation is
// added by the following implementation stage.
func Publish(ctx context.Context, request Request) (Result, error) {
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
remoteName := request.Remote
if remoteName == "" {
remoteName = "origin"
}
checks := candidate.Request{
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
Validate: request.Validate,
Output: request.Output,
}
captured, err := candidate.Capture(ctx, checks)
if err != nil {
return result, fmt.Errorf("capture release candidate: %w", err)
}
result.CandidateCommit = captured.Commit
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
if err != nil {
return result, fmt.Errorf("resolve publication endpoint: %w", err)
}
result.Endpoint = endpoint.PushURL
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
}
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil {
if isRemoteConflict(err) {
result.State = Conflict
}
return result, fmt.Errorf("pre-validation remote guard: %w", err)
}
if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("pre-validation local guard: %w", err)
}
result.Phase = Validation
if err := candidate.Validate(ctx, checks, captured); err != nil {
return result, err
}
if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("post-validation local guard: %w", err)
}
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-publication endpoint guard: %w", err)
}
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil {
if isRemoteConflict(err) {
result.State = Conflict
}
return result, fmt.Errorf("pre-publication remote guard: %w", err)
}
result.Phase = TagCreation
result.State = NotPublished
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Version, captured.Commit, []byte(captured.Note))
if err != nil {
return result, err
}
result.TagObject = tagObject
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
return result, fmt.Errorf("pre-push local guard: %w", err)
}
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-push endpoint guard: %w", err)
}
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil {
if isRemoteConflict(err) {
result.State = Conflict
}
return result, fmt.Errorf("pre-push remote guard: %w", err)
}
result.Phase = Push
result.State = Unknown
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
return result, err
}
result.Phase = Complete
result.State = Published
return result, nil
}
func isRemoteConflict(err error) bool {
return errors.Is(err, git.ErrRemoteMainMissing) ||
errors.Is(err, git.ErrRemoteMainConflict) ||
errors.Is(err, git.ErrRemoteTagConflict)
}