Skip to content

Commit 8971c8d

Browse files
committed
feat: add record-release Go command, replace inline bash
Move the manifest-to-API transformation logic from 86 lines of inline bash/jq into a type-safe Go command. Uses protojson to parse the merged manifest (same pattern as merge-manifests), transforms asset and image fields, reads optional docs/connector.mdx, and POSTs to the registry API. Handles 200 (success) and 409 (already exists) as non-error responses for dual-write migration compatibility.
1 parent 8f9d281 commit 8971c8d

2 files changed

Lines changed: 257 additions & 78 deletions

File tree

.github/workflows/release.yaml

Lines changed: 13 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,88 +1216,23 @@ jobs:
12161216
if: steps.registry-oidc.outcome == 'success'
12171217
continue-on-error: true
12181218
env:
1219-
REGISTRY_URL: ${{ vars.REGISTRY_API_URL }}
1220-
MANIFEST_FILE: _workflows/_output/manifest.json
1219+
REGISTRY_API_TOKEN: ${{ steps.registry-oidc.outputs.token }}
12211220
run: |
1222-
set -euo pipefail
1223-
1224-
# Read documentation content if available
1225-
DOCS=""
1221+
DOCS_FLAG=""
12261222
if [ "${{ steps.read-docs.outputs.has_docs }}" = "true" ]; then
1227-
DOCS=$(cat _connector/docs/connector.mdx)
1223+
DOCS_FLAG="-docs _connector/docs/connector.mdx"
12281224
fi
12291225
1230-
# Transform merged manifest assets: href->downloadUrl, signatureHref->signatureUrl, etc.
1231-
ASSETS=$(jq '(.assets // {}) | to_entries | map({
1232-
key: .key,
1233-
value: {
1234-
platform: .key,
1235-
filename: .value.filename,
1236-
mediaType: .value.mediaType,
1237-
sizeBytes: .value.sizeBytes,
1238-
sha256: .value.sha256,
1239-
downloadUrl: .value.href,
1240-
signatureUrl: .value.signatureHref,
1241-
certificateUrl: .value.certificateHref,
1242-
sbomUrl: .value.sbomHref
1243-
}
1244-
}) | from_entries' "$MANIFEST_FILE")
1245-
1246-
# Transform merged manifest images: map key becomes platform
1247-
IMAGES=$(jq '(.images // {}) | to_entries | map({
1248-
key: .key,
1249-
value: {
1250-
ref: .value.ref,
1251-
digest: .value.digest,
1252-
platform: .key
1253-
}
1254-
}) | from_entries' "$MANIFEST_FILE")
1255-
1256-
# Manifest-level signature URLs (from cosign sign-blob of manifest.json)
1257-
SIG_URL=$(jq -r '.signatureHref // ""' "$MANIFEST_FILE")
1258-
CERT_URL=$(jq -r '.certificateHref // ""' "$MANIFEST_FILE")
1259-
1260-
BODY=$(jq -n \
1261-
--arg org "${{ github.event.repository.owner.login }}" \
1262-
--arg name "${{ github.event.repository.name }}" \
1263-
--arg version "${{ inputs.tag }}" \
1264-
--arg repositoryUrl "https://github.com/${{ github.repository }}" \
1265-
--arg commitSha "${{ github.sha }}" \
1266-
--arg workflowRunId "${{ github.run_id }}" \
1267-
--arg documentation "$DOCS" \
1268-
--arg signatureUrl "$SIG_URL" \
1269-
--arg certificateUrl "$CERT_URL" \
1270-
--argjson assets "$ASSETS" \
1271-
--argjson images "$IMAGES" \
1272-
'{
1273-
org: $org,
1274-
name: $name,
1275-
version: $version,
1276-
repositoryUrl: $repositoryUrl,
1277-
commitSha: $commitSha,
1278-
workflowRunId: $workflowRunId,
1279-
documentation: $documentation,
1280-
signatureUrl: $signatureUrl,
1281-
certificateUrl: $certificateUrl,
1282-
assets: $assets,
1283-
images: $images
1284-
}')
1285-
1286-
HTTP_CODE=$(curl -s -o /tmp/registry-response.json -w "%{http_code}" \
1287-
-X POST "${REGISTRY_URL}/api/v1/ingest/release" \
1288-
-H "Authorization: Bearer ${{ steps.registry-oidc.outputs.token }}" \
1289-
-H "Content-Type: application/json" \
1290-
-d "$BODY")
1291-
1292-
if [ "$HTTP_CODE" = "200" ]; then
1293-
echo "Registry API record: success (HTTP 200)"
1294-
elif [ "$HTTP_CODE" = "409" ]; then
1295-
echo "Registry API record: already exists (HTTP 409) -- expected during dual-write migration"
1296-
else
1297-
echo "::error::Registry API record failed: HTTP $HTTP_CODE"
1298-
cat /tmp/registry-response.json
1299-
exit 1
1300-
fi
1226+
go run ./_workflows/cmd/record-release \
1227+
-manifest _workflows/_output/manifest.json \
1228+
-org "${{ github.event.repository.owner.login }}" \
1229+
-name "${{ github.event.repository.name }}" \
1230+
-version "${{ inputs.tag }}" \
1231+
-repository-url "https://github.com/${{ github.repository }}" \
1232+
-commit-sha "${{ github.sha }}" \
1233+
-workflow-run-id "${{ github.run_id }}" \
1234+
-registry-url "${{ vars.REGISTRY_API_URL }}" \
1235+
$DOCS_FLAG
13011236
13021237
record-lambda-registry:
13031238
if: inputs.lambda == true

cmd/record-release/main.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"strings"
12+
"time"
13+
14+
"google.golang.org/protobuf/encoding/protojson"
15+
16+
pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1"
17+
)
18+
19+
// RecordReleaseRequest is the JSON body sent to the registry API.
20+
type RecordReleaseRequest struct {
21+
Org string `json:"org"`
22+
Name string `json:"name"`
23+
Version string `json:"version"`
24+
RepositoryURL string `json:"repositoryUrl"`
25+
CommitSha string `json:"commitSha"`
26+
WorkflowRunID string `json:"workflowRunId"`
27+
Documentation string `json:"documentation,omitempty"`
28+
SignatureURL string `json:"signatureUrl,omitempty"`
29+
CertificateURL string `json:"certificateUrl,omitempty"`
30+
Assets map[string]*ReleaseAsset `json:"assets,omitempty"`
31+
Images map[string]*ReleaseImage `json:"images,omitempty"`
32+
}
33+
34+
// ReleaseAsset is the transformed asset for the registry API.
35+
type ReleaseAsset struct {
36+
Platform string `json:"platform"`
37+
Filename string `json:"filename"`
38+
MediaType string `json:"mediaType"`
39+
SizeBytes int64 `json:"sizeBytes"`
40+
Sha256 string `json:"sha256"`
41+
DownloadURL string `json:"downloadUrl"`
42+
SignatureURL string `json:"signatureUrl,omitempty"`
43+
CertificateURL string `json:"certificateUrl,omitempty"`
44+
SbomURL string `json:"sbomUrl,omitempty"`
45+
}
46+
47+
// ReleaseImage is the transformed image for the registry API.
48+
type ReleaseImage struct {
49+
Ref string `json:"ref"`
50+
Digest string `json:"digest"`
51+
Platform string `json:"platform"`
52+
}
53+
54+
func main() {
55+
var (
56+
manifestPath string
57+
docsPath string
58+
org string
59+
name string
60+
version string
61+
repositoryURL string
62+
commitSha string
63+
workflowRunID string
64+
registryURL string
65+
token string
66+
)
67+
68+
flag.StringVar(&manifestPath, "manifest", "", "Path to merged manifest.json file (required)")
69+
flag.StringVar(&docsPath, "docs", "", "Path to docs/connector.mdx file (optional)")
70+
flag.StringVar(&org, "org", "", "GitHub organization (required)")
71+
flag.StringVar(&name, "name", "", "Repository/connector name (required)")
72+
flag.StringVar(&version, "version", "", "Release version tag (required)")
73+
flag.StringVar(&repositoryURL, "repository-url", "", "Full repository URL (required)")
74+
flag.StringVar(&commitSha, "commit-sha", "", "Git commit SHA (required)")
75+
flag.StringVar(&workflowRunID, "workflow-run-id", "", "GitHub Actions workflow run ID (required)")
76+
flag.StringVar(&registryURL, "registry-url", "", "Registry API base URL (required)")
77+
flag.StringVar(&token, "token", "", "Bearer token (or set REGISTRY_API_TOKEN env var)")
78+
flag.Parse()
79+
80+
// Validate required flags
81+
var missing []string
82+
if manifestPath == "" {
83+
missing = append(missing, "-manifest")
84+
}
85+
if org == "" {
86+
missing = append(missing, "-org")
87+
}
88+
if name == "" {
89+
missing = append(missing, "-name")
90+
}
91+
if version == "" {
92+
missing = append(missing, "-version")
93+
}
94+
if repositoryURL == "" {
95+
missing = append(missing, "-repository-url")
96+
}
97+
if commitSha == "" {
98+
missing = append(missing, "-commit-sha")
99+
}
100+
if workflowRunID == "" {
101+
missing = append(missing, "-workflow-run-id")
102+
}
103+
if registryURL == "" {
104+
missing = append(missing, "-registry-url")
105+
}
106+
if len(missing) > 0 {
107+
fmt.Fprintf(os.Stderr, "record-release: error: missing required flags: %s\n", strings.Join(missing, ", "))
108+
flag.Usage()
109+
os.Exit(1)
110+
}
111+
112+
// Resolve token: flag > env var
113+
if token == "" {
114+
token = os.Getenv("REGISTRY_API_TOKEN")
115+
}
116+
if token == "" {
117+
fmt.Fprintf(os.Stderr, "record-release: error: bearer token required (use -token flag or REGISTRY_API_TOKEN env var)\n")
118+
os.Exit(1)
119+
}
120+
121+
// Read and parse manifest using protojson (same pattern as merge-manifests)
122+
manifestBytes, err := os.ReadFile(manifestPath)
123+
if err != nil {
124+
fmt.Fprintf(os.Stderr, "record-release: error: reading manifest: %v\n", err)
125+
os.Exit(1)
126+
}
127+
128+
manifest := &pb.Manifest{}
129+
unmarshalOpts := protojson.UnmarshalOptions{
130+
DiscardUnknown: true,
131+
}
132+
if err := unmarshalOpts.Unmarshal(manifestBytes, manifest); err != nil {
133+
fmt.Fprintf(os.Stderr, "record-release: error: parsing manifest: %v\n", err)
134+
os.Exit(1)
135+
}
136+
137+
// Read optional documentation
138+
var documentation string
139+
if docsPath != "" {
140+
docsBytes, err := os.ReadFile(docsPath)
141+
if err != nil {
142+
// Not fatal -- docs are optional
143+
fmt.Fprintf(os.Stderr, "record-release: warning: could not read docs file: %v\n", err)
144+
} else {
145+
documentation = string(docsBytes)
146+
}
147+
}
148+
149+
// Transform manifest assets: href->downloadUrl, signatureHref->signatureUrl, etc.
150+
assets := make(map[string]*ReleaseAsset)
151+
for platform, asset := range manifest.GetAssets() {
152+
assets[platform] = &ReleaseAsset{
153+
Platform: platform,
154+
Filename: asset.GetFilename(),
155+
MediaType: asset.GetMediaType(),
156+
SizeBytes: asset.GetSizeBytes(),
157+
Sha256: asset.GetSha256(),
158+
DownloadURL: asset.GetHref(),
159+
SignatureURL: asset.GetSignatureHref(),
160+
CertificateURL: asset.GetCertificateHref(),
161+
SbomURL: asset.GetSbomHref(),
162+
}
163+
}
164+
165+
// Transform manifest images: extract ref, digest, add platform from map key
166+
images := make(map[string]*ReleaseImage)
167+
for platform, image := range manifest.GetImages() {
168+
images[platform] = &ReleaseImage{
169+
Ref: image.GetRef(),
170+
Digest: image.GetDigest(),
171+
Platform: platform,
172+
}
173+
}
174+
175+
// Build request body
176+
req := &RecordReleaseRequest{
177+
Org: org,
178+
Name: name,
179+
Version: version,
180+
RepositoryURL: repositoryURL,
181+
CommitSha: commitSha,
182+
WorkflowRunID: workflowRunID,
183+
Documentation: documentation,
184+
SignatureURL: manifest.GetSignatureHref(),
185+
CertificateURL: manifest.GetCertificateHref(),
186+
Assets: assets,
187+
Images: images,
188+
}
189+
190+
bodyBytes, err := json.Marshal(req)
191+
if err != nil {
192+
fmt.Fprintf(os.Stderr, "record-release: error: marshaling request body: %v\n", err)
193+
os.Exit(1)
194+
}
195+
196+
// POST to registry API
197+
endpoint := strings.TrimRight(registryURL, "/") + "/api/v1/ingest/release"
198+
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(bodyBytes))
199+
if err != nil {
200+
fmt.Fprintf(os.Stderr, "record-release: error: creating HTTP request: %v\n", err)
201+
os.Exit(1)
202+
}
203+
httpReq.Header.Set("Authorization", "Bearer "+token)
204+
httpReq.Header.Set("Content-Type", "application/json")
205+
206+
client := &http.Client{
207+
Timeout: 30 * time.Second,
208+
}
209+
resp, err := client.Do(httpReq)
210+
if err != nil {
211+
fmt.Fprintf(os.Stderr, "record-release: error: HTTP request failed: %v\n", err)
212+
os.Exit(1)
213+
}
214+
defer resp.Body.Close()
215+
216+
respBody, err := io.ReadAll(resp.Body)
217+
if err != nil {
218+
fmt.Fprintf(os.Stderr, "record-release: error: reading response body: %v\n", err)
219+
os.Exit(1)
220+
}
221+
222+
// Handle response codes
223+
switch resp.StatusCode {
224+
case http.StatusOK:
225+
result, _ := json.Marshal(map[string]interface{}{
226+
"status": "success",
227+
"code": 200,
228+
"version": version,
229+
})
230+
fmt.Println(string(result))
231+
case http.StatusConflict:
232+
// 409 = already exists, not an error (expected during dual-write migration)
233+
result, _ := json.Marshal(map[string]interface{}{
234+
"status": "already_exists",
235+
"code": 409,
236+
"version": version,
237+
})
238+
fmt.Println(string(result))
239+
default:
240+
fmt.Fprintf(os.Stderr, "::error::Registry API record failed: HTTP %d\n", resp.StatusCode)
241+
fmt.Fprintf(os.Stderr, "%s\n", string(respBody))
242+
os.Exit(1)
243+
}
244+
}

0 commit comments

Comments
 (0)