Files
releasekit/internal/git/tag.go

135 lines
5.5 KiB
Go

package git
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"strings"
)
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
// on the captured commit. Once its type and ID are established, the ID is
// returned even if a later verification fails.
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
return createAnnotatedTag(ctx, repositoryDir, tag, commit, note, os.Remove)
}
func createAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte, remove func(string) error) (objectID string, resultErr error) {
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return "", err
}
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 func() {
if err := remove(messagePath); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary release note %q: %w", messagePath, err))
}
}()
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, tag, commit); err != nil {
return "", fmt.Errorf("create annotated tag %s: %w", tag, err)
}
ref := "refs/tags/" + tag
object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note)
if err != nil {
return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err)
}
return object, nil
}
func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) {
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil {
return "", fmt.Errorf("inspect created tag %s: %w", ref, err)
}
if strings.TrimSpace(tagType) != "tag" {
return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, 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", ref, err)
}
tagObject = strings.TrimSpace(tagObject)
if !validObjectID(tagObject) {
return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, tagObject)
}
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
if err != nil {
return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err)
}
if strings.TrimSpace(peeled) != commit {
return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, strings.TrimSpace(peeled), commit)
}
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
if err != nil {
return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, err)
}
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref)
}
return tagObject, nil
}
// QueryTag verifies the captured endpoint still resolves to itself, then reads
// one exact authoritative version tag and its peeled commit. Failed
// verification or reads are unreadable and never interpreted as absence.
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return RemoteRef{}, err
}
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err)
}
ref := "refs/tags/" + tag
peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
if err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
}
remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) {
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
}
switch name {
case ref:
remoteTag = mergeRef(remoteTag, object, false)
case peeledRef:
remoteTag = mergeRef(remoteTag, object, true)
}
}
return finalizeRef(remoteTag), nil
}
// PushTag publishes only the captured tag object to its exact tag ref.
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return err
}
if !validObjectID(tagObject) {
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
}
refspec := tagObject + ":refs/tags/" + tag
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
}
return nil
}