diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 953a082..a8e181c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -250,7 +250,7 @@ jobs: ((PROVENANCE_COUNT++)) || true done - # Note: checksums provenance is generated in record-connector-registry job + # Note: checksums provenance is generated in publish-release-manifest job # after merging with Windows hashes echo "Generated provenance bundles: ${PROVENANCE_COUNT}" @@ -837,20 +837,24 @@ jobs: GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} - name: Set up Go for workflows - if: inputs.docker == true + if: inputs.docker == true || inputs.lambda == true uses: actions/setup-go@v6 with: go-version-file: "_workflows/go.mod" - - name: Extract GHCR and ECR public image digests from OCI assets + - name: Extract image digests from GoReleaser assets id: extract-images - if: inputs.docker == true + if: inputs.docker == true || inputs.lambda == true working-directory: _workflows env: CALLER_DIST_OCI: ../_caller/dist/oci + CALLER_DIST_LAMBDA: ../_caller/dist/lambda run: | IMAGES_JSON=$(go run ./cmd/extract-images \ + -include-public=${{ inputs.docker }} \ + -include-lambda=${{ inputs.lambda }} \ -asset-dir "${CALLER_DIST_OCI}" \ + -lambda-asset-dir "${CALLER_DIST_LAMBDA}" \ -repo-name "${{ github.event.repository.name }}" \ -tag "${{ inputs.tag }}") @@ -907,8 +911,8 @@ jobs: echo "✅ Attested $URI" done < "$DIGEST_FILE" - record-connector-registry: - # Legacy dist recording: manifest + S3 upload. + publish-release-manifest: + # Release manifest publication: manifest + checksums + S3 upload. # Require binaries to succeed; windows and docker may be skipped based on inputs. # Each optional job must succeed if it ran — a failure means incomplete release artifacts. # see: https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution @@ -1137,138 +1141,14 @@ jobs: --content-type "application/octet-stream" fi - - name: Invoke Lambda with retries - run: | - set +e # Disable default fail-fast to support retries - if [[ "$ACTIONS_STEP_DEBUG" == "true" ]]; then - set -x # Debug logging - fi - - TMPFILE=$(mktemp) - MAX_RETRIES=5 - RETRY_DELAY=10 # seconds - - for ((i=1; i<=MAX_RETRIES; i++)); do - echo "Attempt $i to invoke Lambda..." - - RESPONSE=$(aws lambda invoke \ - --function-name "${{ github.event.repository.owner.login }}-${{ github.event.repository.name }}-artifact-releases" \ - --payload "{\"tag\":\"${{ inputs.tag }}\"}" \ - --cli-binary-format raw-in-base64-out \ - "$TMPFILE" 2>&1) - EXIT_CODE=$? - - echo "AWS CLI exited with code: $EXIT_CODE" - cat "$TMPFILE" - - STATUS_CODE=$(jq -r '.statusCode' < "$TMPFILE" 2>/dev/null) - - if [[ $EXIT_CODE -eq 0 && "$STATUS_CODE" == "200" ]]; then - echo "Lambda invoked successfully." - break - - elif [[ "$RESPONSE" == *"CodeArtifactUserPendingException"* ]]; then - echo "Lambda not ready (CodeArtifactUserPendingException)." - - if [[ $i -lt $MAX_RETRIES ]]; then - WAIT_TIME=$((i * RETRY_DELAY)) - echo "Retrying in $WAIT_TIME seconds..." - sleep "$WAIT_TIME" - else - echo "Lambda still not ready after $MAX_RETRIES attempts." - rm -f "$TMPFILE" - exit 1 - fi - - else - echo "Lambda invoke failed with unexpected error: $RESPONSE" - rm -f "$TMPFILE" - exit 1 - fi - done - - rm -f "$TMPFILE" - - record-lambda-registry: - if: inputs.lambda == true - # Legacy per-connector Lambda invocation — records to connectorreleases DynamoDB. - # Only needs binaries + docker (container images). Does not gate on windows/msi since - # the Lambda pipeline only cares about container images. Will be removed after cutover - # to the registry API (record-connector-registry replaces this path). - needs: [goreleaser-binaries, goreleaser-docker] - permissions: - id-token: write - contents: read - runs-on: ubuntu-latest - steps: - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v5 - with: - role-to-assume: "arn:aws:iam::168442440833:role/GitHubActionsECRPushRole-${{ github.event.repository.name }}" - aws-region: us-west-2 - - - name: Invoke Lambda with retries - run: | - set +e # Disable default fail-fast to support retries - if [[ "$ACTIONS_STEP_DEBUG" == "true" ]]; then - set -x # Debug logging - fi - - TMPFILE=$(mktemp) - MAX_RETRIES=5 - RETRY_DELAY=10 # seconds - - for ((i=1; i<=MAX_RETRIES; i++)); do - echo "Attempt $i to invoke Lambda..." - - RESPONSE=$(aws lambda invoke \ - --function-name "${{ github.event.repository.name }}-releases" \ - --payload "{\"tag\":\"${{ inputs.tag }}\"}" \ - --cli-binary-format raw-in-base64-out \ - "$TMPFILE" 2>&1) - EXIT_CODE=$? - - echo "AWS CLI exited with code: $EXIT_CODE" - cat "$TMPFILE" - - STATUS_CODE=$(jq -r '.statusCode' < "$TMPFILE" 2>/dev/null) - - if [[ $EXIT_CODE -eq 0 && "$STATUS_CODE" == "200" ]]; then - echo "Lambda invoked successfully." - break - - elif [[ "$RESPONSE" == *"CodeArtifactUserPendingException"* ]]; then - echo "Lambda not ready (CodeArtifactUserPendingException)." - - if [[ $i -lt $MAX_RETRIES ]]; then - WAIT_TIME=$((i * RETRY_DELAY)) - echo "Retrying in $WAIT_TIME seconds..." - sleep "$WAIT_TIME" - else - echo "Lambda still not ready after $MAX_RETRIES attempts." - rm -f "$TMPFILE" - exit 1 - fi - - else - echo "Lambda invoke failed with unexpected error: $RESPONSE" - rm -f "$TMPFILE" - exit 1 - fi - done - - rm -f "$TMPFILE" - # ================================================================ - # Registry API: record release after all legacy recording completes. - # Depends on both dist (record-connector-registry) and lambda (record-lambda-registry) - # so the recording has the full picture: assets, images, config_schema, capabilities. - # continue-on-error on the recording step so failures don't block the release. - # Will become the sole recording path after cutover. + # Registry API: record release after release manifest publication. + # This is the sole release metadata recording path. # ================================================================ record-registry-api: - if: ${{ !cancelled() && needs.record-connector-registry.result == 'success' && (needs.record-lambda-registry.result == 'success' || needs.record-lambda-registry.result == 'skipped') }} - needs: [determine-workflows-ref, record-connector-registry, record-lambda-registry] + # Use !cancelled() so the explicit needs.result check controls skipped-job behavior. + if: ${{ !cancelled() && needs.publish-release-manifest.result == 'success' }} + needs: [determine-workflows-ref, publish-release-manifest] permissions: id-token: write contents: read @@ -1325,10 +1205,10 @@ jobs: RELEASED_AT=$(echo "$RELEASE_JSON" | jq -r '.published_at // .created_at // empty') echo "released_at=$RELEASED_AT" >> "$GITHUB_OUTPUT" - - name: Write merged manifest from dist recording job + - name: Write merged manifest from manifest publication job working-directory: _workflows env: - MERGED_MANIFEST: ${{ needs.record-connector-registry.outputs.merged_manifest }} + MERGED_MANIFEST: ${{ needs.publish-release-manifest.outputs.merged_manifest }} run: | mkdir -p _output echo "$MERGED_MANIFEST" | jq . > _output/manifest.json @@ -1382,8 +1262,8 @@ jobs: verify-release: # Verify release artifacts and attestations after publishing # This job is not blocking - failures trigger Datadog notification but don't fail the release - needs: [determine-workflows-ref, record-connector-registry] - if: always() && needs.record-connector-registry.result == 'success' + needs: [determine-workflows-ref, publish-release-manifest] + if: always() && needs.publish-release-manifest.result == 'success' permissions: id-token: write # Required for cosign verification contents: read @@ -1423,8 +1303,7 @@ jobs: goreleaser-binaries, goreleaser-windows, goreleaser-docker, - record-connector-registry, - record-lambda-registry, + publish-release-manifest, record-registry-api, verify-release, ] diff --git a/cmd/extract-images/main.go b/cmd/extract-images/main.go index 911a98a..12c7b0e 100644 --- a/cmd/extract-images/main.go +++ b/cmd/extract-images/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "sort" "strings" "google.golang.org/protobuf/encoding/protojson" @@ -11,90 +12,140 @@ import ( pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1" ) +const lambdaArm64ImageKey = "lambda-arm64" + func main() { var ( - assetDir string - digestFile string - repoName string - tag string + assetDir string + digestFile string + lambdaAssetDir string + lambdaDigestFile string + repoName string + tag string + includePublic bool + includeLambda bool ) flag.StringVar(&assetDir, "asset-dir", "../_caller/dist", "Directory containing asset files") flag.StringVar(&digestFile, "digest-file", "", "Path to digest file (if not provided, will be constructed from repo-name, tag, and asset-dir)") + flag.StringVar(&lambdaAssetDir, "lambda-asset-dir", "", "Directory containing Lambda asset files") + flag.StringVar(&lambdaDigestFile, "lambda-digest-file", "", "Path to Lambda digest file (if not provided, will be constructed from repo-name, tag, and lambda-asset-dir)") flag.StringVar(&repoName, "repo-name", "", "Repository name") flag.StringVar(&tag, "tag", "", "Release tag (e.g., v0.1.65 or 0.1.65)") + flag.BoolVar(&includePublic, "include-public", true, "Extract GHCR and ECR public image metadata") + flag.BoolVar(&includeLambda, "include-lambda", false, "Extract private Lambda image metadata") flag.Parse() if tag == "" { fmt.Fprintf(os.Stderr, "extract-images: error: tag is required\n") os.Exit(1) } + if !includePublic && !includeLambda { + fmt.Fprintf(os.Stderr, "extract-images: error: at least one of include-public or include-lambda must be true\n") + os.Exit(1) + } // Remove 'v' prefix if present to get version for matching image refs and file names version := strings.TrimPrefix(tag, "v") - // Construct digest file path if not provided - if digestFile == "" { + images := make(map[string]*pb.Image) + + if includePublic { + // Construct digest file path if not provided + if digestFile == "" { + if repoName == "" { + fmt.Fprintf(os.Stderr, "extract-images: error: either digest-file or repo-name must be provided\n") + os.Exit(1) + } + digestFile = digestPath(assetDir, repoName, version) + } + + content, err := os.ReadFile(digestFile) + if err != nil { + fmt.Fprintf(os.Stderr, "extract-images: ::error::Digest file not found: %s\n", digestFile) + os.Exit(1) + } + + foundGHCR, foundECR := extractPublicImages(content, version, images) + if !foundGHCR && !foundECR { + fmt.Fprintf(os.Stderr, "extract-images: ::error::Could not find GHCR or ECR public index image in %s\n", digestFile) + fmt.Fprintf(os.Stderr, "extract-images: Contents of digest file:\n%s\n", content) + os.Exit(1) + } + if !foundGHCR { + fmt.Fprintf(os.Stderr, "extract-images: ::warning::Could not find GHCR index image in %s\n", digestFile) + } + if !foundECR { + fmt.Fprintf(os.Stderr, "extract-images: ::warning::Could not find ECR public index image in %s\n", digestFile) + } + } + + if includeLambda { if repoName == "" { - fmt.Fprintf(os.Stderr, "extract-images: error: either digest-file or repo-name must be provided\n") + fmt.Fprintf(os.Stderr, "extract-images: error: repo-name is required for Lambda image extraction\n") + os.Exit(1) + } + if lambdaAssetDir == "" { + lambdaAssetDir = assetDir + } + if lambdaDigestFile == "" { + lambdaDigestFile = digestPath(lambdaAssetDir, repoName, version) + } + + content, err := os.ReadFile(lambdaDigestFile) + if err != nil { + fmt.Fprintf(os.Stderr, "extract-images: ::error::Lambda digest file not found: %s\n", lambdaDigestFile) + os.Exit(1) + } + if !extractLambdaImage(content, repoName, version, images) { + fmt.Fprintf(os.Stderr, "extract-images: ::error::Could not find Lambda arm64 image in %s\n", lambdaDigestFile) + fmt.Fprintf(os.Stderr, "extract-images: Contents of digest file:\n%s\n", content) os.Exit(1) } - digestFile = fmt.Sprintf("%s/%s_%s_digests.txt", assetDir, repoName, version) } - content, err := os.ReadFile(digestFile) + imagesJSON, err := marshalImages(images) if err != nil { - fmt.Fprintf(os.Stderr, "extract-images: ::error::Digest file not found: %s\n", digestFile) + fmt.Fprintf(os.Stderr, "extract-images: error: marshaling images: %v\n", err) os.Exit(1) } - images := make(map[string]*pb.Image) - var foundGHCR, foundECR bool - - for _, line := range strings.Split(string(content), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // docker_digest prints " " - parts := strings.Fields(line) - if len(parts) != 2 { - continue - } + // Write JSON to stdout (progress messages go to stderr) + fmt.Println(imagesJSON) + fmt.Fprintln(os.Stderr, "✅ Extracted image digests") +} - digestHex, ref := parts[0], parts[1] - digest := fmt.Sprintf("sha256:%s", digestHex) +func digestPath(assetDir, repoName, version string) string { + return fmt.Sprintf("%s/%s_%s_digests.txt", assetDir, repoName, version) +} +func extractPublicImages(content []byte, version string, images map[string]*pb.Image) (bool, bool) { + var foundGHCR, foundECR bool + for _, line := range parseDigestLines(content) { // Only capture the multi-arch index images (tagged with just version, not version-arch) - if !strings.HasSuffix(ref, fmt.Sprintf(":%s", version)) { + if !strings.HasSuffix(line.ref, fmt.Sprintf(":%s", version)) { continue } - // Build the canonical digest-pinned URI - // ref is like "ghcr.io/conductorone/baton-ukg:0.1.98" - // uri should be "ghcr.io/conductorone/baton-ukg@sha256:abc123..." - refParts := strings.Split(ref, ":") - if len(refParts) < 2 { + uri, ok := digestPinnedURI(line.ref, line.digest) + if !ok { continue } - imageBase := refParts[0] - uri := fmt.Sprintf("%s@%s", imageBase, digest) - if strings.HasPrefix(ref, "ghcr.io/conductorone/") { + if strings.HasPrefix(line.ref, "ghcr.io/conductorone/") { isIndex := true images["ghcr"] = pb.Image_builder{ - Ref: &ref, - Digest: &digest, + Ref: &line.ref, + Digest: &line.digest, Tag: &version, Uri: &uri, IsIndex: &isIndex, }.Build() foundGHCR = true - } else if strings.HasPrefix(ref, "public.ecr.aws/conductorone/") { + } else if strings.HasPrefix(line.ref, "public.ecr.aws/conductorone/") { isIndex := true images["ecrPublic"] = pb.Image_builder{ - Ref: &ref, - Digest: &digest, + Ref: &line.ref, + Digest: &line.digest, Tag: &version, Uri: &uri, IsIndex: &isIndex, @@ -103,47 +154,100 @@ func main() { } } - if !foundGHCR && !foundECR { - fmt.Fprintf(os.Stderr, "extract-images: ::error::Could not find GHCR or ECR public index image in %s\n", digestFile) - fmt.Fprintf(os.Stderr, "extract-images: Contents of digest file:\n%s\n", content) - os.Exit(1) + return foundGHCR, foundECR +} + +func extractLambdaImage(content []byte, repoName, version string, images map[string]*pb.Image) bool { + tag := fmt.Sprintf("%s-arm64", version) + for _, line := range parseDigestLines(content) { + if !strings.HasSuffix(line.ref, fmt.Sprintf(":%s", tag)) { + continue + } + + ref := fmt.Sprintf("%s:%s", repoName, tag) + uri := fmt.Sprintf("%s@%s", repoName, line.digest) + isIndex := false + images[lambdaArm64ImageKey] = pb.Image_builder{ + Ref: &ref, + Digest: &line.digest, + Tag: &tag, + Uri: &uri, + IsIndex: &isIndex, + }.Build() + return true + } + + return false +} + +type digestLine struct { + digest string + ref string +} + +func parseDigestLines(content []byte) []digestLine { + var lines []digestLine + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // docker_digest prints " " + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + + lines = append(lines, digestLine{ + digest: normalizeDigest(parts[0]), + ref: parts[1], + }) } - if !foundGHCR { - fmt.Fprintf(os.Stderr, "extract-images: ::warning::Could not find GHCR index image in %s\n", digestFile) + return lines +} + +func normalizeDigest(raw string) string { + if strings.HasPrefix(raw, "sha256:") { + return raw } - if !foundECR { - fmt.Fprintf(os.Stderr, "extract-images: ::warning::Could not find ECR public index image in %s\n", digestFile) + return fmt.Sprintf("sha256:%s", raw) +} + +func digestPinnedURI(ref, digest string) (string, bool) { + i := strings.LastIndex(ref, ":") + if i < 0 { + return "", false } + return fmt.Sprintf("%s@%s", ref[:i], digest), true +} - // Marshal images map to JSON using protojson - // Marshal each image individually and build the JSON object - // Marshal options with frontend consumption in mind. Ensures all fields are present for predictable structure. +func marshalImages(images map[string]*pb.Image) (string, error) { opts := protojson.MarshalOptions{ Multiline: true, Indent: " ", EmitUnpopulated: true, } - // Marshal each image individually and build the JSON object. - // protojson.Marshal only works on proto.Message, not on Go maps, so we construct the JSON manually. imagesJSONParts := []string{"{"} + keys := make([]string, 0, len(images)) + for key := range images { + keys = append(keys, key) + } + sort.Strings(keys) + first := true - for key, image := range images { + for _, key := range keys { if !first { imagesJSONParts = append(imagesJSONParts, ",") } first = false - imageJSON, err := opts.Marshal(image) + imageJSON, err := opts.Marshal(images[key]) if err != nil { - fmt.Fprintf(os.Stderr, "extract-images: error: marshaling image: %v\n", err) - os.Exit(1) + return "", err } imagesJSONParts = append(imagesJSONParts, fmt.Sprintf(" %q: %s", key, string(imageJSON))) } imagesJSONParts = append(imagesJSONParts, "}") - imagesJSON := strings.Join(imagesJSONParts, "\n") - - // Write JSON to stdout (progress messages go to stderr) - fmt.Println(imagesJSON) - fmt.Fprintln(os.Stderr, "✅ Extracted image digests") + return strings.Join(imagesJSONParts, "\n"), nil } diff --git a/cmd/extract-images/main_test.go b/cmd/extract-images/main_test.go new file mode 100644 index 0000000..8287682 --- /dev/null +++ b/cmd/extract-images/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "testing" + + pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1" +) + +func TestExtractPublicImages(t *testing.T) { + images := make(map[string]*pb.Image) + foundGHCR, foundECR := extractPublicImages([]byte(` +aaa111 ghcr.io/conductorone/baton-example:0.1.2 +bbb222 ghcr.io/conductorone/baton-example:0.1.2-amd64 +ccc333 public.ecr.aws/conductorone/baton-example:0.1.2 +`), "0.1.2", images) + + if !foundGHCR || !foundECR { + t.Fatalf("foundGHCR=%t foundECR=%t, want both true", foundGHCR, foundECR) + } + if images["ghcr"].GetDigest() != "sha256:aaa111" { + t.Fatalf("ghcr digest = %q", images["ghcr"].GetDigest()) + } + if !images["ghcr"].GetIsIndex() { + t.Fatal("ghcr image should be marked as an index") + } + if images["ecrPublic"].GetUri() != "public.ecr.aws/conductorone/baton-example@sha256:ccc333" { + t.Fatalf("ecrPublic uri = %q", images["ecrPublic"].GetUri()) + } +} + +func TestExtractLambdaImageUsesGenericRef(t *testing.T) { + images := make(map[string]*pb.Image) + found := extractLambdaImage([]byte(` +ddd444 168442440833.dkr.ecr.us-west-2.amazonaws.com/baton-example:0.1.2-arm64 +`), "baton-example", "0.1.2", images) + + if !found { + t.Fatal("lambda image was not found") + } + image := images[lambdaArm64ImageKey] + if image == nil { + t.Fatalf("missing %s image", lambdaArm64ImageKey) + } + if image.GetRef() != "baton-example:0.1.2-arm64" { + t.Fatalf("lambda ref = %q", image.GetRef()) + } + if image.GetDigest() != "sha256:ddd444" { + t.Fatalf("lambda digest = %q", image.GetDigest()) + } + if image.GetIsIndex() { + t.Fatal("lambda image should not be marked as an index") + } +} + +func TestMarshalImagesSortsKeys(t *testing.T) { + isIndex := true + images := map[string]*pb.Image{ + "lambda-arm64": pb.Image_builder{ + Ref: strPtr("baton-example:0.1.2-arm64"), + Digest: strPtr("sha256:lambda"), + IsIndex: boolPtr(false), + }.Build(), + "ghcr": pb.Image_builder{ + Ref: strPtr("ghcr.io/conductorone/baton-example:0.1.2"), + Digest: strPtr("sha256:ghcr"), + IsIndex: &isIndex, + }.Build(), + } + + got, err := marshalImages(images) + if err != nil { + t.Fatalf("marshalImages: %v", err) + } + + var decoded map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &decoded); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, got) + } + if len(decoded) != 2 { + t.Fatalf("decoded keys = %d, want 2", len(decoded)) + } + if got[4:10] != `"ghcr"` { + t.Fatalf("first key was not ghcr in sorted output:\n%s", got) + } +} + +func strPtr(s string) *string { + return &s +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/cmd/merge-manifests/main.go b/cmd/merge-manifests/main.go index e711cce..95a4e64 100644 --- a/cmd/merge-manifests/main.go +++ b/cmd/merge-manifests/main.go @@ -80,6 +80,7 @@ func main() { unmarshalOpts := protojson.UnmarshalOptions{ DiscardUnknown: true, } + hasAttestedImage := false for key, imageJSON := range imagesMapJSON { image := &pb.Image{} if err := unmarshalOpts.Unmarshal(imageJSON, image); err != nil { @@ -87,16 +88,21 @@ func main() { os.Exit(1) } images[key] = image + if image.GetIsIndex() { + hasAttestedImage = true + } } - // Set manifest-level image attestation descriptor - // Images use OCI referrers for attestation discovery, so bundle_href is omitted - attestationType := AttestationTypeInTotoV1 - predicateType := PredicateTypeSLSAProvenanceV1 - manifest.SetImageAttestation(pb.AttestationDescriptor_builder{ - AttestationType: &attestationType, - PredicateType: &predicateType, - }.Build()) + if hasAttestedImage { + // Set manifest-level image attestation descriptor. + // OCI index images use referrers for attestation discovery, so bundle_href is omitted. + attestationType := AttestationTypeInTotoV1 + predicateType := PredicateTypeSLSAProvenanceV1 + manifest.SetImageAttestation(pb.AttestationDescriptor_builder{ + AttestationType: &attestationType, + PredicateType: &predicateType, + }.Build()) + } fmt.Fprintf(os.Stderr, "✅ Added %d images to manifest\n", len(images)) } else { diff --git a/cmd/record-release/main.go b/cmd/record-release/main.go index 6b24f63..44d6d54 100644 --- a/cmd/record-release/main.go +++ b/cmd/record-release/main.go @@ -327,7 +327,7 @@ func transformImages(manifest *pb.Manifest) map[string]*ReleaseImage { Ref: image.GetRef(), Digest: image.GetDigest(), Platform: platform, - Attestations: transformImageAttestations(manifest.GetImageAttestation()), + Attestations: transformImageAttestations(image, manifest.GetImageAttestation()), } } @@ -350,7 +350,10 @@ func transformAttestations(in []*pb.AttestationDescriptor) []*ReleaseAttestation return out } -func transformImageAttestations(att *pb.AttestationDescriptor) []*ReleaseAttestation { +func transformImageAttestations(image *pb.Image, att *pb.AttestationDescriptor) []*ReleaseAttestation { + if image == nil || !image.GetIsIndex() { + return nil + } if att == nil || att.GetPredicateType() == "" { return nil } diff --git a/cmd/record-release/main_test.go b/cmd/record-release/main_test.go index 623e711..a82d703 100644 --- a/cmd/record-release/main_test.go +++ b/cmd/record-release/main_test.go @@ -84,6 +84,25 @@ func TestTransformImagesAppliesManifestImageAttestation(t *testing.T) { } } +func TestTransformImagesSkipsAttestationForNonIndexImage(t *testing.T) { + isIndex := false + manifest := pb.Manifest_builder{ + ImageAttestation: attestation(slsaProvenance, ""), + Images: map[string]*pb.Image{ + "lambda-arm64": pb.Image_builder{ + Ref: strPtr("baton-example:1.2.3-arm64"), + Digest: strPtr("sha256:lambda"), + IsIndex: &isIndex, + }.Build(), + }, + }.Build() + + images := transformImages(manifest) + if len(images["lambda-arm64"].Attestations) != 0 { + t.Fatalf("lambda attestations = %#v, want none", images["lambda-arm64"].Attestations) + } +} + func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) { req := &RecordReleaseRequest{ Org: "example", diff --git a/docs/diagrams/release-workflow.dot b/docs/diagrams/release-workflow.dot index 8631dbb..30c3d07 100644 --- a/docs/diagrams/release-workflow.dot +++ b/docs/diagrams/release-workflow.dot @@ -23,9 +23,9 @@ digraph ReleaseWorkflow { docker [label="goreleaser-docker\n• multi-arch OCI images\n• Lambda image (arm64)\n• GHCR push\n• ECR Public push\n• image attestations", fillcolor="#ecfeff"]; - record [label="record-connector-registry\n• merge manifests\n• sign manifest\n• upload manifest\n• invoke Lambda (release event)", fillcolor="#ecfeff"]; + record [label="publish-release-manifest\n• merge manifests\n• sign manifest\n• upload manifest/checksums", fillcolor="#ecfeff"]; - record_lambda [label="record-lambda-registry\n• invoke Lambda (release event)", fillcolor="#ecfeff"]; + registry_api [label="record-registry-api\n• attach docs + metadata\n• record release via API", fillcolor="#ecfeff"]; verify [label="verify-release\n• validate artifacts\n• verify attestations", fillcolor="#f0fdf4"]; } @@ -34,6 +34,7 @@ digraph ReleaseWorkflow { s3 [label="S3\n• archives\n• attestations\n• manifest", fillcolor="#fef9c3"]; ghcr [label="GHCR\n• images\n• OCI attestations", fillcolor="#f9fafb"]; ecr [label="ECR Public\n• OCI images\n• Lambda image", fillcolor="#fef9c3"]; + registry [label="Connector Registry API\n• release metadata\n• assets + images", fillcolor="#dcfce7"]; // Flow connector_repo -> tag; @@ -45,11 +46,12 @@ digraph ReleaseWorkflow { binaries -> record; windows -> record; docker -> record; - docker -> record_lambda; binaries -> s3 [label="artifacts"]; windows -> s3 [label="artifacts"]; docker -> ghcr [label="push"]; docker -> ecr [label="push"]; record -> s3 [label="manifest"]; + record -> registry_api; + registry_api -> registry [label="record"]; record -> verify; } diff --git a/docs/diagrams/release-workflow.png b/docs/diagrams/release-workflow.png index 7401662..000961d 100644 Binary files a/docs/diagrams/release-workflow.png and b/docs/diagrams/release-workflow.png differ diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 19a1f14..1be0a33 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -14,7 +14,7 @@ When a tag is pushed to a connector repository, the shared release workflow: 4. Signs all artifacts with Sigstore (keyless) 5. Generates SLSA provenance attestations 6. Publishes to S3, GHCR, and ECR Public -7. Records the release in the connector registry +7. Records the release in the connector registry API ## Jobs @@ -72,15 +72,24 @@ Builds and publishes container images: **Outputs:** GHCR and ECR Public images with attached attestations -### record-connector-registry +### publish-release-manifest -Finalizes the release: +Finalizes distributable release artifacts: - Creates unified checksums file (all platforms) - Merges binary, Windows, and image manifests - Signs `manifest.json` and checksums with Sigstore - Uploads manifest and checksums to S3 -- Invokes release recording Lambda +- Exposes the final manifest to the registry API recording job + +### record-registry-api + +Records release metadata in the connector registry API: + +- Reuses the exact manifest uploaded to S3 +- Includes documentation and changelog data when present +- Includes `config_schema.json` and `baton_capabilities.json` when present +- Sends release timestamp, commit SHA, and workflow run metadata ### verify-release diff --git a/templates/.goreleaser-docker-lambda-template.yaml.tmpl b/templates/.goreleaser-docker-lambda-template.yaml.tmpl index 9376a81..f4383f3 100644 --- a/templates/.goreleaser-docker-lambda-template.yaml.tmpl +++ b/templates/.goreleaser-docker-lambda-template.yaml.tmpl @@ -33,6 +33,8 @@ dockers: - "--label=org.opencontainers.image.version={{.Version}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--label=org.opencontainers.image.description={{.ProjectName}}" +docker_digest: + name_template: "{{ .ProjectName }}_{{ .Version }}_digests.txt" checksum: disable: true release: