Add local publication attempt discovery

This commit is contained in:
2026-11-02 19:09:26 +00:00
parent 2a04938a96
commit fb5947ea3a
7 changed files with 200 additions and 7 deletions

View File

@@ -0,0 +1,91 @@
package publish
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
type Attempt struct {
RecordPath string
Evidence Evidence
}
type AttemptsResult struct {
RecordDir string
Attempts []Attempt
Diagnostics []*RecoveryError
}
func ListAttempts(ctx context.Context, repositoryDir, override string) (AttemptsResult, error) {
if repositoryDir == "" {
repositoryDir = "."
}
result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}}
var directory string
{
resolved, err := recordDirectoryFor(ctx, repositoryDir, override)
if err != nil {
return result, &RecoveryError{Kind: "storage_enumeration", cause: err}
}
directory = resolved
}
result.RecordDir = directory
if err := ctx.Err(); err != nil {
return result, &RecoveryError{Kind: "canceled", cause: err}
}
entries, err := os.ReadDir(directory)
if err != nil {
return result, &RecoveryError{Kind: "storage_enumeration", cause: fmt.Errorf("enumerate records in %q: %w", directory, err)}
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
paths = append(paths, filepath.Join(directory, entry.Name()))
}
}
sort.Strings(paths)
for _, path := range paths {
if err := ctx.Err(); err != nil {
return result, &RecoveryError{Kind: "canceled", cause: err}
}
absolute, err := filepath.Abs(path)
if err != nil {
result.Diagnostics = append(result.Diagnostics, &RecoveryError{Kind: "record_read", RecordPath: path, cause: err})
continue
}
evidence, err := LoadEvidence(absolute)
if err != nil {
var recordError *RecoveryError
if errors.As(err, &recordError) {
result.Diagnostics = append(result.Diagnostics, recordError)
}
continue
}
result.Attempts = append(result.Attempts, Attempt{RecordPath: absolute, Evidence: evidence})
}
return result, nil
}
func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (string, error) {
if filepath.IsAbs(override) {
return filepath.Clean(override), nil
}
root, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
rootPath := strings.TrimSpace(string(root))
if override != "" {
return filepath.Join(rootPath, override), nil
}
common, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output()
if err != nil {
return "", err
}
return filepath.Join(strings.TrimSpace(string(common)), "releasekit", "publication-attempts"), nil
}

View File

@@ -32,6 +32,7 @@ type Observation struct {
// RecoveryError describes why a later publication observation is unresolved.
type RecoveryError struct {
Kind string
RecordPath string
MissingFields []string
cause error
}

View File

@@ -25,26 +25,26 @@ type recordEnvelope struct {
func LoadEvidence(path string) (Evidence, error) {
data, err := os.ReadFile(path)
if err != nil {
return Evidence{}, &RecoveryError{Kind: "record_read", cause: fmt.Errorf("read record %q: %w", path, err)}
return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)}
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
var envelope recordEnvelope
if err := decoder.Decode(&envelope); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("decode record %q: %w", path, err)}
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)}
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q contains trailing JSON", path)}
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q contains trailing JSON", path)}
}
if envelope.FormatVersion != recordFormatVersion {
return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
}
if len(envelope.Evidence) == 0 || string(envelope.Evidence) == "null" {
return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q has no evidence object", path)}
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has no evidence object", path)}
}
var evidence Evidence
if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("decode record evidence %q: %w", path, err)}
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence %q: %w", path, err)}
}
return evidence, nil
}

View File

@@ -1,11 +1,13 @@
package publish
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -154,3 +156,47 @@ func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
t.Fatalf("unexpected record: %v", err)
}
}
func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.T) {
repo := recordRepo(t)
directory := filepath.Join(t.TempDir(), "attempts")
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
second, _ := SaveEvidence(repo, directory, Evidence{Tag: "v2.0.0"})
first, _ := SaveEvidence(repo, directory, Evidence{Tag: "v1.0.0"})
if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil {
t.Fatal(err)
}
listed, err := ListAttempts(t.Context(), repo, directory)
if err != nil || len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 {
t.Fatalf("list=%#v error=%v", listed, err)
}
wantPaths := []string{first, second}
sort.Strings(wantPaths)
if listed.Attempts[0].RecordPath != wantPaths[0] || listed.Attempts[1].RecordPath != wantPaths[1] {
t.Fatalf("attempt order=%v", listed.Attempts)
}
if listed.Diagnostics[0].Kind != "record_format" || listed.Diagnostics[0].RecordPath == "" {
t.Fatalf("diagnostic=%#v", listed.Diagnostics[0])
}
}
func TestListAttemptsDistinguishesEmptyMissingAndCanceledStorage(t *testing.T) {
empty := t.TempDir()
listed, err := ListAttempts(t.Context(), "", empty)
if err != nil || len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
t.Fatalf("empty list=%#v error=%v", listed, err)
}
_, err = ListAttempts(t.Context(), "", filepath.Join(t.TempDir(), "missing"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" {
t.Fatalf("missing storage error=%v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = ListAttempts(ctx, "", empty)
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled error=%v", err)
}
}