2026-05-16 14:43:29 +00:00
package stage
import (
2026-08-10 20:05:24 +00:00
"bytes"
2026-05-16 14:43:29 +00:00
"context"
2026-08-10 19:16:18 +00:00
"crypto/sha256"
"encoding/hex"
2026-05-16 15:01:01 +00:00
"encoding/json"
2026-05-19 20:05:24 -05:00
"errors"
2026-05-16 14:43:29 +00:00
"fmt"
2026-08-10 19:16:18 +00:00
"io"
2026-05-16 14:43:29 +00:00
"os"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
2026-05-23 13:28:25 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
2026-05-16 14:43:29 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
2026-05-16 15:01:01 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/config"
2026-08-10 19:16:18 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
2026-05-16 14:43:29 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
2026-08-10 19:16:18 +00:00
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
2026-05-16 14:43:29 +00:00
)
2026-05-23 13:39:15 +00:00
type publishStage struct { }
type publishUploadFile struct {
2026-08-10 19:16:18 +00:00
RelativePath string
SourceRelativePath string
Checksum string
2026-05-17 11:03:02 -05:00
}
2026-05-16 14:43:29 +00:00
2026-05-23 13:39:15 +00:00
var publishPrerequisiteStages = [ ] string {
2026-05-16 14:43:29 +00:00
"prepare" ,
"transcribe" ,
"merge" ,
"polish" ,
"normalize" ,
"trim" ,
2026-05-25 00:46:27 +00:00
"render" ,
2026-05-16 14:43:29 +00:00
"analyze" ,
}
2026-05-23 13:39:15 +00:00
func ( publishStage ) Name ( ) string { return "publish" }
2026-05-16 14:43:29 +00:00
2026-05-23 13:39:15 +00:00
func ( publishStage ) Run ( ctx context . Context , env * Env , m * manifest . Manifest ) ( * StageResult , error ) {
2026-05-16 14:43:29 +00:00
if env == nil || env . Config == nil || env . Config . Pipeline == nil || env . Config . Session == nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: resolved config must include pipeline and session" )
2026-05-16 14:43:29 +00:00
}
2026-05-23 13:39:15 +00:00
if publishDisabled ( env ) {
2026-05-16 14:43:29 +00:00
return & StageResult {
Metadata : map [ string ] any {
2026-08-10 20:05:24 +00:00
"stage" : "publish" ,
"skipped" : true ,
"publish_enabled" : false ,
"audio_upload_skipped" : true ,
2026-05-16 14:43:29 +00:00
} ,
} , nil
}
2026-05-23 13:39:15 +00:00
if publishRunUploadDisabled ( env ) {
2026-05-16 14:43:29 +00:00
return & StageResult {
Metadata : map [ string ] any {
2026-08-10 20:05:24 +00:00
"stage" : "publish" ,
"skipped" : true ,
"upload_run_enabled" : false ,
"audio_upload_skipped" : true ,
2026-05-16 14:43:29 +00:00
} ,
} , nil
}
2026-05-23 04:42:08 +00:00
if err := validatePublishPrerequisites ( m ) ; err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: %w" , err )
2026-05-16 14:43:29 +00:00
}
if env . ObjectStore == nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: remote object store backend is required when publish run upload is enabled" )
2026-05-16 14:43:29 +00:00
}
2026-08-10 20:05:24 +00:00
runManifestPath , err := resolvePublishRunManifestPath ( env , m )
2026-05-16 14:43:29 +00:00
if err != nil {
2026-08-10 20:05:24 +00:00
return nil , fmt . Errorf ( "publish: resolve run manifest path: %w" , err )
2026-05-16 14:43:29 +00:00
}
2026-08-10 20:05:24 +00:00
runRoot := filepath . Dir ( runManifestPath )
2026-08-10 19:16:18 +00:00
runRootInfo , err := os . Lstat ( runRoot )
2026-05-16 14:43:29 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: run root %q: %w" , runRoot , err )
2026-05-16 14:43:29 +00:00
}
2026-08-10 19:16:18 +00:00
if runRootInfo . Mode ( ) & os . ModeSymlink != 0 || ! runRootInfo . IsDir ( ) {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: run root %q is not a directory" , runRoot )
2026-05-16 14:43:29 +00:00
}
2026-05-23 13:39:15 +00:00
runPrefix , err := artifacts . ResolvePublishRunPrefix ( env . Config , m )
2026-05-16 14:43:29 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: resolve s3 run prefix: %w" , err )
2026-05-16 14:43:29 +00:00
}
2026-05-23 13:39:15 +00:00
sessionPrefix , err := artifacts . ResolvePublishSessionPrefix ( env . Config , m )
2026-05-16 15:01:01 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: resolve s3 session prefix: %w" , err )
2026-05-16 15:01:01 +00:00
}
2026-05-23 13:39:15 +00:00
bucket := artifacts . ResolvePublishBucket ( env . Config , m )
2026-05-16 14:43:29 +00:00
if bucket == "" {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: resolve s3 bucket: bucket is required" )
2026-05-16 14:43:29 +00:00
}
2026-05-16 15:01:01 +00:00
runID := strings . TrimSpace ( m . RunID )
if runID == "" {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: run id is required" )
2026-05-16 15:01:01 +00:00
}
2026-05-16 14:43:29 +00:00
2026-08-10 20:05:24 +00:00
runManifestFile , err := fileops . OpenConfinedRegularFile ( runRoot , filepath . Base ( runManifestPath ) )
2026-05-17 11:03:02 -05:00
if err != nil {
2026-08-10 19:16:18 +00:00
return nil , fmt . Errorf ( "publish: open run manifest: %w" , err )
}
defer runManifestFile . Close ( )
runManifest , err := ( & manifest . LocalStore { } ) . LoadRunReader ( ctx , runManifestFile )
if err != nil {
return nil , fmt . Errorf ( "publish: load run manifest: %w" , err )
}
if _ , err := runManifestFile . Seek ( 0 , io . SeekStart ) ; err != nil {
return nil , fmt . Errorf ( "publish: rewind run manifest: %w" , err )
2026-05-17 11:03:02 -05:00
}
2026-08-10 19:16:18 +00:00
runFiles , err := collectPublishRunFiles ( runRoot , runManifest )
2026-05-16 14:43:29 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: collect run files: %w" , err )
2026-05-16 14:43:29 +00:00
}
2026-08-10 19:16:18 +00:00
runSources , err := openPublishRunFiles ( runRoot , runFiles , runManifestFile )
if err != nil {
return nil , fmt . Errorf ( "publish: verify run files: %w" , err )
}
defer closePublishSources ( runSources )
uploader , ok := env . ObjectStore . ( storage . ReaderUploader )
if ! ok {
return nil , fmt . Errorf ( "publish: object store does not support verified source uploads" )
}
2026-05-23 13:39:15 +00:00
sessionPaths := publishSessionPaths ( env , m )
2026-08-10 19:16:18 +00:00
previousFiles , previousManifestFile , err := collectPublishPreviousFiles ( ctx , sessionPaths . PreviousDir )
2026-05-20 15:03:30 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: collect previous files: %w" , err )
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
if previousManifestFile != nil {
defer previousManifestFile . Close ( )
}
previousSources , err := openPublishRunFiles ( sessionPaths . PreviousDir , previousFiles , previousManifestFile )
if err != nil {
return nil , fmt . Errorf ( "publish: verify previous files: %w" , err )
}
defer closePublishSources ( previousSources )
2026-08-10 00:32:24 +00:00
runtimeCatalog , err := buildPublishRuntimeArtifactCatalog (
sessionPaths ,
m ,
env . Config . Pipeline . Scriptorium ,
env . Config . Pipeline . Notarius ,
)
2026-05-18 01:13:51 +00:00
if err != nil {
2026-05-23 04:30:45 +00:00
return nil , fmt . Errorf ( "publish: build runtime artifact catalog: %w" , err )
2026-05-18 01:13:51 +00:00
}
2026-05-23 04:42:08 +00:00
publishOutputs , skippedOptionalOutputs , skippedUnselectedOutputs , lockedOutputs , err := resolvePublishOutputs (
2026-05-20 21:40:09 -05:00
sessionPaths ,
m ,
runtimeCatalog ,
2026-05-23 04:42:08 +00:00
env . Config . Pipeline . Publish . Outputs ,
2026-05-23 04:30:45 +00:00
env . Config . Pipeline . Publish . Locks ,
2026-05-22 18:01:05 -05:00
env . SelectedArtifactKeys ,
2026-08-10 20:05:24 +00:00
runPrefix ,
2026-05-20 21:40:09 -05:00
)
2026-05-16 15:01:01 +00:00
if err != nil {
2026-05-23 04:42:08 +00:00
return nil , fmt . Errorf ( "publish: resolve publish output rules: %w" , err )
2026-05-16 15:01:01 +00:00
}
2026-08-10 20:05:24 +00:00
runUploaded := publishUploadPaths ( runFiles )
publishedUploaded := publishOutputPaths ( publishOutputs )
previousUploaded := publishUploadPaths ( previousFiles )
commitKey := artifacts . S3RunCommitKey ( sessionPrefix , runID )
pointerKey := artifacts . S3CurrentCommitPointerKey ( sessionPrefix )
2026-05-16 15:01:01 +00:00
2026-08-10 20:05:24 +00:00
transfers , err := buildPublishTransferPlan ( runPrefix , runFiles , runSources , previousFiles , previousSources , publishOutputs )
if err != nil {
return nil , fmt . Errorf ( "publish: build immutable upload mapping: %w" , err )
2026-05-20 15:03:30 +00:00
}
2026-08-10 20:05:24 +00:00
defer func ( ) { closePublishTransfers ( transfers ) } ( )
2026-05-20 15:03:30 +00:00
2026-08-10 20:05:24 +00:00
manifestTempPath , err := writeCommittedSessionManifest ( m , publishMetadata (
2026-05-16 15:01:01 +00:00
bucket ,
runPrefix ,
2026-08-10 20:29:00 +00:00
runID ,
2026-05-16 15:01:01 +00:00
runUploaded ,
2026-05-23 04:42:08 +00:00
publishedUploaded ,
2026-05-20 15:03:30 +00:00
previousUploaded ,
2026-05-23 04:42:08 +00:00
skippedOptionalOutputs ,
skippedUnselectedOutputs ,
lockedOutputs ,
2026-08-10 20:05:24 +00:00
commitKey ,
pointerKey ,
2026-05-16 15:01:01 +00:00
) )
if err != nil {
2026-08-10 20:05:24 +00:00
return nil , fmt . Errorf ( "publish: build committed session manifest: %w" , err )
2026-05-16 15:01:01 +00:00
}
2026-08-10 20:05:24 +00:00
if err := appendCommittedSessionManifest ( & transfers , manifestTempPath , artifacts . S3RunSessionManifestKey ( sessionPrefix , runID ) ) ; err != nil {
return nil , fmt . Errorf ( "publish: add committed session manifest: %w" , err )
}
if err := validatePublishTransferPlan ( transfers , commitKey ) ; err != nil {
return nil , fmt . Errorf ( "publish: validate immutable upload mapping: %w" , err )
2026-05-16 15:01:01 +00:00
}
2026-08-10 20:05:24 +00:00
commit := artifacts . RemoteCommitManifest {
FormatVersion : artifacts . RemoteCommitFormatVersion ,
Campaign : strings . TrimSpace ( m . Campaign ) ,
SessionID : strings . TrimSpace ( m . SessionID ) ,
RunID : runID ,
Artifacts : make ( [ ] artifacts . RemoteArtifact , 0 , len ( transfers ) ) ,
}
for index := range transfers {
artifact , err := uploadVerifiedPublishTransfer ( ctx , env . ObjectStore , uploader , & transfers [ index ] )
if err != nil {
return nil , fmt . Errorf ( "publish: upload immutable object %q: %w" , transfers [ index ] . Artifact . DestinationKey , err )
}
commit . Artifacts = append ( commit . Artifacts , artifact )
}
commitData , err := artifacts . EncodeRemoteCommitManifest ( commit )
2026-05-16 15:01:01 +00:00
if err != nil {
2026-08-10 20:05:24 +00:00
return nil , fmt . Errorf ( "publish: encode immutable commit: %w" , err )
2026-05-16 15:01:01 +00:00
}
2026-08-10 20:05:24 +00:00
commitInfo , err := uploadVerifiedPublishBytes ( ctx , env . ObjectStore , uploader , commitKey , commitData , "application/json" )
if err != nil {
return nil , fmt . Errorf ( "publish: upload immutable commit %q: %w" , commitKey , err )
}
2026-08-10 20:17:54 +00:00
if err := revalidatePublishCommitLocks ( ctx , env , publishOutputs ) ; err != nil {
return nil , fmt . Errorf ( "publish: revalidate locks before current commit selection: %w" , err )
}
2026-08-10 20:05:24 +00:00
pointerData , err := artifacts . EncodeCurrentCommitPointer ( artifacts . CurrentCommitPointer {
FormatVersion : artifacts . RemoteCommitFormatVersion ,
Campaign : commit . Campaign ,
SessionID : commit . SessionID ,
RunID : commit . RunID ,
CommitKey : commitKey ,
CommitSHA256 : publishSHA256 ( commitData ) ,
CommitSize : int64 ( len ( commitData ) ) ,
CommitGeneration : commitInfo . ETag ,
} )
if err != nil {
return nil , fmt . Errorf ( "publish: encode current commit pointer: %w" , err )
}
if _ , err := uploader . UploadReader ( ctx , bytes . NewReader ( pointerData ) , pointerKey , storage . UploadOptions { ContentType : "application/json" } ) ; err != nil {
return nil , fmt . Errorf ( "publish: update current commit pointer %q: %w" , pointerKey , err )
2026-05-16 14:43:29 +00:00
}
return & StageResult {
Metadata : map [ string ] any {
2026-05-23 04:42:08 +00:00
"stage" : "publish" ,
"uploaded" : true ,
"s3_bucket" : bucket ,
"s3_run_prefix" : runPrefix ,
"run_files_uploaded" : len ( runUploaded ) ,
"run_uploaded_paths" : runUploaded ,
"published_files_uploaded" : len ( publishedUploaded ) ,
"published_paths" : publishedUploaded ,
"previous_files_uploaded" : len ( previousUploaded ) ,
"previous_uploaded_paths" : previousUploaded ,
"skipped_optional_outputs" : skippedOptionalOutputs ,
"skipped_unselected_outputs" : skippedUnselectedOutputMetadata ( skippedUnselectedOutputs ) ,
"locked_output_count" : len ( lockedOutputs ) ,
"locked_outputs" : lockedOutputMetadata ( lockedOutputs ) ,
2026-08-10 20:05:24 +00:00
"remote_commit_key" : commitKey ,
"current_commit_pointer_key" : pointerKey ,
2026-05-23 04:42:08 +00:00
"audio_upload_skipped" : true ,
2026-05-16 14:43:29 +00:00
} ,
} , nil
}
2026-08-10 20:17:54 +00:00
func revalidatePublishCommitLocks ( ctx context . Context , env * Env , outputs [ ] publishOutput ) error {
if env == nil || env . RevalidatePublishLocks == nil || len ( outputs ) == 0 {
return nil
}
locks , err := env . RevalidatePublishLocks ( ctx )
if err != nil {
return err
}
lockSet := publishLockSet ( locks )
for _ , output := range outputs {
if lock , locked := lockSet [ output . Source ] ; locked {
return fmt . Errorf ( "source %q is locked: %s" , output . Source , strings . TrimSpace ( lock . Reason ) )
}
}
return nil
}
2026-05-23 04:42:08 +00:00
type publishOutput struct {
2026-05-19 20:05:24 -05:00
Source string
Dest string
Required bool
LocalPath string
Provenance string
2026-05-16 15:01:01 +00:00
}
2026-08-10 20:05:24 +00:00
type publishTransfer struct {
Artifact artifacts . RemoteArtifact
Source * os . File
TemporaryPath string
}
2026-05-23 04:42:08 +00:00
type publishLockedOutput struct {
2026-05-20 21:40:09 -05:00
Source string
Dest string
RemoteKey string
Reason string
Required bool
LocalPath string
Provenance string
}
2026-05-23 04:42:08 +00:00
type publishSkippedUnselectedOutput struct {
2026-05-22 18:01:05 -05:00
Source string
Dest string
Required bool
}
2026-08-10 20:05:24 +00:00
func buildPublishTransferPlan (
runPrefix string ,
runFiles [ ] publishUploadFile ,
runSources [ ] * os . File ,
previousFiles [ ] publishUploadFile ,
previousSources [ ] * os . File ,
publishOutputs [ ] publishOutput ,
) ( [ ] publishTransfer , error ) {
if len ( runFiles ) != len ( runSources ) || len ( previousFiles ) != len ( previousSources ) {
return nil , fmt . Errorf ( "verified publish sources do not match their declared files" )
}
transfers := make ( [ ] publishTransfer , 0 , len ( runFiles ) + len ( previousFiles ) + len ( publishOutputs ) )
add := func ( artifact artifacts . RemoteArtifact , source * os . File ) error {
if source == nil {
return fmt . Errorf ( "source for %q is required" , artifact . DestinationKey )
}
transfers = append ( transfers , publishTransfer { Artifact : artifact , Source : source } )
return nil
}
for index , file := range runFiles {
artifactType := artifacts . RemoteArtifactTypeRunFile
source := "run:" + file . RelativePath
if file . RelativePath == config . S3ManifestFile {
artifactType = artifacts . RemoteArtifactTypeRunManifest
source = "run.manifest"
}
if err := add ( artifacts . RemoteArtifact {
Type : artifactType ,
Source : source ,
DestinationKey : artifacts . S3RunRelativeDestinationKey ( runPrefix , file . RelativePath ) ,
} , runSources [ index ] ) ; err != nil {
closePublishTransfers ( transfers )
return nil , err
}
}
for index , file := range previousFiles {
if err := add ( artifacts . RemoteArtifact {
Type : artifacts . RemoteArtifactTypePreviousArtifact ,
Source : "previous:" + file . SourceRelativePath ,
DestinationKey : artifacts . S3RunRelativeDestinationKey ( runPrefix , file . RelativePath ) ,
} , previousSources [ index ] ) ; err != nil {
closePublishTransfers ( transfers )
return nil , err
}
}
for _ , output := range publishOutputs {
source , err := openPublishOutputSource ( output . LocalPath )
if err != nil {
closePublishTransfers ( transfers )
return nil , fmt . Errorf ( "open output source %q: %w" , output . Source , err )
}
if err := add ( artifacts . RemoteArtifact {
Type : artifacts . RemoteArtifactTypePublishedOutput ,
Source : output . Source ,
DestinationKey : publishOutputRemoteKey ( runPrefix , output . Dest ) ,
} , source ) ; err != nil {
_ = source . Close ( )
closePublishTransfers ( transfers )
return nil , err
}
}
sort . Slice ( transfers , func ( i , j int ) bool {
return transfers [ i ] . Artifact . DestinationKey < transfers [ j ] . Artifact . DestinationKey
} )
return transfers , nil
}
func appendCommittedSessionManifest ( transfers * [ ] publishTransfer , localPath , destinationKey string ) error {
if transfers == nil {
return fmt . Errorf ( "transfer plan is required" )
}
source , err := os . Open ( localPath )
if err != nil {
return err
}
* transfers = append ( * transfers , publishTransfer {
Artifact : artifacts . RemoteArtifact {
Type : artifacts . RemoteArtifactTypeSessionManifest ,
Source : "session.manifest" ,
DestinationKey : destinationKey ,
} ,
Source : source ,
TemporaryPath : localPath ,
} )
sort . Slice ( * transfers , func ( i , j int ) bool {
return ( * transfers ) [ i ] . Artifact . DestinationKey < ( * transfers ) [ j ] . Artifact . DestinationKey
} )
return nil
}
func validatePublishTransferPlan ( transfers [ ] publishTransfer , commitKey string ) error {
artifactsToUpload := make ( [ ] artifacts . RemoteArtifact , 0 , len ( transfers ) )
for _ , transfer := range transfers {
if transfer . Artifact . DestinationKey == commitKey {
return fmt . Errorf ( "artifact destination %q is reserved for the immutable commit" , commitKey )
}
artifactsToUpload = append ( artifactsToUpload , transfer . Artifact )
}
return artifacts . ValidateRemoteArtifactMapping ( artifactsToUpload )
}
func openPublishOutputSource ( localPath string ) ( * os . File , error ) {
path := filepath . Clean ( strings . TrimSpace ( localPath ) )
if path == "" || path == "." {
return nil , fmt . Errorf ( "local path is required" )
}
return fileops . OpenConfinedRegularFile ( filepath . Dir ( path ) , filepath . Base ( path ) )
}
func publishOutputRemoteKey ( runPrefix , destination string ) string {
return artifacts . S3RunRelativeDestinationKey ( runPrefix , filepath . ToSlash ( filepath . Join ( "published" , destination ) ) )
}
func uploadVerifiedPublishTransfer (
ctx context . Context ,
store storage . ObjectStore ,
uploader storage . ReaderUploader ,
transfer * publishTransfer ,
) ( artifacts . RemoteArtifact , error ) {
if transfer == nil || transfer . Source == nil {
return artifacts . RemoteArtifact { } , fmt . Errorf ( "transfer source is required" )
}
if err := preparePublishTransfer ( transfer ) ; err != nil {
return artifacts . RemoteArtifact { } , err
}
if existing , found , err := verifyExistingImmutableObject ( ctx , store , transfer . Artifact . DestinationKey , transfer . Artifact . SHA256 , transfer . Artifact . Size ) ; err != nil {
return artifacts . RemoteArtifact { } , err
} else if found {
transfer . Artifact . Generation = existing . ETag
return transfer . Artifact , nil
}
info , err := uploader . UploadReader ( ctx , transfer . Source , transfer . Artifact . DestinationKey , storage . UploadOptions { } )
if err != nil {
return artifacts . RemoteArtifact { } , err
}
verified , err := verifyPublishedObject ( ctx , store , transfer . Artifact . DestinationKey , transfer . Artifact . Size , info . ETag )
if err != nil {
return artifacts . RemoteArtifact { } , err
}
transfer . Artifact . Generation = verified . ETag
return transfer . Artifact , nil
}
func uploadVerifiedPublishBytes (
ctx context . Context ,
store storage . ObjectStore ,
uploader storage . ReaderUploader ,
key string ,
data [ ] byte ,
contentType string ,
) ( storage . ObjectInfo , error ) {
checksum := publishSHA256 ( data )
if existing , found , err := verifyExistingImmutableObject ( ctx , store , key , checksum , int64 ( len ( data ) ) ) ; err != nil {
return storage . ObjectInfo { } , err
} else if found {
return existing , nil
}
info , err := uploader . UploadReader ( ctx , bytes . NewReader ( data ) , key , storage . UploadOptions { ContentType : contentType } )
if err != nil {
return storage . ObjectInfo { } , err
}
return verifyPublishedObject ( ctx , store , key , int64 ( len ( data ) ) , info . ETag )
}
func verifyExistingImmutableObject ( ctx context . Context , store storage . ObjectStore , key , wantSHA256 string , wantSize int64 ) ( storage . ObjectInfo , bool , error ) {
exists , err := store . Exists ( ctx , key )
if err != nil {
return storage . ObjectInfo { } , false , fmt . Errorf ( "check immutable object %q: %w" , key , err )
}
if ! exists {
return storage . ObjectInfo { } , false , nil
}
path , err := storage . DownloadObjectToTemp ( ctx , store , key , "narratio-verify-immutable-*.bin" )
if err != nil {
return storage . ObjectInfo { } , false , fmt . Errorf ( "download existing immutable object %q: %w" , key , err )
}
defer func ( ) { _ = os . Remove ( path ) } ( )
file , err := os . Open ( path )
if err != nil {
return storage . ObjectInfo { } , false , fmt . Errorf ( "open existing immutable object %q: %w" , key , err )
}
hash := sha256 . New ( )
size , copyErr := io . Copy ( hash , file )
closeErr := file . Close ( )
if copyErr != nil {
return storage . ObjectInfo { } , false , fmt . Errorf ( "checksum existing immutable object %q: %w" , key , copyErr )
}
if closeErr != nil {
return storage . ObjectInfo { } , false , fmt . Errorf ( "close existing immutable object %q: %w" , key , closeErr )
}
if size != wantSize || hex . EncodeToString ( hash . Sum ( nil ) ) != wantSHA256 {
return storage . ObjectInfo { } , false , fmt . Errorf ( "immutable object %q already exists with different content" , key )
}
info , err := verifyPublishedObject ( ctx , store , key , wantSize , "" )
if err != nil {
return storage . ObjectInfo { } , false , err
}
return info , true , nil
}
func preparePublishTransfer ( transfer * publishTransfer ) error {
if _ , err := transfer . Source . Seek ( 0 , io . SeekStart ) ; err != nil {
return fmt . Errorf ( "rewind source %q: %w" , transfer . Artifact . Source , err )
}
info , err := transfer . Source . Stat ( )
if err != nil {
return fmt . Errorf ( "stat source %q: %w" , transfer . Artifact . Source , err )
}
if ! info . Mode ( ) . IsRegular ( ) {
return fmt . Errorf ( "source %q is not a regular file" , transfer . Artifact . Source )
}
hash := sha256 . New ( )
if _ , err := io . Copy ( hash , transfer . Source ) ; err != nil {
return fmt . Errorf ( "checksum source %q: %w" , transfer . Artifact . Source , err )
}
if _ , err := transfer . Source . Seek ( 0 , io . SeekStart ) ; err != nil {
return fmt . Errorf ( "rewind source %q after checksum: %w" , transfer . Artifact . Source , err )
}
transfer . Artifact . Size = info . Size ( )
transfer . Artifact . SHA256 = hex . EncodeToString ( hash . Sum ( nil ) )
return nil
}
func verifyPublishedObject ( ctx context . Context , store storage . ObjectStore , key string , wantSize int64 , uploadedGeneration string ) ( storage . ObjectInfo , error ) {
objects , err := store . List ( ctx , key )
if err != nil {
return storage . ObjectInfo { } , fmt . Errorf ( "list uploaded object %q: %w" , key , err )
}
var found * storage . ObjectInfo
for _ , object := range objects {
if object . Key != key {
continue
}
if found != nil {
return storage . ObjectInfo { } , fmt . Errorf ( "uploaded object %q is ambiguous" , key )
}
copy := object
found = & copy
}
if found == nil {
return storage . ObjectInfo { } , fmt . Errorf ( "uploaded object %q is missing" , key )
}
if found . Size != wantSize {
return storage . ObjectInfo { } , fmt . Errorf ( "uploaded object %q size is %d, want %d" , key , found . Size , wantSize )
}
if strings . TrimSpace ( found . ETag ) == "" {
return storage . ObjectInfo { } , fmt . Errorf ( "uploaded object %q has no generation" , key )
}
if strings . TrimSpace ( uploadedGeneration ) != "" && found . ETag != uploadedGeneration {
return storage . ObjectInfo { } , fmt . Errorf ( "uploaded object %q generation changed during verification" , key )
}
return * found , nil
}
func closePublishTransfers ( transfers [ ] publishTransfer ) {
for _ , transfer := range transfers {
if transfer . Source != nil {
_ = transfer . Source . Close ( )
}
if transfer . TemporaryPath != "" {
_ = os . Remove ( transfer . TemporaryPath )
}
}
}
func publishUploadPaths ( files [ ] publishUploadFile ) [ ] string {
paths := make ( [ ] string , 0 , len ( files ) )
for _ , file := range files {
paths = append ( paths , file . RelativePath )
}
return paths
}
func publishOutputPaths ( outputs [ ] publishOutput ) [ ] string {
paths := make ( [ ] string , 0 , len ( outputs ) )
for _ , output := range outputs {
paths = append ( paths , output . Dest )
}
return paths
}
func publishSHA256 ( data [ ] byte ) string {
sum := sha256 . Sum256 ( data )
return hex . EncodeToString ( sum [ : ] )
}
2026-05-23 13:39:15 +00:00
func publishDisabled ( env * Env ) bool {
2026-05-23 04:30:45 +00:00
cfg := env . Config . Pipeline . Publish
2026-05-16 14:43:29 +00:00
if cfg == nil {
return true
}
return cfg . Enabled != nil && ! * cfg . Enabled
}
2026-05-23 13:39:15 +00:00
func publishRunUploadDisabled ( env * Env ) bool {
2026-05-23 04:30:45 +00:00
cfg := env . Config . Pipeline . Publish
2026-05-16 14:43:29 +00:00
if cfg == nil {
return true
}
return cfg . UploadRun != nil && ! * cfg . UploadRun
}
2026-05-23 04:42:08 +00:00
func validatePublishPrerequisites ( m * manifest . Manifest ) error {
2026-05-16 14:43:29 +00:00
if m == nil {
return fmt . Errorf ( "manifest is required" )
}
2026-05-23 13:39:15 +00:00
for _ , stageName := range publishPrerequisiteStages {
2026-05-16 14:43:29 +00:00
sr := m . Stages [ stageName ]
if sr == nil {
return fmt . Errorf ( "prerequisite stage %q has not succeeded" , stageName )
}
if sr . Status != manifest . StatusSucceeded {
return fmt . Errorf ( "prerequisite stage %q status is %q (want %q)" , stageName , sr . Status , manifest . StatusSucceeded )
}
}
return nil
}
2026-08-10 20:05:24 +00:00
func resolvePublishRunManifestPath ( env * Env , m * manifest . Manifest ) ( string , error ) {
2026-05-16 14:43:29 +00:00
sessionID := strings . TrimSpace ( env . Config . Session . SessionID )
2026-05-18 01:13:51 +00:00
if sessionID == "" && m != nil {
2026-05-16 14:43:29 +00:00
sessionID = strings . TrimSpace ( m . SessionID )
}
campaign := strings . TrimSpace ( env . Config . Session . Campaign )
2026-05-18 01:13:51 +00:00
if campaign == "" && m != nil {
2026-05-16 14:43:29 +00:00
campaign = strings . TrimSpace ( m . Campaign )
}
2026-05-18 01:13:51 +00:00
runID := ""
if m != nil {
runID = strings . TrimSpace ( m . RunID )
}
if sessionID == "" || campaign == "" {
return "" , fmt . Errorf ( "campaign and session id are required" )
}
2026-05-16 14:43:29 +00:00
if runID == "" {
return "" , fmt . Errorf ( "run id is required" )
}
2026-05-18 01:13:51 +00:00
2026-08-10 20:05:24 +00:00
canonical := filepath . Clean ( artifacts . SessionRunManifestPathForCampaign ( env . Config . Pipeline . Workspace . Root , campaign , sessionID , runID ) )
canonicalExists , err := directoryExists ( filepath . Dir ( canonical ) )
2026-05-18 01:13:51 +00:00
if err != nil {
2026-08-10 20:05:24 +00:00
return "" , fmt . Errorf ( "check canonical run manifest directory %q: %w" , filepath . Dir ( canonical ) , err )
2026-05-16 14:43:29 +00:00
}
2026-05-18 03:02:22 +00:00
if ! canonicalExists {
2026-08-10 20:05:24 +00:00
return "" , fmt . Errorf ( "run manifest directory not found for campaign %q session %q run %q at canonical path %q" , campaign , sessionID , runID , filepath . Dir ( canonical ) )
2026-05-18 01:13:51 +00:00
}
2026-05-18 03:02:22 +00:00
return canonical , nil
2026-05-18 01:13:51 +00:00
}
2026-05-23 13:39:15 +00:00
func publishSessionPaths ( env * Env , m * manifest . Manifest ) artifacts . SessionPaths {
2026-05-19 20:05:24 -05:00
sessionID := strings . TrimSpace ( env . Config . Session . SessionID )
if sessionID == "" && m != nil {
sessionID = strings . TrimSpace ( m . SessionID )
}
campaign := strings . TrimSpace ( env . Config . Session . Campaign )
if campaign == "" && m != nil {
campaign = strings . TrimSpace ( m . Campaign )
}
store := artifacts . NewLocalStore ( env . Config . Pipeline . Workspace . Root )
return store . SessionPathsFor ( campaign , sessionID )
}
2026-05-23 04:42:08 +00:00
func resolvePublishOutputs (
2026-05-19 20:05:24 -05:00
paths artifacts . SessionPaths ,
m * manifest . Manifest ,
catalog * artifacts . ArtifactCatalog ,
2026-05-23 04:42:08 +00:00
rules [ ] config . PublishOutputRule ,
locks [ ] config . PublishLockRule ,
2026-05-22 18:01:05 -05:00
selectedArtifactKeys [ ] string ,
2026-08-10 20:05:24 +00:00
runPrefix string ,
2026-05-23 04:42:08 +00:00
) ( [ ] publishOutput , [ ] string , [ ] publishSkippedUnselectedOutput , [ ] publishLockedOutput , error ) {
out := make ( [ ] publishOutput , 0 , len ( rules ) )
skippedOptionalOutputs := make ( [ ] string , 0 )
skippedUnselectedOutputs := make ( [ ] publishSkippedUnselectedOutput , 0 )
lockedOutputs := make ( [ ] publishLockedOutput , 0 )
2026-05-23 13:39:15 +00:00
lockSet := publishLockSet ( locks )
selectedSet := publishSelectedArtifactSet ( selectedArtifactKeys )
2026-05-23 13:28:25 +00:00
configuredOutputs := configuredOutputPathMapFromCatalog ( catalog )
2026-08-10 00:32:24 +00:00
extractionOutputs := extractionOutputSetFromCatalog ( catalog )
2026-05-16 15:01:01 +00:00
for _ , rule := range rules {
2026-05-19 20:05:24 -05:00
source := strings . TrimSpace ( rule . Source )
2026-05-16 15:01:01 +00:00
required := rule . Required == nil || * rule . Required
2026-08-10 00:32:24 +00:00
dest , err := resolvePublishOutputDest ( rule , configuredOutputs , extractionOutputs )
2026-05-18 01:13:51 +00:00
if err != nil {
2026-05-22 18:01:05 -05:00
return nil , nil , nil , nil , fmt . Errorf ( "source %q: %w" , source , err )
}
if len ( selectedSet ) > 0 {
2026-05-23 13:28:25 +00:00
if key , ok := artifactpolicy . ParseConfiguredSource ( source ) ; ok {
2026-05-22 18:01:05 -05:00
if _ , selected := selectedSet [ key ] ; ! selected {
2026-05-23 04:42:08 +00:00
skippedUnselectedOutputs = append ( skippedUnselectedOutputs , publishSkippedUnselectedOutput {
2026-05-22 18:01:05 -05:00
Source : source ,
Dest : dest ,
Required : required ,
} )
continue
}
}
2026-05-16 15:01:01 +00:00
}
2026-05-20 21:40:09 -05:00
lock , locked := lockSet [ source ]
2026-05-19 20:05:24 -05:00
resolved , err := artifacts . ResolveSessionArtifactWithCatalog ( paths , m , source , catalog )
if err != nil {
2026-05-20 21:40:09 -05:00
if locked {
2026-05-23 04:42:08 +00:00
lockedOutputs = append ( lockedOutputs , publishLockedOutput {
2026-05-20 21:40:09 -05:00
Source : source ,
Dest : dest ,
2026-08-10 20:05:24 +00:00
RemoteKey : publishOutputRemoteKey ( runPrefix , dest ) ,
2026-05-20 21:40:09 -05:00
Reason : strings . TrimSpace ( lock . Reason ) ,
Required : required ,
} )
continue
}
2026-05-19 20:05:24 -05:00
if errors . Is ( err , artifacts . ErrSessionArtifactNotFound ) && ! required {
2026-05-23 04:42:08 +00:00
skippedOptionalOutputs = append ( skippedOptionalOutputs , dest )
2026-05-19 20:05:24 -05:00
continue
}
if errors . Is ( err , artifacts . ErrSessionArtifactNotFound ) {
2026-05-23 04:42:08 +00:00
return nil , nil , nil , nil , fmt . Errorf ( "required output source unavailable: %q" , source )
2026-05-19 20:05:24 -05:00
}
2026-05-22 18:01:05 -05:00
return nil , nil , nil , nil , fmt . Errorf ( "resolve source %q: %w" , source , err )
2026-05-20 21:40:09 -05:00
}
if locked {
2026-05-23 04:42:08 +00:00
lockedOutputs = append ( lockedOutputs , publishLockedOutput {
2026-05-20 21:40:09 -05:00
Source : source ,
Dest : dest ,
2026-08-10 20:05:24 +00:00
RemoteKey : publishOutputRemoteKey ( runPrefix , dest ) ,
2026-05-20 21:40:09 -05:00
Reason : strings . TrimSpace ( lock . Reason ) ,
Required : required ,
LocalPath : resolved . Path ,
Provenance : resolved . Provenance ,
} )
continue
2026-05-18 01:13:51 +00:00
}
2026-05-23 04:42:08 +00:00
out = append ( out , publishOutput {
2026-05-19 20:05:24 -05:00
Source : source ,
Dest : dest ,
Required : required ,
LocalPath : resolved . Path ,
Provenance : resolved . Provenance ,
2026-05-16 15:01:01 +00:00
} )
}
2026-05-23 04:42:08 +00:00
return out , skippedOptionalOutputs , skippedUnselectedOutputs , lockedOutputs , nil
2026-05-22 18:01:05 -05:00
}
2026-05-23 13:39:15 +00:00
func publishSelectedArtifactSet ( selected [ ] string ) map [ string ] struct { } {
2026-05-22 18:01:05 -05:00
if len ( selected ) == 0 {
return nil
}
out := make ( map [ string ] struct { } , len ( selected ) )
for _ , key := range selected {
trimmed := strings . TrimSpace ( key )
if trimmed == "" {
continue
}
out [ trimmed ] = struct { } { }
}
return out
2026-05-20 21:40:09 -05:00
}
2026-05-23 13:39:15 +00:00
func publishLockSet ( locks [ ] config . PublishLockRule ) map [ string ] config . PublishLockRule {
2026-05-23 04:42:08 +00:00
out := make ( map [ string ] config . PublishLockRule , len ( locks ) )
2026-05-20 21:40:09 -05:00
for _ , lock := range locks {
source := strings . TrimSpace ( lock . Source )
if source == "" {
continue
}
lock . Source = source
lock . Reason = strings . TrimSpace ( lock . Reason )
out [ source ] = lock
}
return out
2026-05-16 15:01:01 +00:00
}
2026-08-10 00:32:24 +00:00
func resolvePublishOutputDest ( rule config . PublishOutputRule , configured map [ string ] string , extractions map [ string ] struct { } ) ( string , error ) {
return artifactpolicy . ResolvePublishedDestinationWithExtractions ( rule . Source , rule . Dest , configured , extractions )
2026-05-23 13:28:25 +00:00
}
func configuredOutputPathMapFromCatalog ( catalog * artifacts . ArtifactCatalog ) map [ string ] string {
out := map [ string ] string { }
if catalog == nil {
return out
}
for _ , entry := range catalog . ListConfigured ( ) {
if strings . TrimSpace ( entry . ConfiguredKey ) == "" {
continue
2026-05-19 20:05:24 -05:00
}
2026-05-23 13:28:25 +00:00
out [ entry . ConfiguredKey ] = strings . TrimSpace ( entry . CanonicalRelPath )
2026-05-19 20:05:24 -05:00
}
2026-05-23 13:28:25 +00:00
return out
2026-05-19 20:05:24 -05:00
}
2026-08-10 00:32:24 +00:00
func extractionOutputSetFromCatalog ( catalog * artifacts . ArtifactCatalog ) map [ string ] struct { } {
out := map [ string ] struct { } { }
if catalog == nil {
return out
}
for _ , entry := range catalog . ListExtraction ( ) {
if strings . TrimSpace ( entry . ExtractionKey ) != "" {
out [ entry . ExtractionKey ] = struct { } { }
}
}
return out
}
2026-05-23 13:39:15 +00:00
func buildPublishRuntimeArtifactCatalog (
2026-05-19 20:05:24 -05:00
paths artifacts . SessionPaths ,
2026-08-10 00:32:24 +00:00
m * manifest . Manifest ,
2026-05-19 20:05:24 -05:00
scriptoriumCfg * config . ScriptoriumConfig ,
2026-08-10 00:32:24 +00:00
notariusCfg * config . NotariusConfig ,
2026-05-19 20:05:24 -05:00
) ( * artifacts . ArtifactCatalog , error ) {
2026-08-10 22:27:06 +00:00
extractionDefinitions := artifacts . ExtractionDefinitionsFromConfig ( notariusCfg )
configured := artifacts . ConfiguredArtifactDefinitions ( nil )
if scriptoriumCfg != nil {
configured = artifacts . ConfiguredArtifactDefinitions ( scriptoriumCfg . Artifacts )
}
effective , err := artifacts . ResolveEffectiveArtifactSet ( configured , nil )
if err != nil {
2026-05-19 20:05:24 -05:00
return nil , err
}
2026-08-10 22:27:06 +00:00
catalog , err := artifacts . BootstrapRuntimeCatalog ( configured , effective , extractionDefinitions )
if err != nil {
2026-08-10 00:32:24 +00:00
return nil , err
}
if notariusCfg != nil && notariusCfg . Enabled {
catalog . HydrateExtractionArtifacts ( paths , m , extractionDefinitions )
}
2026-08-29 18:46:40 +00:00
catalog . HydrateAnalyzeArtifacts ( paths , m , configured )
2026-05-19 20:05:24 -05:00
return catalog , nil
}
2026-08-10 19:16:18 +00:00
func collectPublishRunFiles ( runRoot string , runManifest * manifest . RunManifest ) ( [ ] publishUploadFile , error ) {
if runManifest == nil {
return nil , fmt . Errorf ( "run manifest is required" )
}
files := map [ string ] publishUploadFile {
"manifest.json" : { RelativePath : "manifest.json" , SourceRelativePath : "manifest.json" } ,
}
add := func ( path , checksum string ) error {
if strings . TrimSpace ( path ) == "" {
2026-05-16 14:43:29 +00:00
return nil
}
2026-08-10 19:16:18 +00:00
rel , err := pathsafe . SlashRelativeFromRoot ( runRoot , path )
2026-05-18 01:13:51 +00:00
if err != nil {
2026-08-10 01:43:43 +00:00
return nil
}
2026-08-10 19:16:18 +00:00
candidate := publishUploadFile { RelativePath : rel , SourceRelativePath : rel , Checksum : strings . TrimSpace ( checksum ) }
if prior , exists := files [ rel ] ; exists && prior . Checksum != "" && candidate . Checksum != "" && prior . Checksum != candidate . Checksum {
return fmt . Errorf ( "source %q has conflicting declared checksums" , rel )
2026-08-10 01:43:43 +00:00
}
2026-08-10 19:16:18 +00:00
if prior , exists := files [ rel ] ; exists && prior . Checksum != "" {
candidate . Checksum = prior . Checksum
}
files [ rel ] = candidate
2026-05-18 01:13:51 +00:00
return nil
2026-05-16 14:43:29 +00:00
}
2026-08-10 19:16:18 +00:00
for _ , record := range runManifest . Stages {
if record == nil {
continue
}
for _ , output := range record . Outputs {
if err := add ( output . ArchivePath , output . Checksum ) ; err != nil {
return nil , err
}
if output . ArchivePath == "" {
if err := add ( output . LocalPath , output . Checksum ) ; err != nil {
return nil , err
}
}
}
for _ , path := range append ( append ( [ ] string ( nil ) , record . Logs ... ) , record . GeneratedConfigs ... ) {
if err := add ( path , "" ) ; err != nil {
return nil , err
}
2026-05-16 14:43:29 +00:00
}
}
2026-08-10 19:16:18 +00:00
out := make ( [ ] publishUploadFile , 0 , len ( files ) )
2026-05-18 01:13:51 +00:00
for _ , file := range files {
2026-08-10 19:16:18 +00:00
out = append ( out , file )
2026-05-18 01:13:51 +00:00
}
2026-08-10 19:16:18 +00:00
sort . Slice ( out , func ( i , j int ) bool { return out [ i ] . RelativePath < out [ j ] . RelativePath } )
return out , nil
2026-05-16 14:43:29 +00:00
}
2026-05-16 15:01:01 +00:00
2026-08-10 19:16:18 +00:00
func collectPublishPreviousFiles ( ctx context . Context , previousDir string ) ( [ ] publishUploadFile , * os . File , error ) {
2026-05-20 15:03:30 +00:00
previousDir = filepath . Clean ( strings . TrimSpace ( previousDir ) )
2026-08-10 19:16:18 +00:00
if previousDir == "" || previousDir == "." {
return nil , nil , fmt . Errorf ( "previous directory is required" )
}
info , err := os . Lstat ( previousDir )
if errors . Is ( err , os . ErrNotExist ) {
return nil , nil , nil
2026-05-20 15:03:30 +00:00
}
if err != nil {
2026-08-10 19:16:18 +00:00
return nil , nil , fmt . Errorf ( "inspect previous directory: %w" , err )
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
if info . Mode ( ) & os . ModeSymlink != 0 || ! info . IsDir ( ) {
return nil , nil , fmt . Errorf ( "previous directory is not a regular directory" )
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
manifestFile , err := fileops . OpenConfinedRegularFile ( previousDir , "manifest.json" )
if err != nil {
if errors . Is ( err , os . ErrNotExist ) {
return nil , nil , nil
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
return nil , nil , fmt . Errorf ( "open previous manifest: %w" , err )
}
previousManifest , err := ( & manifest . LocalStore { } ) . LoadReader ( ctx , manifestFile )
if err != nil {
_ = manifestFile . Close ( )
return nil , nil , fmt . Errorf ( "load previous manifest: %w" , err )
}
if _ , err := manifestFile . Seek ( 0 , io . SeekStart ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , fmt . Errorf ( "rewind previous manifest: %w" , err )
}
files := map [ string ] publishUploadFile {
"manifest.json" : { RelativePath : filepath . ToSlash ( filepath . Join ( config . PathPreviousDirSegment , "manifest.json" ) ) , SourceRelativePath : "manifest.json" } ,
}
add := func ( path , checksum string ) error {
if strings . TrimSpace ( path ) == "" {
2026-05-20 15:03:30 +00:00
return nil
}
2026-08-10 19:16:18 +00:00
rel , err := pathsafe . SlashRelativeFromRoot ( previousDir , path )
2026-05-20 15:03:30 +00:00
if err != nil {
2026-08-10 19:16:18 +00:00
return nil
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
archivePath := filepath . ToSlash ( filepath . Join ( config . PathPreviousDirSegment , rel ) )
candidate := publishUploadFile { RelativePath : archivePath , SourceRelativePath : rel , Checksum : strings . TrimSpace ( checksum ) }
if prior , exists := files [ rel ] ; exists && prior . Checksum != "" && candidate . Checksum != "" && prior . Checksum != candidate . Checksum {
return fmt . Errorf ( "source %q has conflicting declared checksums" , rel )
}
if prior , exists := files [ rel ] ; exists && prior . Checksum != "" {
candidate . Checksum = prior . Checksum
}
files [ rel ] = candidate
2026-05-20 15:03:30 +00:00
return nil
}
2026-08-10 19:16:18 +00:00
for _ , artifact := range previousManifest . Artifacts {
if err := add ( artifact . ArchivePath , artifact . Checksum ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , err
}
if artifact . ArchivePath == "" {
if err := add ( artifact . LocalPath , artifact . Checksum ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , err
}
}
}
for _ , record := range previousManifest . Stages {
if record == nil {
continue
}
for _ , artifact := range record . Outputs {
if err := add ( artifact . ArchivePath , artifact . Checksum ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , err
}
if artifact . ArchivePath == "" {
if err := add ( artifact . LocalPath , artifact . Checksum ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , err
}
}
}
for _ , path := range append ( append ( [ ] string ( nil ) , record . Logs ... ) , record . GeneratedConfigs ... ) {
if err := add ( path , "" ) ; err != nil {
_ = manifestFile . Close ( )
return nil , nil , err
}
}
}
out := make ( [ ] publishUploadFile , 0 , len ( files ) )
for _ , file := range files {
out = append ( out , file )
}
sort . Slice ( out , func ( i , j int ) bool { return out [ i ] . RelativePath < out [ j ] . RelativePath } )
return out , manifestFile , nil
2026-05-20 15:03:30 +00:00
}
2026-08-10 19:16:18 +00:00
func openPublishRunFiles ( runRoot string , files [ ] publishUploadFile , manifestFile * os . File ) ( [ ] * os . File , error ) {
sources := make ( [ ] * os . File , 0 , len ( files ) )
for _ , candidate := range files {
if manifestFile == nil {
closePublishSources ( sources )
return nil , fmt . Errorf ( "archive manifest is required" )
}
file := manifestFile
if candidate . SourceRelativePath != "manifest.json" {
var err error
file , err = fileops . OpenConfinedRegularFile ( runRoot , candidate . SourceRelativePath )
if err != nil {
closePublishSources ( sources )
return nil , err
}
2026-05-17 20:57:27 +00:00
}
2026-08-10 19:16:18 +00:00
if err := verifyPublishSourceChecksum ( file , candidate . RelativePath , candidate . Checksum ) ; err != nil {
if file != manifestFile {
_ = file . Close ( )
}
closePublishSources ( sources )
return nil , err
}
sources = append ( sources , file )
2026-05-17 11:03:02 -05:00
}
2026-08-10 19:16:18 +00:00
return sources , nil
}
func verifyPublishSourceChecksum ( file * os . File , relativePath , expected string ) error {
if strings . TrimSpace ( expected ) == "" {
return nil
}
hash := sha256 . New ( )
if _ , err := io . Copy ( hash , file ) ; err != nil {
return fmt . Errorf ( "read source %q for checksum: %w" , relativePath , err )
}
if _ , err := file . Seek ( 0 , io . SeekStart ) ; err != nil {
return fmt . Errorf ( "rewind source %q after checksum: %w" , relativePath , err )
}
if actual := hex . EncodeToString ( hash . Sum ( nil ) ) ; ! strings . EqualFold ( actual , expected ) {
return fmt . Errorf ( "source %q does not match its declared checksum" , relativePath )
}
return nil
}
func closePublishSources ( sources [ ] * os . File ) {
for _ , source := range sources {
_ = source . Close ( )
2026-05-18 01:13:51 +00:00
}
}
2026-05-17 11:03:02 -05:00
2026-05-18 01:13:51 +00:00
func directoryExists ( path string ) ( bool , error ) {
info , err := os . Stat ( path )
if err == nil {
return info . IsDir ( ) , nil
}
if os . IsNotExist ( err ) {
return false , nil
2026-05-17 11:03:02 -05:00
}
2026-05-18 01:13:51 +00:00
return false , err
2026-05-17 11:03:02 -05:00
}
2026-08-10 20:05:24 +00:00
func writeCommittedSessionManifest ( m * manifest . Manifest , publishMetadata map [ string ] any ) ( string , error ) {
2026-05-16 15:01:01 +00:00
if m == nil {
return "" , fmt . Errorf ( "manifest is required" )
}
clone := * m
clone . Stages = make ( map [ string ] * manifest . StageRecord , len ( m . Stages ) )
for name , sr := range m . Stages {
if sr == nil {
continue
}
stageCopy := * sr
if sr . Outputs != nil {
stageCopy . Outputs = append ( [ ] manifest . ArtifactRecord ( nil ) , sr . Outputs ... )
}
if sr . Logs != nil {
stageCopy . Logs = append ( [ ] string ( nil ) , sr . Logs ... )
}
if sr . GeneratedConfigs != nil {
stageCopy . GeneratedConfigs = append ( [ ] string ( nil ) , sr . GeneratedConfigs ... )
}
if sr . Metadata != nil {
metaCopy := make ( map [ string ] any , len ( sr . Metadata ) )
for k , v := range sr . Metadata {
metaCopy [ k ] = v
}
stageCopy . Metadata = metaCopy
}
clone . Stages [ name ] = & stageCopy
}
2026-08-10 20:05:24 +00:00
clone . MarkStageSucceeded ( "publish" , clone . UpdatedAt , nil )
2026-05-23 04:30:45 +00:00
if sr := clone . Stages [ "publish" ] ; sr != nil {
2026-05-23 13:39:15 +00:00
sr . Metadata = publishMetadata
2026-05-16 15:01:01 +00:00
}
data , err := json . MarshalIndent ( & clone , "" , " " )
if err != nil {
return "" , fmt . Errorf ( "marshal manifest: %w" , err )
}
data = append ( data , '\n' )
2026-08-10 20:05:24 +00:00
tmp , err := os . CreateTemp ( "" , "narratio-committed-session-manifest-*.json" )
2026-05-16 15:01:01 +00:00
if err != nil {
return "" , fmt . Errorf ( "create temp file: %w" , err )
}
path := tmp . Name ( )
if _ , err := tmp . Write ( data ) ; err != nil {
_ = tmp . Close ( )
return "" , fmt . Errorf ( "write temp manifest: %w" , err )
}
if err := tmp . Close ( ) ; err != nil {
return "" , fmt . Errorf ( "close temp manifest: %w" , err )
}
return path , nil
}
2026-08-10 20:05:24 +00:00
func publishMetadata (
2026-08-10 20:29:00 +00:00
bucket , runPrefix , runID string ,
2026-05-16 15:01:01 +00:00
runUploaded [ ] string ,
2026-05-23 04:42:08 +00:00
publishedUploaded [ ] string ,
2026-05-20 15:03:30 +00:00
previousUploaded [ ] string ,
2026-05-23 04:42:08 +00:00
skippedOptionalOutputs [ ] string ,
skippedUnselectedOutputs [ ] publishSkippedUnselectedOutput ,
lockedOutputs [ ] publishLockedOutput ,
2026-08-10 20:05:24 +00:00
commitKey string ,
pointerKey string ,
2026-05-16 15:01:01 +00:00
) map [ string ] any {
return map [ string ] any {
2026-05-23 04:42:08 +00:00
"stage" : "publish" ,
"uploaded" : true ,
"s3_bucket" : bucket ,
"s3_run_prefix" : runPrefix ,
2026-08-10 20:29:00 +00:00
"published_run_id" : runID ,
2026-05-23 04:42:08 +00:00
"run_files_uploaded" : len ( runUploaded ) ,
"run_uploaded_paths" : append ( [ ] string ( nil ) , runUploaded ... ) ,
"published_files_uploaded" : len ( publishedUploaded ) ,
"published_paths" : append ( [ ] string ( nil ) , publishedUploaded ... ) ,
"previous_files_uploaded" : len ( previousUploaded ) ,
"previous_uploaded_paths" : append ( [ ] string ( nil ) , previousUploaded ... ) ,
"skipped_optional_outputs" : append ( [ ] string ( nil ) , skippedOptionalOutputs ... ) ,
"skipped_unselected_outputs" : skippedUnselectedOutputMetadata ( skippedUnselectedOutputs ) ,
"locked_output_count" : len ( lockedOutputs ) ,
"locked_outputs" : lockedOutputMetadata ( lockedOutputs ) ,
2026-08-10 20:05:24 +00:00
"remote_commit_key" : commitKey ,
"current_commit_pointer_key" : pointerKey ,
2026-05-23 04:42:08 +00:00
"audio_upload_skipped" : true ,
2026-05-16 15:01:01 +00:00
}
}
2026-05-20 21:40:09 -05:00
2026-05-23 04:42:08 +00:00
func skippedUnselectedOutputMetadata ( skipped [ ] publishSkippedUnselectedOutput ) [ ] map [ string ] any {
2026-05-22 18:01:05 -05:00
out := make ( [ ] map [ string ] any , 0 , len ( skipped ) )
for _ , item := range skipped {
out = append ( out , map [ string ] any {
"source" : item . Source ,
"dest" : item . Dest ,
"required" : item . Required ,
} )
}
return out
}
2026-05-23 04:42:08 +00:00
func lockedOutputMetadata ( locked [ ] publishLockedOutput ) [ ] map [ string ] any {
2026-05-20 21:40:09 -05:00
out := make ( [ ] map [ string ] any , 0 , len ( locked ) )
for _ , item := range locked {
out = append ( out , map [ string ] any {
"source" : item . Source ,
"dest" : item . Dest ,
"remote_key" : item . RemoteKey ,
"reason" : item . Reason ,
"required" : item . Required ,
"local_path" : item . LocalPath ,
"provenance" : item . Provenance ,
} )
}
return out
}