Add private publication attempt records

This commit is contained in:
2026-11-02 19:00:51 +00:00
parent 4dff8086ab
commit 1ddeb2c6dc
4 changed files with 326 additions and 10 deletions

View File

@@ -124,6 +124,8 @@ rejected-push, endpoint-change, and cancellation behavior.
## Stage 2: Implement private attempt records
**Completed:** 2026-11-02
**Outcome:** A supported internal codec can save and load a single attempt
without relying on diagnostic output.

View File

@@ -9,16 +9,16 @@ import (
// Evidence is the identity captured when one publication was attempted.
type Evidence struct {
RepositoryDir string
ModuleDir string
ModulePath string
Version string
Tag string
CandidateCommit string
TagObject string
Endpoint string
Remote string
RecordedAt string
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
// Observation combines the original evidence with authoritative remote state.

158
internal/publish/record.go Normal file
View File

@@ -0,0 +1,158 @@
package publish
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const recordFormatVersion = 1
type recordEnvelope struct {
FormatVersion int `json:"format_version"`
Evidence json.RawMessage `json:"evidence"`
}
// LoadEvidence reads one complete supported private publication record.
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)}
}
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)}
}
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)}
}
if envelope.FormatVersion != recordFormatVersion {
return Evidence{}, &RecoveryError{Kind: "record_format", 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)}
}
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, nil
}
// SaveEvidence writes one private attempt record and returns its absolute path.
func SaveEvidence(repositoryDir, override string, evidence Evidence) (string, error) {
recordDir, root, err := resolveRecordDir(repositoryDir, override)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: err}
}
if evidence.RecordedAt == "" {
evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
if err := os.MkdirAll(recordDir, 0o700); err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record directory %q: %w", recordDir, err)}
}
if override != "" && root != "" {
path, pathErr := proposedRecordPath(recordDir)
if pathErr != nil {
return "", &RecoveryError{Kind: "record_write", cause: pathErr}
}
if err := rejectUnignoredWorktreePath(repositoryDir, root, path); err != nil {
return "", &RecoveryError{Kind: "record_write", cause: err}
}
}
evidenceJSON, err := json.Marshal(evidence)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
}
data, err := json.Marshal(recordEnvelope{FormatVersion: recordFormatVersion, Evidence: evidenceJSON})
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
}
for attempt := 0; attempt < 5; attempt++ {
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
}
path := filepath.Join(recordDir, "attempt-"+hex.EncodeToString(nonce[:])+".json")
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record %q: %w", path, err)}
}
if _, err = file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("write record %q: %w", path, err)}
}
if err = file.Close(); err != nil {
_ = os.Remove(path)
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("close record %q: %w", path, err)}
}
return path, nil
}
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("could not allocate a unique record name in %q", recordDir)}
}
func resolveRecordDir(repositoryDir, override string) (string, string, error) {
rootOutput, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", "", fmt.Errorf("resolve source repository root: %w", err)
}
root, err := filepath.Abs(strings.TrimSpace(string(rootOutput)))
if err != nil {
return "", "", fmt.Errorf("resolve source repository root: %w", err)
}
if override != "" {
if filepath.IsAbs(override) {
return filepath.Clean(override), root, nil
}
return filepath.Join(root, override), root, nil
}
common, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output()
if err != nil {
return "", "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
}
return filepath.Join(strings.TrimSpace(string(common)), "releasekit", "publication-attempts"), root, nil
}
func proposedRecordPath(directory string) (string, error) {
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
}
return filepath.Join(directory, "attempt-"+hex.EncodeToString(nonce[:])+".json"), nil
}
func rejectUnignoredWorktreePath(repositoryDir, root, proposed string) error {
parent, err := filepath.EvalSymlinks(filepath.Dir(proposed))
if err != nil {
return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err)
}
actual := filepath.Join(parent, filepath.Base(proposed))
rel, err := filepath.Rel(root, actual)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil
}
cmd := exec.Command("git", "-C", repositoryDir, "check-ignore", "-q", "--", actual)
err = cmd.Run()
if err == nil {
return nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
}
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
}

View File

@@ -0,0 +1,156 @@
package publish
import (
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func recordGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func recordRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
recordGit(t, dir, "init", "-q")
recordGit(t, dir, "config", "user.name", "Test")
recordGit(t, dir, "config", "user.email", "test@example.invalid")
if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, dir, "add", "file")
recordGit(t, dir, "commit", "-qm", "init")
return dir
}
func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) {
dir := t.TempDir()
partial := filepath.Join(dir, "partial.json")
if err := os.WriteFile(partial, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
t.Fatal(err)
}
got, err := LoadEvidence(partial)
if err != nil || got != (Evidence{}) {
t.Fatalf("partial record = %#v, %v", got, err)
}
for name, data := range map[string]string{"bad-json": "{", "unsupported": `{"format_version":2,"evidence":{}}`, "type": `{"format_version":1,"evidence":{"tag":false}}`, "trailing": `{"format_version":1,"evidence":{}} {}`} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
_, err := LoadEvidence(path)
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_format" {
t.Fatalf("LoadEvidence() error=%v", err)
}
})
}
}
func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) {
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_read" || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("LoadEvidence() error = %v", err)
}
}
func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testing.T) {
repo := recordRepo(t)
worktree := filepath.Join(t.TempDir(), "linked")
recordGit(t, repo, "worktree", "add", "--detach", worktree)
evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"}
first, err := SaveEvidence(worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
second, err := SaveEvidence(worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("record paths collided: %q", first)
}
common := recordGit(t, repo, "rev-parse", "--path-format=absolute", "--git-common-dir")
wantDir := filepath.Join(strings.TrimSpace(common), "releasekit", "publication-attempts")
if filepath.Dir(first) != wantDir {
t.Fatalf("record dir=%q want %q", filepath.Dir(first), wantDir)
}
dirInfo, err := os.Stat(wantDir)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0700 {
t.Fatalf("record directory permissions=%o", dirInfo.Mode().Perm())
}
info, err := os.Stat(first)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("record permissions=%o", info.Mode().Perm())
}
loaded, err := LoadEvidence(first)
if err != nil {
t.Fatal(err)
}
if loaded.Endpoint != evidence.Endpoint || loaded.RecordedAt == "" {
t.Fatalf("loaded evidence=%#v", loaded)
}
var envelope map[string]json.RawMessage
data, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "user:secret@example.invalid") {
t.Fatal("private record did not retain the exact endpoint")
}
}
func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) {
repo := recordRepo(t)
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := SaveEvidence(repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil {
t.Fatalf("ignored override: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
t.Fatal(err)
}
external := filepath.Join(t.TempDir(), "external-store")
path, err := SaveEvidence(repo, external, Evidence{Tag: "v2.0.0"})
if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) {
t.Fatalf("absolute external override path=%q error=%v", path, err)
}
}
func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
repo := recordRepo(t)
_, err := SaveEvidence(repo, "records", Evidence{Tag: "v1.2.3"})
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
t.Fatalf("SaveEvidence() error=%v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records")); err != nil {
t.Fatalf("expected parent directory to exist without record: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records", "attempt")); !os.IsNotExist(err) {
t.Fatalf("unexpected record: %v", err)
}
}