2026-11-02 19:00:51 +00:00
package publish
import (
2026-11-02 22:18:51 +00:00
"context"
2026-11-02 19:00:51 +00:00
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
2026-11-02 22:18:51 +00:00
"go.maximumdirect.net/releasekit/internal/git"
2026-11-02 19:00:51 +00:00
)
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 {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_read" , RecordPath : path , cause : fmt . Errorf ( "read record %q: %w" , path , err ) }
2026-11-02 19:00:51 +00:00
}
decoder := json . NewDecoder ( strings . NewReader ( string ( data ) ) )
var envelope recordEnvelope
if err := decoder . Decode ( & envelope ) ; err != nil {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_format" , RecordPath : path , cause : fmt . Errorf ( "decode record %q: %w" , path , err ) }
2026-11-02 19:00:51 +00:00
}
var trailing any
if err := decoder . Decode ( & trailing ) ; err != io . EOF {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_format" , RecordPath : path , cause : fmt . Errorf ( "record %q contains trailing JSON" , path ) }
2026-11-02 19:00:51 +00:00
}
if envelope . FormatVersion != recordFormatVersion {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_format" , RecordPath : path , cause : fmt . Errorf ( "record %q has unsupported format version %d" , path , envelope . FormatVersion ) }
2026-11-02 19:00:51 +00:00
}
if len ( envelope . Evidence ) == 0 || string ( envelope . Evidence ) == "null" {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_format" , RecordPath : path , cause : fmt . Errorf ( "record %q has no evidence object" , path ) }
2026-11-02 19:00:51 +00:00
}
var evidence Evidence
if err := json . Unmarshal ( envelope . Evidence , & evidence ) ; err != nil {
2026-11-02 19:09:26 +00:00
return Evidence { } , & RecoveryError { Kind : "record_format" , RecordPath : path , cause : fmt . Errorf ( "decode record evidence %q: %w" , path , err ) }
2026-11-02 19:00:51 +00:00
}
return evidence , nil
}
// SaveEvidence writes one private attempt record and returns its absolute path.
2026-11-02 22:20:53 +00:00
func SaveEvidence ( ctx context . Context , repositoryDir , override string , evidence Evidence ) ( string , error ) {
recordDir , root , err := resolveRecordDir ( ctx , repositoryDir , override )
2026-11-02 19:00:51 +00:00
if err != nil {
return "" , & RecoveryError { Kind : "record_write" , cause : err }
}
2026-11-02 22:20:53 +00:00
return saveEvidenceInDir ( ctx , repositoryDir , root , override , recordDir , evidence )
2026-11-02 19:08:07 +00:00
}
2026-11-02 22:20:53 +00:00
func saveEvidenceInDir ( ctx context . Context , repositoryDir , root , override , recordDir string , evidence Evidence ) ( string , error ) {
return saveEvidenceNamed ( ctx , repositoryDir , root , override , recordDir , evidence , proposedRecordPath )
2026-11-02 22:17:59 +00:00
}
2026-11-02 22:20:53 +00:00
func saveEvidenceNamed ( ctx context . Context , repositoryDir , root , override , recordDir string , evidence Evidence , nextPath func ( string ) ( string , error ) ) ( string , error ) {
2026-11-02 19:00:51 +00:00
if evidence . RecordedAt == "" {
evidence . RecordedAt = time . Now ( ) . UTC ( ) . Format ( time . RFC3339Nano )
}
if err := os . MkdirAll ( recordDir , 0 o700 ) ; err != nil {
return "" , & RecoveryError { Kind : "record_write" , cause : fmt . Errorf ( "create record directory %q: %w" , recordDir , 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 ++ {
2026-11-02 22:17:59 +00:00
path , err := nextPath ( recordDir )
if err != nil {
2026-11-02 19:00:51 +00:00
return "" , & RecoveryError { Kind : "record_write" , cause : fmt . Errorf ( "name publication record: %w" , err ) }
}
2026-11-02 22:17:59 +00:00
if override != "" && root != "" {
2026-11-02 22:20:53 +00:00
if err := rejectUnignoredWorktreePath ( ctx , repositoryDir , root , path ) ; err != nil {
2026-11-02 22:17:59 +00:00
return "" , & RecoveryError { Kind : "record_write" , cause : err }
}
}
2026-11-02 19:00:51 +00:00
file , err := os . OpenFile ( path , os . O_WRONLY | os . O_CREATE | os . O_EXCL , 0 o600 )
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 ) }
}
2026-11-02 22:20:53 +00:00
func resolveRecordDir ( ctx context . Context , repositoryDir , override string ) ( string , string , error ) {
root , err := git . RepositoryRoot ( ctx , repositoryDir )
2026-11-02 19:00:51 +00:00
if err != nil {
2026-11-02 22:20:53 +00:00
return "" , "" , err
2026-11-02 19:00:51 +00:00
}
if override != "" {
if filepath . IsAbs ( override ) {
return filepath . Clean ( override ) , root , nil
}
return filepath . Join ( root , override ) , root , nil
}
2026-11-02 22:20:53 +00:00
common , err := git . CommonDirectory ( ctx , repositoryDir )
2026-11-02 19:00:51 +00:00
if err != nil {
2026-11-02 22:20:53 +00:00
return "" , "" , err
2026-11-02 19:00:51 +00:00
}
2026-11-02 22:20:53 +00:00
return filepath . Join ( common , "releasekit" , "publication-attempts" ) , root , nil
2026-11-02 19:00:51 +00:00
}
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
}
2026-11-02 22:20:53 +00:00
func rejectUnignoredWorktreePath ( ctx context . Context , repositoryDir , root , proposed string ) error {
2026-11-02 19:00:51 +00:00
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
}
2026-11-02 22:20:53 +00:00
metadata , err := git . MetadataDirectories ( ctx , repositoryDir )
2026-11-02 22:18:51 +00:00
if err != nil {
return err
}
for _ , directory := range metadata {
rel , err := filepath . Rel ( directory , actual )
if err == nil && rel != ".." && ! strings . HasPrefix ( rel , ".." + string ( filepath . Separator ) ) {
return nil
}
}
2026-11-02 22:20:53 +00:00
ignored , err := git . IsIgnored ( ctx , repositoryDir , actual )
if err != nil {
return fmt . Errorf ( "check Git ignore rules for record path %q: %w" , actual , err )
2026-11-02 19:00:51 +00:00
}
2026-11-02 22:20:53 +00:00
if ! ignored {
2026-11-02 19:00:51 +00:00
return fmt . Errorf ( "record path %q is inside the worktree and is not ignored" , actual )
}
2026-11-02 22:20:53 +00:00
return nil
2026-11-02 19:00:51 +00:00
}