Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 97 additions & 45 deletions cmd/record-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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(),
}}
}
149 changes: 149 additions & 0 deletions cmd/record-release/main_test.go
Original file line number Diff line number Diff line change
@@ -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
}