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

@@ -2,9 +2,10 @@
ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, and `version` and `check` commands are implemented.
Publication, build, delivery, and release automation remain to be implemented.
Their selected behavior and sequence are described in the
local candidate check, source-tag publication API, and `version` and `check`
commands are implemented. Push reconciliation, a publication command, builds,
delivery, and release automation remain to be implemented. Their selected
behavior and sequence are described in the
[product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md).

View File

@@ -32,3 +32,18 @@ A successful check is evidence about the local candidate only. It does not
check whether a remote branch or tag is available and does not create a tag.
The [`check` command](../cli.md) reads the product and optional validation
commands from `releasekit.json`.
## Publishing the source tag
`releasekit.Publish` uses the same request and performs candidate validation,
authoritative remote checks, annotated-tag creation, and an exact tag push. The
selected remote must publish `main` at the captured commit and must not already
contain the selected version tag. A successful result includes the endpoint and
tag object ID. Publication states distinguish an unattempted push, a tag that
was created locally but not pushed, a successful push, a guard conflict, and an
unresolved push error.
When a push reports an error, the local annotated tag is retained and the
result is `unknown` until a later implementation adds authoritative
reconciliation. Do not treat that state as success or proof that the remote
lacks the tag.

View File

@@ -18,11 +18,14 @@ Implemented internal packages:
its committed release note and local tag state, runs validation once, and
rechecks mutable repository state.
- `internal/git` resolves one effective push destination, applies it to a
command-scoped remote, and reads exact authoritative branch and tag refs.
command-scoped remote, reads exact authoritative branch and tag refs, and
creates and pushes exact annotated tag objects.
- `internal/publish` coordinates local and remote guards around one validation
run and reports the publication state reached by that invocation.
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, and adapts configured checks to the process runner.
The executable controller uses the runtime facade and the command-owned CLI
adapter. The adapter uses the process runner and imports neither public facade.
Release orchestration, build, Git, and Gitea implementation packages are
introduced as their responsibilities are implemented.
The publication operation coordinates the candidate and Git packages. Build
and Gitea implementation packages will be added as those responsibilities land.

View File

@@ -591,7 +591,7 @@ and unchanged persistent config and refs.
## Stage 7: Create and push the exact annotated tag
Status: Pending. Depends on Stage 6.
Status: Complete. Depends on Stage 6.
1. Implement public publication request/result values and explicit conversion
into the engine. Validate once between the complete preflight guards.
@@ -609,6 +609,13 @@ cannot expand publication. Note content, tag identity, and one-validation
behavior are checked through the public facade. Restrict stage trials to test
remotes while failure handling is incomplete.
Evidence: the public `releasekit.Publish` API captures the committed note,
validates once between exact remote guards, creates an unsigned annotated tag,
rechecks local and remote state, and pushes only the captured tag object. Bare
remote tests verify the note bytes, peeled commit, tag object, unchanged branch
and unrelated refs, disabled follow-tags and mirror expansion, validation count,
remote conflicts, and retained local tags with `unknown` status after push error.
## Stage 8: Resolve rejected and ambiguous publication
Status: Pending. Depends on Stage 7.

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

83
publish.go Normal file
View File

@@ -0,0 +1,83 @@
package releasekit
import (
"context"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
)
type PublicationState string
const (
// PublicationNotAttempted means no tag push was attempted.
PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted)
// PublicationNotPublished means this invocation knows the tag was not published.
PublicationNotPublished PublicationState = PublicationState(publish.NotPublished)
// PublicationPublished means Git reported a successful push of the intended tag object.
PublicationPublished PublicationState = PublicationState(publish.Published)
// PublicationConflict means an authoritative branch or tag guard failed.
PublicationConflict PublicationState = PublicationState(publish.Conflict)
// PublicationUnknown means a tag push failed and its remote outcome is unresolved.
PublicationUnknown PublicationState = PublicationState(publish.Unknown)
)
type PublicationPhase string
const (
// PublicationPreflight identifies local and remote candidate inspection.
PublicationPreflight PublicationPhase = PublicationPhase(publish.Preflight)
// PublicationValidation identifies the selected validation callback or default suite.
PublicationValidation PublicationPhase = PublicationPhase(publish.Validation)
// PublicationTagCreation identifies creation and inspection of the local annotated tag.
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
// PublicationPush identifies the remote tag push attempt.
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
// PublicationComplete identifies a successful tag push.
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
)
// PublicationResult describes the publication state reached by this call.
type PublicationResult struct {
Version string // selected stable version
CandidateCommit string // captured main commit
Endpoint string // captured effective push URL
Phase PublicationPhase // last operation phase reached
TagObject string // local annotated tag object ID, when created
State PublicationState // publication outcome for this invocation
}
// Publish validates the captured candidate and pushes its exact annotated tag
// object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and
// errors after local tag creation but before push are not_published. A failed
// push is unknown until remote reconciliation is available. A nil error means
// the push succeeded and the result state is published.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil {
validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.Validate(ctx, Candidate{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
}
}
result, err := publish.Publish(ctx, publish.Request{
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
Remote: request.Remote,
Validate: validate,
Output: request.Output,
})
return PublicationResult{
Version: result.Version,
CandidateCommit: result.CandidateCommit,
Endpoint: result.Endpoint,
Phase: PublicationPhase(result.Phase),
TagObject: result.TagObject,
State: PublicationState(result.State),
}, err
}

221
publish_test.go Normal file
View File

@@ -0,0 +1,221 @@
package releasekit_test
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
repo := newPublishFixture(t)
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
commit := repo.commit
var validations int
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(_ context.Context, candidate releasekit.Candidate) error {
validations++
if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" {
t.Fatalf("callback candidate = %#v", candidate)
}
return nil
},
})
if err != nil {
t.Fatal(err)
}
if validations != 1 {
t.Fatalf("validation calls = %d, want 1", validations)
}
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result)
}
if result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
t.Fatalf("publication identity = %#v", result)
}
refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
for _, ref := range strings.Split(refsBefore, "\n") {
if !strings.Contains(refsAfter, ref) {
t.Fatalf("preexisting remote ref %q changed or disappeared; refs now:\n%s", ref, refsAfter)
}
}
remoteTag := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3")
if remoteTag != result.TagObject {
t.Fatalf("remote tag object = %s, want %s", remoteTag, result.TagObject)
}
if peeled := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3^{}"); peeled != commit {
t.Fatalf("remote tag peels to %s, want %s", peeled, commit)
}
if tagType := gitText(t, repo.bare, "cat-file", "-t", "refs/tags/v1.2.3"); tagType != "tag" {
t.Fatalf("remote tag object type = %q, want tag", tagType)
}
tagContents := gitBytes(t, repo.bare, "cat-file", "tag", "refs/tags/v1.2.3")
separator := strings.Index(tagContents, "\n\n")
if separator < 0 || tagContents[separator+2:] != releaseNote {
t.Fatalf("annotated tag message = %q, want %q", tagContents[separator+2:], releaseNote)
}
if strings.Contains(tagContents, "-----BEGIN PGP SIGNATURE-----") {
t.Fatal("publication tag unexpectedly contains a signature")
}
if !strings.Contains(refsAfter, "refs/heads/unrelated") || !strings.Contains(refsAfter, "refs/tags/v0.9.0") {
t.Fatalf("publication changed unrelated refs:\n%s", refsAfter)
}
if !strings.Contains(refsAfter, "refs/tags/v1.2.3") || strings.Count(refsAfter, "refs/tags/") != 2 {
t.Fatalf("publication pushed unexpected tag refs:\n%s", refsAfter)
}
}
func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
repo := newPublishFixture(t)
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil },
})
if err == nil {
t.Fatal("Publish() succeeded despite rejected push hook")
}
if result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationPush || result.TagObject == "" {
t.Fatalf("publication result = %#v", result)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
}
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("rejected push unexpectedly published the tag")
}
}
func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
repo := newPublishFixture(t)
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
var validations int
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
validations++
return nil
},
})
if err == nil {
t.Fatal("Publish() succeeded with a conflicting authoritative main")
}
if validations != 0 || result.State != releasekit.PublicationConflict || result.TagObject != "" {
t.Fatalf("validation calls = %d, result = %#v, error = %v", validations, result, err)
}
}
func TestPublishRemoteChangeDuringValidationPreventsLocalTag(t *testing.T) {
repo := newPublishFixture(t)
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
return nil
},
})
if err == nil {
t.Fatal("Publish() succeeded after authoritative main changed during validation")
}
if result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationValidation || result.TagObject != "" {
t.Fatalf("publication result = %#v, error = %v", result, err)
}
if _, err := exec.Command("git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("tag was created after the remote guard failed")
}
}
type publishFixture struct {
work string
bare string
commit string
unrelatedCommit string
}
func newPublishFixture(t *testing.T) publishFixture {
t.Helper()
base := t.TempDir()
work := filepath.Join(base, "work")
bare := filepath.Join(base, "remote.git")
gitRun(t, base, "init", "-b", "main", work)
gitRun(t, work, "config", "user.name", "ReleaseKit Test")
gitRun(t, work, "config", "user.email", "releasekit@example.invalid")
writeFile(t, work, "docs/releases/v1.2.3.md", releaseNote)
writeFile(t, work, "source.txt", "candidate")
gitRun(t, work, "add", ".")
gitRun(t, work, "commit", "-m", "Candidate")
commit := gitText(t, work, "rev-parse", "HEAD")
gitRun(t, work, "init", "--bare", bare)
gitRun(t, work, "remote", "add", "origin", bare)
gitRun(t, work, "push", "origin", "HEAD:refs/heads/main")
gitRun(t, work, "push", "origin", "HEAD:refs/heads/unrelated")
gitRun(t, work, "tag", "v0.9.0", commit)
gitRun(t, work, "push", "origin", "refs/tags/v0.9.0:refs/tags/v0.9.0")
other := gitText(t, work, "commit-tree", gitText(t, work, "rev-parse", "HEAD^{tree}"), "-p", commit, "-m", "Other remote commit")
gitRun(t, work, "push", "origin", other+":refs/heads/other")
gitRun(t, work, "config", "remote.origin.mirror", "true")
gitRun(t, work, "config", "push.followTags", "true")
gitRun(t, work, "config", "tag.gpgSign", "true")
return publishFixture{work: work, bare: bare, commit: commit, unrelatedCommit: other}
}
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
}
func gitText(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return strings.TrimSuffix(string(output), "\n")
}
func gitBytes(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return string(output)
}
func writeFile(t *testing.T, root, name, contents string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}