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
51 changes: 25 additions & 26 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,7 @@ jobs:
needs: [determine-workflows-ref, goreleaser-binaries, goreleaser-windows, goreleaser-docker]
outputs:
merged_manifest: ${{ steps.export-manifest.outputs.merged_manifest }}
manifest_url: ${{ steps.upload-manifest.outputs.manifest_url }}
permissions:
id-token: write
contents: read
Expand Down Expand Up @@ -1038,16 +1039,6 @@ jobs:
- name: Install cosign
uses: sigstore/cosign-installer@v3

- name: Sign manifest.json
working-directory: _workflows/_output
env: { COSIGN_EXPERIMENTAL: "1" }
shell: bash
run: |
set -euo pipefail
cosign sign-blob --yes "manifest.json" \
--output-signature "manifest.json.sig" \
--output-certificate "manifest.json.cert"

- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v5
with:
Expand Down Expand Up @@ -1142,15 +1133,16 @@ jobs:
'if .assets.checksums then .assets.checksums.sha256 = $sha | .assets.checksums.sizeBytes = $size else . end' \
manifest.json > manifest.tmp && mv manifest.tmp manifest.json

- name: Re-sign manifest.json
- name: Sign final manifest.json
working-directory: _workflows/_output
env: { COSIGN_EXPERIMENTAL: "1" }
shell: bash
run: |
set -euo pipefail
cosign sign-blob --yes "manifest.json" \
--output-signature "manifest.json.sig" \
--output-certificate "manifest.json.cert"
--output-certificate "manifest.json.cert" \
--bundle "manifest.json.sigstore.json"

- name: Export final manifest for registry API
id: export-manifest
Expand Down Expand Up @@ -1213,25 +1205,30 @@ jobs:
echo "::error::Failed to upload manifest.json to S3"
exit 1
fi
if [ -f "manifest.json.sig" ]; then
aws s3 cp "manifest.json.sig" "s3://$BUCKET/$DIRECTORY/manifest.json.sig" \
--cache-control "public,max-age=31536000,immutable" \
--content-type "application/octet-stream"
fi
if [ -f "manifest.json.cert" ]; then
aws s3 cp "manifest.json.cert" "s3://$BUCKET/$DIRECTORY/manifest.json.cert" \
--cache-control "public,max-age=31536000,immutable" \
--content-type "application/octet-stream"
fi
for required_file in manifest.json.sig manifest.json.cert manifest.json.sigstore.json; do
if [ ! -f "$required_file" ]; then
echo "::error::$required_file not found in $(pwd)"
exit 1
fi
done
aws s3 cp "manifest.json.sig" "s3://$BUCKET/$DIRECTORY/manifest.json.sig" \
--cache-control "public,max-age=31536000,immutable" \
--content-type "application/octet-stream"
aws s3 cp "manifest.json.cert" "s3://$BUCKET/$DIRECTORY/manifest.json.cert" \
--cache-control "public,max-age=31536000,immutable" \
--content-type "application/octet-stream"
aws s3 cp "manifest.json.sigstore.json" "s3://$BUCKET/$DIRECTORY/manifest.json.sigstore.json" \
--cache-control "public,max-age=31536000,immutable" \
--content-type "application/json"

# ================================================================
# Registry API: record release after release manifest publication.
# This is the sole release metadata recording path.
# ================================================================
record-registry-api:
# 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]
if: ${{ !cancelled() && needs.publish-release-manifest.result == 'success' && needs.verify-release.result == 'success' }}
needs: [determine-workflows-ref, publish-release-manifest, verify-release]
permissions:
id-token: write
contents: read
Expand Down Expand Up @@ -1352,6 +1349,7 @@ jobs:

go run ./cmd/record-release \
-manifest _output/manifest.json \
-manifest-url "${{ needs.publish-release-manifest.outputs.manifest_url }}" \
-org "${{ github.event.repository.owner.login }}" \
-name "${{ github.event.repository.name }}" \
-version "${{ inputs.tag }}" \
Expand All @@ -1366,8 +1364,9 @@ jobs:
$RELEASED_AT_FLAG

verify-release:
# Verify release artifacts and attestations after publishing
# This job is not blocking - failures trigger Datadog notification but don't fail the release
# Verify release artifacts and attestations before recording the release.
# Registry-side verification is the trust boundary; this job prevents
# obviously bad artifacts from being submitted.
needs: [determine-workflows-ref, publish-release-manifest]
if: always() && needs.publish-release-manifest.result == 'success'
permissions:
Expand Down
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ protofmt:
buf format -w proto
@echo "Protobuf formatting complete."

.PHONY: test
test:
go test ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests

.PHONY: workflow-validate
workflow-validate:
yq '.' .github/workflows/release.yaml >/dev/null

.PHONY: verify
verify: protogen test workflow-validate

.PHONY: docs
docs:
@echo "Generating documentation diagrams..."
Expand Down
18 changes: 10 additions & 8 deletions cmd/generate-manifest/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,18 @@ func main() {
baseURLTrimmed := strings.TrimSuffix(baseURL, "/")
signatureHref := fmt.Sprintf("%s/manifest.json.sig", baseURLTrimmed)
certificateHref := fmt.Sprintf("%s/manifest.json.cert", baseURLTrimmed)
signatureBundleHref := fmt.Sprintf("%s/manifest.json.sigstore.json", baseURLTrimmed)

manifest := pb.Manifest_builder{
Version: &version,
Name: &repoName,
Org: &orgName,
Semver: &tag,
ReleasedAt: timestamppb.New(now),
Assets: assets,
SignatureHref: &signatureHref,
CertificateHref: &certificateHref,
Version: &version,
Name: &repoName,
Org: &orgName,
Semver: &tag,
ReleasedAt: timestamppb.New(now),
Assets: assets,
SignatureHref: &signatureHref,
CertificateHref: &certificateHref,
SignatureBundleHref: &signatureBundleHref,
}.Build()

// Marshal to JSON
Expand Down
73 changes: 43 additions & 30 deletions cmd/record-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,23 @@ import (

// RecordReleaseRequest is the JSON body sent to the registry API.
type RecordReleaseRequest struct {
Org string `json:"org"`
Name string `json:"name"`
Version string `json:"version"`
RepositoryURL string `json:"repositoryUrl"`
CommitSha string `json:"commitSha"`
WorkflowRunID string `json:"workflowRunId"`
Documentation string `json:"documentation,omitempty"`
Changelog string `json:"changelog,omitempty"`
ConfigSchema string `json:"configSchema,omitempty"`
Capabilities string `json:"capabilities,omitempty"`
SignatureURL string `json:"signatureUrl,omitempty"`
CertificateURL string `json:"certificateUrl,omitempty"`
Assets map[string]*ReleaseAsset `json:"assets,omitempty"`
Images map[string]*ReleaseImage `json:"images,omitempty"`
ReleasedAt string `json:"releasedAt,omitempty"`
Org string `json:"org"`
Name string `json:"name"`
Version string `json:"version"`
RepositoryURL string `json:"repositoryUrl"`
CommitSha string `json:"commitSha"`
WorkflowRunID string `json:"workflowRunId"`
Documentation string `json:"documentation,omitempty"`
Changelog string `json:"changelog,omitempty"`
ConfigSchema string `json:"configSchema,omitempty"`
Capabilities string `json:"capabilities,omitempty"`
SignatureURL string `json:"signatureUrl,omitempty"`
CertificateURL string `json:"certificateUrl,omitempty"`
ManifestURL string `json:"manifestUrl,omitempty"`
SignatureBundleURL string `json:"signatureBundleUrl,omitempty"`
Assets map[string]*ReleaseAsset `json:"assets,omitempty"`
Images map[string]*ReleaseImage `json:"images,omitempty"`
ReleasedAt string `json:"releasedAt,omitempty"`
}

// ReleaseAsset is the transformed asset for the registry API.
Expand Down Expand Up @@ -92,6 +94,7 @@ func main() {
changelogPath string
configSchemaPath string
capabilitiesPath string
manifestURL string
token string
)

Expand All @@ -107,6 +110,7 @@ func main() {
flag.StringVar(&changelogPath, "changelog", "", "Path to a file containing release notes (optional)")
flag.StringVar(&configSchemaPath, "config-schema", "", "Path to config_schema.json file (optional)")
flag.StringVar(&capabilitiesPath, "capabilities", "", "Path to baton_capabilities.json file (optional)")
flag.StringVar(&manifestURL, "manifest-url", "", "Published manifest.json URL (required)")
var releasedAt string
flag.StringVar(&releasedAt, "released-at", "", "Release publish timestamp in RFC 3339 format (optional, defaults to server time)")
flag.StringVar(&token, "token", "", "Bearer token (or set REGISTRY_API_TOKEN env var)")
Expand Down Expand Up @@ -138,6 +142,9 @@ func main() {
if registryURL == "" {
missing = append(missing, "-registry-url")
}
if manifestURL == "" {
missing = append(missing, "-manifest-url")
}
if len(missing) > 0 {
fmt.Fprintf(os.Stderr, "record-release: error: missing required flags: %s\n", strings.Join(missing, ", "))
flag.Usage()
Expand Down Expand Up @@ -168,6 +175,10 @@ func main() {
fmt.Fprintf(os.Stderr, "record-release: error: parsing manifest: %v\n", err)
os.Exit(1)
}
if manifest.GetSignatureBundleHref() == "" {
fmt.Fprintf(os.Stderr, "record-release: error: manifest missing signatureBundleHref\n")
os.Exit(1)
}

// Read optional documentation
var documentation string
Expand Down Expand Up @@ -220,21 +231,23 @@ func main() {

// Build request body
req := &RecordReleaseRequest{
Org: org,
Name: name,
Version: version,
RepositoryURL: repositoryURL,
CommitSha: commitSha,
WorkflowRunID: workflowRunID,
Documentation: documentation,
Changelog: changelog,
ConfigSchema: configSchema,
Capabilities: capabilities,
SignatureURL: manifest.GetSignatureHref(),
CertificateURL: manifest.GetCertificateHref(),
Assets: assets,
Images: images,
ReleasedAt: releasedAt,
Org: org,
Name: name,
Version: version,
RepositoryURL: repositoryURL,
CommitSha: commitSha,
WorkflowRunID: workflowRunID,
Documentation: documentation,
Changelog: changelog,
ConfigSchema: configSchema,
Capabilities: capabilities,
SignatureURL: manifest.GetSignatureHref(),
CertificateURL: manifest.GetCertificateHref(),
ManifestURL: manifestURL,
SignatureBundleURL: manifest.GetSignatureBundleHref(),
Assets: assets,
Images: images,
ReleasedAt: releasedAt,
}

bodyBytes, err := json.Marshal(req)
Expand Down
15 changes: 11 additions & 4 deletions cmd/record-release/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ func TestTransformImagesSkipsAttestationForNonIndexImage(t *testing.T) {

func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
req := &RecordReleaseRequest{
Org: "example",
Name: "baton-example",
Version: "v1.2.3",
Org: "example",
Name: "baton-example",
Version: "v1.2.3",
ManifestURL: "https://dist.example.com/manifest.json",
SignatureBundleURL: "https://dist.example.com/manifest.json.sigstore.json",
Assets: map[string]*ReleaseAsset{
"linux-amd64": {
Platform: "linux-amd64",
Expand All @@ -125,7 +127,9 @@ func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
}

var got struct {
Assets map[string]struct {
ManifestURL string `json:"manifestUrl"`
SignatureBundleURL string `json:"signatureBundleUrl"`
Assets map[string]struct {
Attestations []ReleaseAttestation `json:"attestations"`
} `json:"assets"`
Images map[string]struct {
Expand All @@ -136,6 +140,9 @@ func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
t.Fatalf("unmarshal request: %v", err)
}

if got.ManifestURL == "" || got.SignatureBundleURL == "" {
t.Fatalf("manifest signature metadata was not marshaled: %#v", got)
}
if len(got.Assets["linux-amd64"].Attestations) != 1 {
t.Fatalf("asset attestations = %#v, want one entry", got.Assets["linux-amd64"].Attestations)
}
Expand Down
Loading