Reconcile uncertain tag pushes

This commit is contained in:
2026-10-05 18:53:44 +00:00
parent 3757d40ac4
commit 7481dd0fd2
7 changed files with 388 additions and 22 deletions

View File

@@ -3,8 +3,8 @@
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, 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
commands are implemented. 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).

View File

@@ -43,7 +43,8 @@ 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.
When a push reports an error, ReleaseKit makes one bounded query for the
authoritative tag. An exact tag object match confirms success. An absent tag is
reported as `not_published`, a different object as `conflict`, and an unreadable
query as `unknown`. The local annotated tag is retained in every case; an
`unknown` result is neither success nor proof that the remote lacks the tag.

View File

@@ -614,11 +614,12 @@ 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.
remote conflicts, and retained local tags after push error; Stage 8 adds outcome
classification through remote reconciliation.
## Stage 8: Resolve rejected and ambiguous publication
Status: Pending. Depends on Stage 7.
Status: Complete. Depends on Stage 7.
1. Add the one-query reconciliation path and public error/result classification.
Preserve the original push cause when resolution does not confirm success.
@@ -635,6 +636,17 @@ missing, conflicting, and unavailable remote tags have distinct outcomes. Failed
pushes issue one reconciliation query, successful pushes issue none, and no
recovery force-update or automatic tag deletion occurs.
Evidence: failed tag pushes receive one bounded exact-tag query. Matching object
identity returns published; absence returns not_published with the original push
error; a different object returns conflict; query failure returns unknown while
preserving the push cause. Tests cover rejection by a bare remote's receive
hook, an installed conflicting tag, unavailable reconciliation, and a lost
acknowledgment after the bare remote accepted the tag while the caller context
was canceled. Query counts confirm one recovery read after errors and none after
a successful push.
Tests also verify that a remote branch change during validation and a changed
local tag before push prevent publication.
## Stage 9: Expose publication and document ReleaseKit bootstrap
Status: Pending. Depends on Stage 8.

View File

@@ -92,6 +92,34 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
return nil
}
// QueryTag reads one exact authoritative version tag and its peeled commit.
// Failed 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)
}
ref := "refs/tags/" + version
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)
}
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, version, tagObject string) error {
if !validVersionRef(version) || !validObjectID(tagObject) {

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"time"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -24,13 +25,16 @@ const (
type Phase string
const (
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Complete Phase = "complete"
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Reconciliation Phase = "reconciliation"
Complete Phase = "complete"
)
const recoveryTimeout = 30 * time.Second
type Request struct {
RepositoryDir string
Product string
@@ -50,8 +54,9 @@ type Result struct {
}
// 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.
// object. A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error.
func Publish(ctx context.Context, request Request) (Result, error) {
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
remoteName := request.Remote
@@ -129,13 +134,42 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = Push
result.State = Unknown
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
return result, err
return reconcilePush(endpoint, captured, tagObject, result, err)
}
result.Phase = Complete
result.State = Published
return result, nil
}
func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObject string, result Result, pushErr error) (Result, error) {
result.Phase = Reconciliation
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel()
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version)
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
result.State = Unknown
if queryErr == nil {
if remoteTag.State == git.RefConflict {
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
} else {
queryErr = git.ErrRemoteStateUnreadable
}
}
return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%v): %w", captured.Version, queryErr, pushErr)
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent: %w", captured.Version, pushErr)
}
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete
result.State = Published
return result, nil
}
result.State = Conflict
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s: %w", captured.Version, remoteTag.Object, tagObject, pushErr)
}
func isRemoteConflict(err error) bool {
return errors.Is(err, git.ErrRemoteMainMissing) ||
errors.Is(err, git.ErrRemoteMainConflict) ||

View File

@@ -33,6 +33,8 @@ const (
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
// PublicationPush identifies the remote tag push attempt.
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
// PublicationReconciliation identifies the single authoritative tag query after a push error.
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
// PublicationComplete identifies a successful tag push.
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
)
@@ -51,8 +53,10 @@ type PublicationResult struct {
// 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.
// push gets one bounded authoritative tag query: an exact object match confirms
// publication, absence is not_published, a different object is conflict, and
// an unreadable result is unknown. A nil error means publication is confirmed
// by the successful push or exact remote object match.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil {

View File

@@ -2,11 +2,14 @@ package releasekit_test
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
@@ -15,6 +18,8 @@ const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes s
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
commit := repo.commit
var validations int
@@ -36,6 +41,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if validations != 1 {
t.Fatalf("validation calls = %d, want 1", validations)
}
if queries := countLines(t, wrapper.queryLog); queries != 3 {
t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries)
}
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result)
}
@@ -75,10 +83,12 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
}
}
func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(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 {
wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
@@ -90,9 +100,15 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
if err == nil {
t.Fatal("Publish() succeeded despite rejected push hook")
}
if result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationPush || result.TagObject == "" {
if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
t.Fatalf("publication result = %#v", result)
}
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
t.Fatalf("push error was not preserved with absence evidence: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
}
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)
}
@@ -101,6 +117,169 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
}
}
func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "lost-ack-cancel")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
finished := make(chan struct{})
watcherDone := make(chan struct{})
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
go func() {
defer close(watcherDone)
for {
if _, statErr := os.Stat(wrapper.pushDone); statErr == nil {
cancel()
return
}
select {
case <-finished:
return
case <-time.After(time.Millisecond):
}
}
}()
return nil
},
})
close(finished)
<-watcherDone
if err != nil {
t.Fatal(err)
}
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one cancellation recovery query", queries)
}
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("remote tag object = %s, want %s", got, result.TagObject)
}
}
func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
script := fmt.Sprintf("#!/bin/sh\n%s --git-dir=%s update-ref refs/tags/v1.2.3 %s\nexit 1\n", shellQuote(mustLookPath(t, "git")), shellQuote(repo.bare), conflictingObject)
if err := os.WriteFile(hook, []byte(script), 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 || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "authoritative tag identifies object") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not distinguish conflict and preserve push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
}
}
func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) {
repo := newPublishFixture(t)
gitRun(t, repo.work, "-c", "tag.gpgSign=false", "tag", "--annotate", "--message", "different tag", "v0.8.0", repo.commit)
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.8.0")
wrapper := installGitWrapperWithMutation(t, repo, conflictingObject)
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
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 || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != conflictingObject {
t.Fatalf("changed local tag object = %s, want %s", got, conflictingObject)
}
if result.TagObject == "" || result.TagObject == conflictingObject {
t.Fatalf("captured tag object = %q, want the prior object; publish error: %v", result.TagObject, err)
}
if queries := countLines(t, wrapper.queryLog); queries != 2 {
t.Fatalf("remote query count = %d, want only the pre-validation and prepublication guards", queries)
}
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("changed local tag was pushed")
}
}
func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "query-failure")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\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 || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not preserve both query and push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one failed reconciliation query", queries)
}
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)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 {
t.Fatalf("push cause was not preserved in error chain: %v", err)
}
}
func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
repo := newPublishFixture(t)
other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
wrapper := installGitWrapperWithMutationMode(t, repo, "query-conflict", other)
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
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 || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "inconsistent exact ref records") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not preserve uninterpretable remote state and push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one uninterpretable reconciliation query", queries)
}
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)
}
}
func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
repo := newPublishFixture(t)
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
@@ -151,6 +330,114 @@ type publishFixture struct {
unrelatedCommit string
}
type gitWrapper struct {
bin string
queryLog string
pushDone string
realGit string
}
func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrapper {
t.Helper()
return installGitWrapperWithMutationMode(t, repo, mode, "")
}
func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper {
t.Helper()
return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)
}
func installGitWrapperWithMutationMode(t *testing.T, repo publishFixture, mode, mutationObject string) gitWrapper {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
queryLog := filepath.Join(t.TempDir(), "tag-queries.log")
pushDone := filepath.Join(t.TempDir(), "push-attempted")
contents := fmt.Sprintf(`#!/bin/sh
REAL_GIT=%s
QUERY_LOG=%s
PUSH_DONE=%s
MODE=%s
MUTATE_OBJECT=%s
ARGS=" $* "
IS_PUSH=0
IS_LS_REMOTE=0
for ARG in "$@"; do
[ "$ARG" = "push" ] && IS_PUSH=1
[ "$ARG" = "ls-remote" ] && IS_LS_REMOTE=1
done
if [ "$IS_LS_REMOTE" = "1" ]; then
case "$ARGS" in *refs/tags/v1.2.3*) echo query >> "$QUERY_LOG";; esac
if [ "$MODE" = "query-failure" ] && [ -f "$PUSH_DONE" ]; then
case "$ARGS" in *refs/tags/v1.2.3*) echo "reconciliation unavailable" >&2; exit 77;; esac
fi
if [ "$MODE" = "query-conflict" ] && [ -f "$PUSH_DONE" ]; then
case "$ARGS" in
*refs/tags/v1.2.3*)
OBJECT=$("$REAL_GIT" rev-parse refs/tags/v1.2.3)
printf '%%s\trefs/tags/v1.2.3\n' "$OBJECT"
printf '%%s\trefs/tags/v1.2.3\n' "$MUTATE_OBJECT"
exit 0
;;
esac
fi
fi
if [ "$IS_PUSH" = "1" ]; then
"$REAL_GIT" "$@"
STATUS=$?
: > "$PUSH_DONE"
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi
exit "$STATUS"
fi
if [ "$MODE" = "mutate-tag" ]; then
case "$ARGS" in
*"cat-file tag refs/tags/v1.2.3"*)
"$REAL_GIT" "$@"
STATUS=$?
[ "$STATUS" = "0" ] && "$REAL_GIT" update-ref refs/tags/v1.2.3 "$MUTATE_OBJECT"
exit "$STATUS"
;;
esac
fi
exec "$REAL_GIT" "$@"
`, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject))
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
t.Fatal(err)
}
return gitWrapper{bin: bin, queryLog: queryLog, pushDone: pushDone, realGit: realGit}
}
func countLines(t *testing.T, path string) int {
t.Helper()
contents, err := os.ReadFile(path)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(strings.Fields(string(contents)))
}
func mustLookPath(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Fatal(err)
}
return path
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func newPublishFixture(t *testing.T) publishFixture {
t.Helper()
base := t.TempDir()