diff --git a/cmd/record-release/main.go b/cmd/record-release/main.go index 449fa3c..6b24f63 100644 --- a/cmd/record-release/main.go +++ b/cmd/record-release/main.go @@ -38,22 +38,31 @@ type RecordReleaseRequest struct { // ReleaseAsset is the transformed asset for the registry API. type ReleaseAsset struct { - Platform string `json:"platform"` - Filename string `json:"filename"` - MediaType string `json:"mediaType"` - SizeBytes int64 `json:"sizeBytes"` - Sha256 string `json:"sha256"` - DownloadURL string `json:"downloadUrl"` - SignatureURL string `json:"signatureUrl,omitempty"` - CertificateURL string `json:"certificateUrl,omitempty"` - SbomURL string `json:"sbomUrl,omitempty"` + Platform string `json:"platform"` + Filename string `json:"filename"` + MediaType string `json:"mediaType"` + SizeBytes int64 `json:"sizeBytes"` + Sha256 string `json:"sha256"` + DownloadURL string `json:"downloadUrl"` + SignatureURL string `json:"signatureUrl,omitempty"` + CertificateURL string `json:"certificateUrl,omitempty"` + SbomURL string `json:"sbomUrl,omitempty"` + Attestations []*ReleaseAttestation `json:"attestations,omitempty"` } // ReleaseImage is the transformed image for the registry API. type ReleaseImage struct { - Ref string `json:"ref"` - Digest string `json:"digest"` - Platform string `json:"platform"` + Ref string `json:"ref"` + Digest string `json:"digest"` + Platform string `json:"platform"` + Attestations []*ReleaseAttestation `json:"attestations,omitempty"` +} + +// ReleaseAttestation is the registry API attestation shape. +type ReleaseAttestation struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Digest string `json:"digest,omitempty"` } // authTransport adds a Bearer token to every outgoing request. @@ -71,14 +80,14 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { func main() { var ( - manifestPath string - docsPath string - org string - name string - version string - repositoryURL string - commitSha string - workflowRunID string + manifestPath string + docsPath string + org string + name string + version string + repositoryURL string + commitSha string + workflowRunID string registryURL string changelogPath string configSchemaPath string @@ -206,31 +215,8 @@ func main() { } } - // Transform manifest assets: href->downloadUrl, signatureHref->signatureUrl, etc. - assets := make(map[string]*ReleaseAsset) - for platform, asset := range manifest.GetAssets() { - assets[platform] = &ReleaseAsset{ - Platform: platform, - Filename: asset.GetFilename(), - MediaType: asset.GetMediaType(), - SizeBytes: asset.GetSizeBytes(), - Sha256: asset.GetSha256(), - DownloadURL: asset.GetHref(), - SignatureURL: asset.GetSignatureHref(), - CertificateURL: asset.GetCertificateHref(), - SbomURL: asset.GetSbomHref(), - } - } - - // Transform manifest images: extract ref, digest, add platform from map key - images := make(map[string]*ReleaseImage) - for platform, image := range manifest.GetImages() { - images[platform] = &ReleaseImage{ - Ref: image.GetRef(), - Digest: image.GetDigest(), - Platform: platform, - } - } + assets := transformAssets(manifest) + images := transformImages(manifest) // Build request body req := &RecordReleaseRequest{ @@ -313,3 +299,69 @@ func main() { os.Exit(1) } } + +func transformAssets(manifest *pb.Manifest) map[string]*ReleaseAsset { + assets := make(map[string]*ReleaseAsset) + for platform, asset := range manifest.GetAssets() { + assets[platform] = &ReleaseAsset{ + Platform: platform, + Filename: asset.GetFilename(), + MediaType: asset.GetMediaType(), + SizeBytes: asset.GetSizeBytes(), + Sha256: asset.GetSha256(), + DownloadURL: asset.GetHref(), + SignatureURL: asset.GetSignatureHref(), + CertificateURL: asset.GetCertificateHref(), + SbomURL: asset.GetSbomHref(), + Attestations: transformAttestations(asset.GetAttestations()), + } + } + + return assets +} + +func transformImages(manifest *pb.Manifest) map[string]*ReleaseImage { + images := make(map[string]*ReleaseImage) + for platform, image := range manifest.GetImages() { + images[platform] = &ReleaseImage{ + Ref: image.GetRef(), + Digest: image.GetDigest(), + Platform: platform, + Attestations: transformImageAttestations(manifest.GetImageAttestation()), + } + } + + return images +} + +// Asset-level attestations are the source of truth in the registry. The dist +// exporter derives the manifest-level assetAttestation summary from them. +func transformAttestations(in []*pb.AttestationDescriptor) []*ReleaseAttestation { + var out []*ReleaseAttestation + for _, att := range in { + if att.GetPredicateType() == "" || att.GetBundleHref() == "" { + continue + } + out = append(out, &ReleaseAttestation{ + Type: att.GetPredicateType(), + URL: att.GetBundleHref(), + }) + } + return out +} + +func transformImageAttestations(att *pb.AttestationDescriptor) []*ReleaseAttestation { + if att == nil || att.GetPredicateType() == "" { + return nil + } + // The registry stores image attestations per image. Dist manifests keep a + // single imageAttestation summary, and the exporter recreates that summary + // from the per-image entries. + // + // Image attestations are discovered through OCI referrers, so bundleHref is + // intentionally empty in current release manifests. + return []*ReleaseAttestation{{ + Type: att.GetPredicateType(), + URL: att.GetBundleHref(), + }} +} diff --git a/cmd/record-release/main_test.go b/cmd/record-release/main_test.go new file mode 100644 index 0000000..623e711 --- /dev/null +++ b/cmd/record-release/main_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1" +) + +const ( + inTotoStatement = "https://in-toto.io/Statement/v1" + slsaProvenance = "https://slsa.dev/provenance/v1" + spdxDocument = "https://spdx.dev/Document" +) + +func TestTransformAssetsPreservesAssetAttestations(t *testing.T) { + sizeBytes := int64(123) + manifest := pb.Manifest_builder{ + Assets: map[string]*pb.Asset{ + "linux-amd64": pb.Asset_builder{ + Filename: strPtr("baton-example-v1.2.3-linux-amd64.tar.gz"), + MediaType: strPtr("application/gzip"), + SizeBytes: &sizeBytes, + Sha256: strPtr("asset-sha"), + Href: strPtr("https://dist.example.com/asset.tar.gz"), + Attestations: []*pb.AttestationDescriptor{ + attestation(slsaProvenance, "https://dist.example.com/provenance.sigstore.json"), + attestation(spdxDocument, "https://dist.example.com/sbom.sigstore.json"), + }, + }.Build(), + }, + }.Build() + + assets := transformAssets(manifest) + got := assets["linux-amd64"].Attestations + want := []*ReleaseAttestation{ + {Type: slsaProvenance, URL: "https://dist.example.com/provenance.sigstore.json"}, + {Type: spdxDocument, URL: "https://dist.example.com/sbom.sigstore.json"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("attestations = %#v, want %#v", got, want) + } +} + +func TestTransformAttestationsSkipsIncompleteAssetEntries(t *testing.T) { + got := transformAttestations([]*pb.AttestationDescriptor{ + attestation(slsaProvenance, "https://dist.example.com/provenance.sigstore.json"), + attestation("", "https://dist.example.com/missing-predicate.sigstore.json"), + attestation(spdxDocument, ""), + }) + want := []*ReleaseAttestation{ + {Type: slsaProvenance, URL: "https://dist.example.com/provenance.sigstore.json"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("attestations = %#v, want %#v", got, want) + } +} + +func TestTransformImagesAppliesManifestImageAttestation(t *testing.T) { + isIndex := true + manifest := pb.Manifest_builder{ + ImageAttestation: attestation(slsaProvenance, ""), + Images: map[string]*pb.Image{ + "ecrPublic": pb.Image_builder{ + Ref: strPtr("public.ecr.aws/example/baton-example:v1.2.3"), + Digest: strPtr("sha256:ecr"), + IsIndex: &isIndex, + }.Build(), + "ghcr": pb.Image_builder{ + Ref: strPtr("ghcr.io/example/baton-example:v1.2.3"), + Digest: strPtr("sha256:ghcr"), + IsIndex: &isIndex, + }.Build(), + }, + }.Build() + + images := transformImages(manifest) + for platform, image := range images { + want := []*ReleaseAttestation{{Type: slsaProvenance}} + if !reflect.DeepEqual(image.Attestations, want) { + t.Fatalf("%s attestations = %#v, want %#v", platform, image.Attestations, want) + } + } +} + +func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) { + req := &RecordReleaseRequest{ + Org: "example", + Name: "baton-example", + Version: "v1.2.3", + Assets: map[string]*ReleaseAsset{ + "linux-amd64": { + Platform: "linux-amd64", + Attestations: []*ReleaseAttestation{ + {Type: slsaProvenance, URL: "https://dist.example.com/provenance.sigstore.json"}, + }, + }, + }, + Images: map[string]*ReleaseImage{ + "ghcr": { + Platform: "ghcr", + Attestations: []*ReleaseAttestation{{Type: slsaProvenance}}, + }, + }, + } + + body, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + var got struct { + Assets map[string]struct { + Attestations []ReleaseAttestation `json:"attestations"` + } `json:"assets"` + Images map[string]struct { + Attestations []ReleaseAttestation `json:"attestations"` + } `json:"images"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + + if len(got.Assets["linux-amd64"].Attestations) != 1 { + t.Fatalf("asset attestations = %#v, want one entry", got.Assets["linux-amd64"].Attestations) + } + if got.Assets["linux-amd64"].Attestations[0].URL == "" { + t.Fatal("asset attestation URL was not marshaled") + } + if len(got.Images["ghcr"].Attestations) != 1 { + t.Fatalf("image attestations = %#v, want one entry", got.Images["ghcr"].Attestations) + } + if got.Images["ghcr"].Attestations[0].URL != "" { + t.Fatalf("image attestation URL = %q, want empty", got.Images["ghcr"].Attestations[0].URL) + } +} + +func attestation(predicateType, bundleHref string) *pb.AttestationDescriptor { + return pb.AttestationDescriptor_builder{ + AttestationType: strPtr(inTotoStatement), + PredicateType: strPtr(predicateType), + BundleHref: strPtr(bundleHref), + }.Build() +} + +func strPtr(s string) *string { + return &s +}