Derive module release identity and accept full Git tags
This commit is contained in:
@@ -8,23 +8,26 @@ import (
|
||||
|
||||
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
||||
// against expectedCommit, and checks out that commit detached.
|
||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error {
|
||||
if !validVersionRef(version) || !validObjectID(expectedCommit) {
|
||||
return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version)
|
||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
ref := "refs/tags/" + version
|
||||
if !validObjectID(expectedCommit) {
|
||||
return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag)
|
||||
}
|
||||
ref := "refs/tags/" + tag
|
||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
||||
return fmt.Errorf("fetch tag %s from remote %s: %w", version, remote, err)
|
||||
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
|
||||
}
|
||||
source, err := InspectTaggedSource(ctx, repositoryDir, version)
|
||||
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(source.TagCommit, expectedCommit) {
|
||||
return fmt.Errorf("tag %s resolves to commit %s, expected %s", version, source.TagCommit, expectedCommit)
|
||||
return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit)
|
||||
}
|
||||
if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil {
|
||||
return fmt.Errorf("check out tag %s at %s: %w", version, source.TagCommit, err)
|
||||
return fmt.Errorf("check out tag %s at %s: %w", tag, source.TagCommit, err)
|
||||
}
|
||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ var (
|
||||
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
||||
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
||||
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
||||
ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote")
|
||||
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
||||
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ type RemoteRef struct {
|
||||
Peeled string
|
||||
}
|
||||
|
||||
// Snapshot is the result of querying refs/heads/main and one exact version tag.
|
||||
// Snapshot is the result of querying refs/heads/main and one exact tag.
|
||||
type Snapshot struct {
|
||||
Main RemoteRef
|
||||
Tag RemoteRef
|
||||
@@ -97,14 +97,14 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
||||
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
||||
}
|
||||
|
||||
// Query reads exact main and version-tag refs. A command failure is unreadable,
|
||||
// Query reads exact main and tag refs. A command failure is unreadable,
|
||||
// never evidence that a ref is absent.
|
||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) {
|
||||
if !validVersionRef(version) {
|
||||
return Snapshot{}, fmt.Errorf("invalid version tag name %q", version)
|
||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
mainRef := "refs/heads/main"
|
||||
tagRef := "refs/tags/" + version
|
||||
tagRef := "refs/tags/" + tag
|
||||
peeledRef := tagRef + "^{}"
|
||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||
if err != nil {
|
||||
@@ -112,7 +112,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
|
||||
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
|
||||
}
|
||||
main := RemoteRef{State: RefAbsent}
|
||||
tag := RemoteRef{State: RefAbsent}
|
||||
remoteTag := RemoteRef{State: RefAbsent}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
@@ -125,19 +125,19 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
|
||||
case mainRef:
|
||||
main = mergeRef(main, object, false)
|
||||
case tagRef:
|
||||
tag = mergeRef(tag, object, false)
|
||||
remoteTag = mergeRef(remoteTag, object, false)
|
||||
case peeledRef:
|
||||
tag = mergeRef(tag, object, true)
|
||||
remoteTag = mergeRef(remoteTag, object, true)
|
||||
}
|
||||
}
|
||||
return Snapshot{Main: main, Tag: tag}, nil
|
||||
return Snapshot{Main: main, Tag: remoteTag}, nil
|
||||
}
|
||||
|
||||
// RequireCandidate requires exact main equality and an absent remote version
|
||||
// RequireCandidate requires exact main equality and an absent remote
|
||||
// tag. It deliberately does not accept a main branch that merely contains the
|
||||
// candidate as an ancestor.
|
||||
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) {
|
||||
snapshot, err := endpoint.Query(ctx, repositoryDir, version)
|
||||
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, tag, commit string) (Snapshot, error) {
|
||||
snapshot, err := endpoint.Query(ctx, repositoryDir, tag)
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
@@ -294,25 +294,14 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
||||
return ref
|
||||
}
|
||||
|
||||
func validVersionRef(version string) bool {
|
||||
if len(version) < 6 || !strings.HasPrefix(version, "v") {
|
||||
return false
|
||||
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||
if tag == "" {
|
||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||
}
|
||||
parts := strings.Split(version[1:], ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
|
||||
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, char := range part {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
func validObjectID(object string) bool {
|
||||
|
||||
@@ -42,6 +42,32 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tag := "integrations/check/v1.2.3"
|
||||
if _, err := endpoint.RequireCandidate(context.Background(), fixture.work, tag, fixture.commit); err != nil {
|
||||
t.Fatalf("RequireCandidate(%q): %v", tag, err)
|
||||
}
|
||||
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := endpoint.PushTag(context.Background(), fixture.work, tag, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remote, err := endpoint.QueryTag(context.Background(), fixture.work, tag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if remote.State != RefPresent || remote.Object != object || remote.Peeled != fixture.commit {
|
||||
t.Fatalf("remote tag = %#v", remote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesGitURLRewrites(t *testing.T) {
|
||||
for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
|
||||
@@ -32,9 +32,9 @@ type TaggedSource struct {
|
||||
|
||||
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
||||
// Both lightweight historical tags and annotated tags are supported.
|
||||
func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) {
|
||||
if !validVersionRef(version) {
|
||||
return TaggedSource{}, fmt.Errorf("invalid tag version %q", version)
|
||||
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return TaggedSource{}, err
|
||||
}
|
||||
var source TaggedSource
|
||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
@@ -50,10 +50,10 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
||||
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
||||
}
|
||||
source.Status = strings.TrimSpace(status)
|
||||
ref := "refs/tags/" + version
|
||||
ref := "refs/tags/" + tag
|
||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||
if err != nil {
|
||||
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err)
|
||||
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err)
|
||||
}
|
||||
source.TagObject = strings.TrimSpace(object)
|
||||
if !validObjectID(source.TagObject) {
|
||||
@@ -61,7 +61,7 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
||||
}
|
||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
|
||||
if err != nil {
|
||||
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err)
|
||||
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err)
|
||||
}
|
||||
source.TagCommit = strings.TrimSpace(peeled)
|
||||
if !validObjectID(source.TagCommit) {
|
||||
@@ -69,11 +69,11 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
||||
}
|
||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||
if err != nil {
|
||||
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err)
|
||||
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err)
|
||||
}
|
||||
source.TagObjectType = strings.TrimSpace(tagType)
|
||||
if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
|
||||
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType)
|
||||
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
@@ -32,6 +32,37 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
tag := "integrations/check/v2.3.4"
|
||||
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
|
||||
t.Fatalf("checkTagRef(%q): %v", tag, err)
|
||||
}
|
||||
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.TagCommit != fixture.commit || source.TagObject != object || source.TagObjectType != "tag" {
|
||||
t.Fatalf("source for %q = %#v", tag, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
||||
if err := checkTagRef(context.Background(), fixture.work, tag); err == nil {
|
||||
t.Errorf("checkTagRef(%q) succeeded", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true")
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
// 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)
|
||||
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)
|
||||
@@ -30,64 +30,67 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit stri
|
||||
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)
|
||||
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/" + version
|
||||
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", version, err)
|
||||
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", version, strings.TrimSpace(tagType))
|
||||
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", version, err)
|
||||
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", version, 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", version, err)
|
||||
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", version, 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", version, err)
|
||||
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", version)
|
||||
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, version, expectedObject string) error {
|
||||
if !validVersionRef(version) || !validObjectID(expectedObject) {
|
||||
return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version)
|
||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
ref := "refs/tags/" + version
|
||||
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", version, err)
|
||||
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", version, strings.TrimSpace(tagType))
|
||||
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", version, err)
|
||||
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", version, strings.TrimSpace(object), expectedObject)
|
||||
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -95,18 +98,18 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
|
||||
// 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, version string) (RemoteRef, error) {
|
||||
if !validVersionRef(version) {
|
||||
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
|
||||
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/" + version
|
||||
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, version, endpoint.PushURL, err)
|
||||
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) {
|
||||
@@ -125,13 +128,16 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version st
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
refspec := tagObject + ":refs/tags/" + version
|
||||
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", version, endpoint.PushURL, err)
|
||||
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
157
internal/releaseidentity/identity.go
Normal file
157
internal/releaseidentity/identity.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Package releaseidentity resolves and derives the identity of one Go module
|
||||
// release within a repository.
|
||||
package releaseidentity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
)
|
||||
|
||||
// Selection identifies the normalized repository and selected module
|
||||
// directories. ModuleDir is an absolute path; ModuleSubdir is its canonical
|
||||
// repository-relative slash path, with "." representing the root.
|
||||
type Selection struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModuleSubdir string
|
||||
}
|
||||
|
||||
// Identity is the release identity derived from one selected module.
|
||||
type Identity struct {
|
||||
Selection
|
||||
ModulePath string
|
||||
Version string
|
||||
Tag string
|
||||
NotePath string
|
||||
}
|
||||
|
||||
// ResolveSelection normalizes a repository-relative module selection and
|
||||
// confirms its final filesystem location remains inside the repository.
|
||||
func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) {
|
||||
if moduleDir == "" {
|
||||
moduleDir = "."
|
||||
}
|
||||
if filepath.IsAbs(moduleDir) {
|
||||
return Selection{}, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
|
||||
}
|
||||
repositoryAbs, err := filepath.Abs(repositoryDir)
|
||||
if err != nil {
|
||||
return Selection{}, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
|
||||
if err != nil {
|
||||
return Selection{}, fmt.Errorf("resolve repository directory %q: %w", repositoryDir, err)
|
||||
}
|
||||
moduleAbs, err := filepath.Abs(filepath.Join(repositoryAbs, moduleDir))
|
||||
if err != nil {
|
||||
return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err)
|
||||
}
|
||||
moduleAbs, err = filepath.EvalSymlinks(moduleAbs)
|
||||
if err != nil {
|
||||
return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err)
|
||||
}
|
||||
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
|
||||
if err != nil {
|
||||
return Selection{}, fmt.Errorf("make module directory relative to repository: %w", err)
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return Selection{}, fmt.Errorf("module directory %q resolves outside repository %q", moduleDir, repositoryDir)
|
||||
}
|
||||
if relative == "." {
|
||||
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: "."}, nil
|
||||
}
|
||||
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: filepath.ToSlash(relative)}, nil
|
||||
}
|
||||
|
||||
// FromCommitted derives identity from the selected module declaration at a
|
||||
// captured commit. The selected module must contain its own go.mod.
|
||||
func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commit string) (Identity, error) {
|
||||
selection, err := ResolveSelection(repositoryDir, moduleDir)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Identity{}, fmt.Errorf("resolve committed release identity: %w", err)
|
||||
}
|
||||
declaration, err := git.ReadCommitFile(ctx, selection.RepositoryDir, commit, moduleFilePath(selection.ModuleSubdir))
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read selected module declaration at %s: %w", commit, err)
|
||||
}
|
||||
return Derive(selection, declaration, version)
|
||||
}
|
||||
|
||||
// FromWorkingTree derives identity from the selected module declaration in the
|
||||
// working tree. The selected module must contain its own go.mod.
|
||||
func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version string) (Identity, error) {
|
||||
selection, err := ResolveSelection(repositoryDir, moduleDir)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Identity{}, fmt.Errorf("resolve working-tree release identity: %w", err)
|
||||
}
|
||||
declarationPath := filepath.Join(selection.ModuleDir, "go.mod")
|
||||
declaration, err := os.ReadFile(declarationPath)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err)
|
||||
}
|
||||
return Derive(selection, declaration, version)
|
||||
}
|
||||
|
||||
// Derive applies Go module path-major rules and derives the standard full tag
|
||||
// and module-local release-note path from a declaration and selection.
|
||||
func Derive(selection Selection, declaration []byte, version string) (Identity, error) {
|
||||
if selection.RepositoryDir == "" || selection.ModuleDir == "" || selection.ModuleSubdir == "" {
|
||||
return Identity{}, fmt.Errorf("incomplete module selection")
|
||||
}
|
||||
modulePath := modfile.ModulePath(declaration)
|
||||
if modulePath == "" {
|
||||
return Identity{}, fmt.Errorf("selected module declaration has no valid module directive")
|
||||
}
|
||||
if err := module.CheckPath(modulePath); err != nil {
|
||||
return Identity{}, fmt.Errorf("invalid module path %q: %w", modulePath, err)
|
||||
}
|
||||
_, pathMajor, ok := module.SplitPathVersion(modulePath)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath)
|
||||
}
|
||||
if !versioncontract.ValidStableVersion(version) {
|
||||
return Identity{}, fmt.Errorf("invalid stable release version %q", version)
|
||||
}
|
||||
if err := module.CheckPathMajor(version, pathMajor); err != nil {
|
||||
return Identity{}, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err)
|
||||
}
|
||||
|
||||
tagPrefix := selection.ModuleSubdir
|
||||
if strings.HasPrefix(pathMajor, "/") && tagPrefix != "." && path.Base(tagPrefix) == strings.TrimPrefix(pathMajor, "/") {
|
||||
tagPrefix = path.Dir(tagPrefix)
|
||||
}
|
||||
tag := version
|
||||
if tagPrefix != "." {
|
||||
tag = path.Join(tagPrefix, version)
|
||||
}
|
||||
notePath := path.Join(selection.ModuleSubdir, "docs", "releases", version+".md")
|
||||
return Identity{
|
||||
Selection: selection,
|
||||
ModulePath: modulePath,
|
||||
Version: version,
|
||||
Tag: tag,
|
||||
NotePath: notePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func moduleFilePath(moduleSubdir string) string {
|
||||
if moduleSubdir == "." {
|
||||
return "go.mod"
|
||||
}
|
||||
return path.Join(moduleSubdir, "go.mod")
|
||||
}
|
||||
178
internal/releaseidentity/identity_test.go
Normal file
178
internal/releaseidentity/identity_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package releaseidentity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveUsesModuleDeclarationAndPhysicalLayout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subdir string
|
||||
modulePath string
|
||||
version string
|
||||
wantTag string
|
||||
wantNote string
|
||||
}{
|
||||
{name: "root v1", subdir: ".", modulePath: "example.com/root", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"},
|
||||
{name: "nested", subdir: "integrations/check", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/check/v1.2.3", wantNote: "integrations/check/docs/releases/v1.2.3.md"},
|
||||
{name: "root major path", subdir: ".", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "docs/releases/v2.3.4.md"},
|
||||
{name: "root physical major directory", subdir: "v2", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "v2/docs/releases/v2.3.4.md"},
|
||||
{name: "physical major directory", subdir: "integrations/check/v2", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/v2/docs/releases/v2.3.4.md"},
|
||||
{name: "logical major without physical directory", subdir: "integrations/check", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/docs/releases/v2.3.4.md"},
|
||||
{name: "ordinary directory named v2", subdir: "integrations/v2", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/v2/v1.2.3", wantNote: "integrations/v2/docs/releases/v1.2.3.md"},
|
||||
{name: "gopkg special major", subdir: "integrations/example.v2", modulePath: "gopkg.in/example.v2", version: "v2.3.4", wantTag: "integrations/example.v2/v2.3.4", wantNote: "integrations/example.v2/docs/releases/v2.3.4.md"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selection := Selection{RepositoryDir: "/repo", ModuleDir: filepath.Join("/repo", filepath.FromSlash(tt.subdir)), ModuleSubdir: tt.subdir}
|
||||
if tt.subdir == "." {
|
||||
selection.ModuleDir = "/repo"
|
||||
}
|
||||
got, err := Derive(selection, []byte("module "+tt.modulePath+"\n"), tt.version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Tag != tt.wantTag || got.NotePath != tt.wantNote {
|
||||
t.Fatalf("identity tag/note = %q, %q; want %q, %q", got.Tag, got.NotePath, tt.wantTag, tt.wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveRejectsInvalidModuleAndVersionIdentity(t *testing.T) {
|
||||
selection := Selection{RepositoryDir: "/repo", ModuleDir: "/repo", ModuleSubdir: "."}
|
||||
for _, tt := range []struct {
|
||||
name, declaration, version string
|
||||
}{
|
||||
{name: "missing module directive", declaration: "go 1.26\n", version: "v1.2.3"},
|
||||
{name: "malformed module path", declaration: "module example.com/../bad\n", version: "v1.2.3"},
|
||||
{name: "incompatible major", declaration: "module example.com/service/v2\n", version: "v1.2.3"},
|
||||
{name: "unstable version", declaration: "module example.com/service\n", version: "v1.2.3-rc.1"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Derive(selection, []byte(tt.declaration), tt.version); err == nil {
|
||||
t.Fatal("Derive() succeeded for invalid identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSelectionNormalizesAndRejectsEscape(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
moduleDir := filepath.Join(repository, "integrations", "one")
|
||||
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selection, err := ResolveSelection(repository, "integrations/one/../one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selection.ModuleSubdir != "integrations/one" || selection.ModuleDir != moduleDir {
|
||||
t.Fatalf("normalized selection = %#v", selection)
|
||||
}
|
||||
root, err := ResolveSelection(repository, "")
|
||||
if err != nil || root.ModuleSubdir != "." || root.ModuleDir != repository {
|
||||
t.Fatalf("default root selection = %#v, error = %v", root, err)
|
||||
}
|
||||
if _, err := ResolveSelection(repository, "../outside"); err == nil {
|
||||
t.Fatal("ResolveSelection() accepted a path outside the repository")
|
||||
}
|
||||
if _, err := ResolveSelection(repository, moduleDir); err == nil {
|
||||
t.Fatal("ResolveSelection() accepted an absolute module path")
|
||||
}
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(repository, "linked")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveSelection(repository, "linked"); err == nil {
|
||||
t.Fatal("ResolveSelection() accepted a symlink that resolves outside the repository")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
gitIdentityRun(t, repository, "init", "-q")
|
||||
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
|
||||
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
|
||||
moduleDir := filepath.Join(repository, "nested", "mod")
|
||||
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declaration := filepath.Join(moduleDir, "go.mod")
|
||||
if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitIdentityRun(t, repository, "add", ".")
|
||||
gitIdentityRun(t, repository, "commit", "-qm", "add module")
|
||||
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
|
||||
if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
committed, err := FromCommitted(context.Background(), repository, "nested/mod", "v1.2.3", commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
working, err := FromWorkingTree(context.Background(), repository, "nested/mod", "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if committed.ModulePath != "example.com/committed" || committed.Tag != "nested/mod/v1.2.3" {
|
||||
t.Fatalf("committed identity = %#v", committed)
|
||||
}
|
||||
if working.ModulePath != "example.com/working" || working.Tag != "nested/mod/v1.2.3" {
|
||||
t.Fatalf("working identity = %#v", working)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
moduleDir := filepath.Join(repository, "nested")
|
||||
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module example.com/nested\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := FromWorkingTree(context.Background(), repository, "nested", "v1.2.3")
|
||||
if err != nil || identity.ModulePath != "example.com/nested" {
|
||||
t.Fatalf("nested identity = %#v, error = %v", identity, err)
|
||||
}
|
||||
if _, err := FromWorkingTree(context.Background(), repository, ".", "v1.2.3"); err == nil {
|
||||
t.Fatal("root selection silently fell back to a nested module declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadersPreserveCancellation(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := FromWorkingTree(ctx, repository, ".", "v1.2.3"); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||
t.Fatalf("working-tree error = %v, want cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitIdentityRun(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = dir
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func gitIdentityOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = dir
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v", args, err)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
Reference in New Issue
Block a user