Publish the captured release tag
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
105
internal/git/tag.go
Normal 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
143
internal/publish/publish.go
Normal 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
83
publish.go
Normal 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
221
publish_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user