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, tag, commit string, note []byte) (string, 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 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, tag, commit); err != nil { return "", fmt.Errorf("create annotated tag %s: %w", tag, err) } ref := "refs/tags/" + tag tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { return "", fmt.Errorf("inspect created tag %s: %w", tag, err) } if strings.TrimSpace(tagType) != "tag" { return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, 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", tag, err) } tagObject = strings.TrimSpace(tagObject) if !validObjectID(tagObject) { return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject) } peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}") if err != nil { return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err) } if strings.TrimSpace(peeled) != commit { return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, 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", tag, 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", tag) } 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, tag, expectedObject string) error { if err := checkTagRef(ctx, repositoryDir, tag); err != nil { return err } if !validObjectID(expectedObject) { return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag) } ref := "refs/tags/" + tag tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { return fmt.Errorf("inspect local tag %s: %w", tag, err) } if strings.TrimSpace(tagType) != "tag" { return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType)) } object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { return fmt.Errorf("read local tag %s: %w", tag, err) } if strings.TrimSpace(object) != expectedObject { return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject) } return 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, 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, line) } switch name { case ref: remoteTag = mergeRef(remoteTag, object, false) case peeledRef: remoteTag = mergeRef(remoteTag, object, true) } } return 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", "--porcelain", endpoint.ScopedName, refspec); err != nil { return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err) } return nil }