Skip to content

Commit fd88b3f

Browse files
authored
Send signed manifest metadata to registry (#84)
## Why The registry now needs the published manifest URL and Sigstore bundle URL to verify release recordings before accepting them. ## What this changes Adds signatureBundleHref to release manifests, signs the final manifest with a Sigstore bundle, uploads the bundle with the manifest, passes the manifest URL to record-release, and blocks registry recording until release artifact verification succeeds. Adds a make verify target covering proto generation, focused Go tests, and workflow YAML parsing. ## Notes Merge along with the registry verification PR.
1 parent d2a4189 commit fd88b3f

8 files changed

Lines changed: 182 additions & 101 deletions

File tree

.github/workflows/release.yaml

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,7 @@ jobs:
10011001
needs: [determine-workflows-ref, goreleaser-binaries, goreleaser-windows, goreleaser-docker]
10021002
outputs:
10031003
merged_manifest: ${{ steps.export-manifest.outputs.merged_manifest }}
1004+
manifest_url: ${{ steps.upload-manifest.outputs.manifest_url }}
10041005
permissions:
10051006
id-token: write
10061007
contents: read
@@ -1038,16 +1039,6 @@ jobs:
10381039
- name: Install cosign
10391040
uses: sigstore/cosign-installer@v3
10401041

1041-
- name: Sign manifest.json
1042-
working-directory: _workflows/_output
1043-
env: { COSIGN_EXPERIMENTAL: "1" }
1044-
shell: bash
1045-
run: |
1046-
set -euo pipefail
1047-
cosign sign-blob --yes "manifest.json" \
1048-
--output-signature "manifest.json.sig" \
1049-
--output-certificate "manifest.json.cert"
1050-
10511042
- name: Configure AWS credentials via OIDC
10521043
uses: aws-actions/configure-aws-credentials@v5
10531044
with:
@@ -1142,15 +1133,16 @@ jobs:
11421133
'if .assets.checksums then .assets.checksums.sha256 = $sha | .assets.checksums.sizeBytes = $size else . end' \
11431134
manifest.json > manifest.tmp && mv manifest.tmp manifest.json
11441135
1145-
- name: Re-sign manifest.json
1136+
- name: Sign final manifest.json
11461137
working-directory: _workflows/_output
11471138
env: { COSIGN_EXPERIMENTAL: "1" }
11481139
shell: bash
11491140
run: |
11501141
set -euo pipefail
11511142
cosign sign-blob --yes "manifest.json" \
11521143
--output-signature "manifest.json.sig" \
1153-
--output-certificate "manifest.json.cert"
1144+
--output-certificate "manifest.json.cert" \
1145+
--bundle "manifest.json.sigstore.json"
11541146
11551147
- name: Export final manifest for registry API
11561148
id: export-manifest
@@ -1213,25 +1205,30 @@ jobs:
12131205
echo "::error::Failed to upload manifest.json to S3"
12141206
exit 1
12151207
fi
1216-
if [ -f "manifest.json.sig" ]; then
1217-
aws s3 cp "manifest.json.sig" "s3://$BUCKET/$DIRECTORY/manifest.json.sig" \
1218-
--cache-control "public,max-age=31536000,immutable" \
1219-
--content-type "application/octet-stream"
1220-
fi
1221-
if [ -f "manifest.json.cert" ]; then
1222-
aws s3 cp "manifest.json.cert" "s3://$BUCKET/$DIRECTORY/manifest.json.cert" \
1223-
--cache-control "public,max-age=31536000,immutable" \
1224-
--content-type "application/octet-stream"
1225-
fi
1208+
for required_file in manifest.json.sig manifest.json.cert manifest.json.sigstore.json; do
1209+
if [ ! -f "$required_file" ]; then
1210+
echo "::error::$required_file not found in $(pwd)"
1211+
exit 1
1212+
fi
1213+
done
1214+
aws s3 cp "manifest.json.sig" "s3://$BUCKET/$DIRECTORY/manifest.json.sig" \
1215+
--cache-control "public,max-age=31536000,immutable" \
1216+
--content-type "application/octet-stream"
1217+
aws s3 cp "manifest.json.cert" "s3://$BUCKET/$DIRECTORY/manifest.json.cert" \
1218+
--cache-control "public,max-age=31536000,immutable" \
1219+
--content-type "application/octet-stream"
1220+
aws s3 cp "manifest.json.sigstore.json" "s3://$BUCKET/$DIRECTORY/manifest.json.sigstore.json" \
1221+
--cache-control "public,max-age=31536000,immutable" \
1222+
--content-type "application/json"
12261223
12271224
# ================================================================
12281225
# Registry API: record release after release manifest publication.
12291226
# This is the sole release metadata recording path.
12301227
# ================================================================
12311228
record-registry-api:
12321229
# Use !cancelled() so the explicit needs.result check controls skipped-job behavior.
1233-
if: ${{ !cancelled() && needs.publish-release-manifest.result == 'success' }}
1234-
needs: [determine-workflows-ref, publish-release-manifest]
1230+
if: ${{ !cancelled() && needs.publish-release-manifest.result == 'success' && needs.verify-release.result == 'success' }}
1231+
needs: [determine-workflows-ref, publish-release-manifest, verify-release]
12351232
permissions:
12361233
id-token: write
12371234
contents: read
@@ -1352,6 +1349,7 @@ jobs:
13521349
13531350
go run ./cmd/record-release \
13541351
-manifest _output/manifest.json \
1352+
-manifest-url "${{ needs.publish-release-manifest.outputs.manifest_url }}" \
13551353
-org "${{ github.event.repository.owner.login }}" \
13561354
-name "${{ github.event.repository.name }}" \
13571355
-version "${{ inputs.tag }}" \
@@ -1366,8 +1364,9 @@ jobs:
13661364
$RELEASED_AT_FLAG
13671365
13681366
verify-release:
1369-
# Verify release artifacts and attestations after publishing
1370-
# This job is not blocking - failures trigger Datadog notification but don't fail the release
1367+
# Verify release artifacts and attestations before recording the release.
1368+
# Registry-side verification is the trust boundary; this job prevents
1369+
# obviously bad artifacts from being submitted.
13711370
needs: [determine-workflows-ref, publish-release-manifest]
13721371
if: always() && needs.publish-release-manifest.result == 'success'
13731372
permissions:

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ protofmt:
99
buf format -w proto
1010
@echo "Protobuf formatting complete."
1111

12+
.PHONY: test
13+
test:
14+
go test ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests
15+
16+
.PHONY: workflow-validate
17+
workflow-validate:
18+
yq '.' .github/workflows/release.yaml >/dev/null
19+
20+
.PHONY: verify
21+
verify: protogen test workflow-validate
22+
1223
.PHONY: docs
1324
docs:
1425
@echo "Generating documentation diagrams..."

cmd/generate-manifest/main.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,18 @@ func main() {
147147
baseURLTrimmed := strings.TrimSuffix(baseURL, "/")
148148
signatureHref := fmt.Sprintf("%s/manifest.json.sig", baseURLTrimmed)
149149
certificateHref := fmt.Sprintf("%s/manifest.json.cert", baseURLTrimmed)
150+
signatureBundleHref := fmt.Sprintf("%s/manifest.json.sigstore.json", baseURLTrimmed)
150151

151152
manifest := pb.Manifest_builder{
152-
Version: &version,
153-
Name: &repoName,
154-
Org: &orgName,
155-
Semver: &tag,
156-
ReleasedAt: timestamppb.New(now),
157-
Assets: assets,
158-
SignatureHref: &signatureHref,
159-
CertificateHref: &certificateHref,
153+
Version: &version,
154+
Name: &repoName,
155+
Org: &orgName,
156+
Semver: &tag,
157+
ReleasedAt: timestamppb.New(now),
158+
Assets: assets,
159+
SignatureHref: &signatureHref,
160+
CertificateHref: &certificateHref,
161+
SignatureBundleHref: &signatureBundleHref,
160162
}.Build()
161163

162164
// Marshal to JSON

cmd/record-release/main.go

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ import (
1919

2020
// RecordReleaseRequest is the JSON body sent to the registry API.
2121
type RecordReleaseRequest struct {
22-
Org string `json:"org"`
23-
Name string `json:"name"`
24-
Version string `json:"version"`
25-
RepositoryURL string `json:"repositoryUrl"`
26-
CommitSha string `json:"commitSha"`
27-
WorkflowRunID string `json:"workflowRunId"`
28-
Documentation string `json:"documentation,omitempty"`
29-
Changelog string `json:"changelog,omitempty"`
30-
ConfigSchema string `json:"configSchema,omitempty"`
31-
Capabilities string `json:"capabilities,omitempty"`
32-
SignatureURL string `json:"signatureUrl,omitempty"`
33-
CertificateURL string `json:"certificateUrl,omitempty"`
34-
Assets map[string]*ReleaseAsset `json:"assets,omitempty"`
35-
Images map[string]*ReleaseImage `json:"images,omitempty"`
36-
ReleasedAt string `json:"releasedAt,omitempty"`
22+
Org string `json:"org"`
23+
Name string `json:"name"`
24+
Version string `json:"version"`
25+
RepositoryURL string `json:"repositoryUrl"`
26+
CommitSha string `json:"commitSha"`
27+
WorkflowRunID string `json:"workflowRunId"`
28+
Documentation string `json:"documentation,omitempty"`
29+
Changelog string `json:"changelog,omitempty"`
30+
ConfigSchema string `json:"configSchema,omitempty"`
31+
Capabilities string `json:"capabilities,omitempty"`
32+
SignatureURL string `json:"signatureUrl,omitempty"`
33+
CertificateURL string `json:"certificateUrl,omitempty"`
34+
ManifestURL string `json:"manifestUrl,omitempty"`
35+
SignatureBundleURL string `json:"signatureBundleUrl,omitempty"`
36+
Assets map[string]*ReleaseAsset `json:"assets,omitempty"`
37+
Images map[string]*ReleaseImage `json:"images,omitempty"`
38+
ReleasedAt string `json:"releasedAt,omitempty"`
3739
}
3840

3941
// ReleaseAsset is the transformed asset for the registry API.
@@ -92,6 +94,7 @@ func main() {
9294
changelogPath string
9395
configSchemaPath string
9496
capabilitiesPath string
97+
manifestURL string
9598
token string
9699
)
97100

@@ -107,6 +110,7 @@ func main() {
107110
flag.StringVar(&changelogPath, "changelog", "", "Path to a file containing release notes (optional)")
108111
flag.StringVar(&configSchemaPath, "config-schema", "", "Path to config_schema.json file (optional)")
109112
flag.StringVar(&capabilitiesPath, "capabilities", "", "Path to baton_capabilities.json file (optional)")
113+
flag.StringVar(&manifestURL, "manifest-url", "", "Published manifest.json URL (required)")
110114
var releasedAt string
111115
flag.StringVar(&releasedAt, "released-at", "", "Release publish timestamp in RFC 3339 format (optional, defaults to server time)")
112116
flag.StringVar(&token, "token", "", "Bearer token (or set REGISTRY_API_TOKEN env var)")
@@ -138,6 +142,9 @@ func main() {
138142
if registryURL == "" {
139143
missing = append(missing, "-registry-url")
140144
}
145+
if manifestURL == "" {
146+
missing = append(missing, "-manifest-url")
147+
}
141148
if len(missing) > 0 {
142149
fmt.Fprintf(os.Stderr, "record-release: error: missing required flags: %s\n", strings.Join(missing, ", "))
143150
flag.Usage()
@@ -168,6 +175,10 @@ func main() {
168175
fmt.Fprintf(os.Stderr, "record-release: error: parsing manifest: %v\n", err)
169176
os.Exit(1)
170177
}
178+
if manifest.GetSignatureBundleHref() == "" {
179+
fmt.Fprintf(os.Stderr, "record-release: error: manifest missing signatureBundleHref\n")
180+
os.Exit(1)
181+
}
171182

172183
// Read optional documentation
173184
var documentation string
@@ -220,21 +231,23 @@ func main() {
220231

221232
// Build request body
222233
req := &RecordReleaseRequest{
223-
Org: org,
224-
Name: name,
225-
Version: version,
226-
RepositoryURL: repositoryURL,
227-
CommitSha: commitSha,
228-
WorkflowRunID: workflowRunID,
229-
Documentation: documentation,
230-
Changelog: changelog,
231-
ConfigSchema: configSchema,
232-
Capabilities: capabilities,
233-
SignatureURL: manifest.GetSignatureHref(),
234-
CertificateURL: manifest.GetCertificateHref(),
235-
Assets: assets,
236-
Images: images,
237-
ReleasedAt: releasedAt,
234+
Org: org,
235+
Name: name,
236+
Version: version,
237+
RepositoryURL: repositoryURL,
238+
CommitSha: commitSha,
239+
WorkflowRunID: workflowRunID,
240+
Documentation: documentation,
241+
Changelog: changelog,
242+
ConfigSchema: configSchema,
243+
Capabilities: capabilities,
244+
SignatureURL: manifest.GetSignatureHref(),
245+
CertificateURL: manifest.GetCertificateHref(),
246+
ManifestURL: manifestURL,
247+
SignatureBundleURL: manifest.GetSignatureBundleHref(),
248+
Assets: assets,
249+
Images: images,
250+
ReleasedAt: releasedAt,
238251
}
239252

240253
bodyBytes, err := json.Marshal(req)

cmd/record-release/main_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ func TestTransformImagesSkipsAttestationForNonIndexImage(t *testing.T) {
100100

101101
func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
102102
req := &RecordReleaseRequest{
103-
Org: "example",
104-
Name: "baton-example",
105-
Version: "v1.2.3",
103+
Org: "example",
104+
Name: "baton-example",
105+
Version: "v1.2.3",
106+
ManifestURL: "https://dist.example.com/manifest.json",
107+
SignatureBundleURL: "https://dist.example.com/manifest.json.sigstore.json",
106108
Assets: map[string]*ReleaseAsset{
107109
"linux-amd64": {
108110
Platform: "linux-amd64",
@@ -125,7 +127,9 @@ func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
125127
}
126128

127129
var got struct {
128-
Assets map[string]struct {
130+
ManifestURL string `json:"manifestUrl"`
131+
SignatureBundleURL string `json:"signatureBundleUrl"`
132+
Assets map[string]struct {
129133
Attestations []ReleaseAttestation `json:"attestations"`
130134
} `json:"assets"`
131135
Images map[string]struct {
@@ -136,6 +140,9 @@ func TestRecordReleaseRequestMarshalsAttestations(t *testing.T) {
136140
t.Fatalf("unmarshal request: %v", err)
137141
}
138142

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

0 commit comments

Comments
 (0)