diff --git a/README.md b/README.md index 12bced9..1bb622f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 8a8eac6..6f4358c 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -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. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 24e5bd3..da6c851 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index dbb2f7f..fdf934e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/candidate/check.go b/internal/candidate/check.go index ac14402..73455c2 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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 { diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index bd4ccac..ca62679 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -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 { diff --git a/internal/git/tag.go b/internal/git/tag.go new file mode 100644 index 0000000..8856ba2 --- /dev/null +++ b/internal/git/tag.go @@ -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 +} diff --git a/internal/publish/publish.go b/internal/publish/publish.go new file mode 100644 index 0000000..e2c2cdc --- /dev/null +++ b/internal/publish/publish.go @@ -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) +} diff --git a/publish.go b/publish.go new file mode 100644 index 0000000..a77906a --- /dev/null +++ b/publish.go @@ -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 +} diff --git a/publish_test.go b/publish_test.go new file mode 100644 index 0000000..7a54b77 --- /dev/null +++ b/publish_test.go @@ -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) + } +}