Skip to content

Commit b22c92e

Browse files
authored
Harden shared release artifact publishing (#91)
**Why** Managed release repos need the shared Go release workflow to publish immutable S3 artifacts and Public ECR version tags before registry recording. Review found that the Public ECR publish path also needed a same-tag race guard, and custom storage targets needed signature validation to follow the same path as `manifest.json`. **What this changes** Adds no-overwrite S3 uploads with same-digest idempotency, threads `release_storage_name` through upload and validation, serializes same repo/tag workflow runs, and publishes Public ECR version tags with digest preflight plus post-write digest assertion. Manifest signature validation now uses the resolved storage target and fails when signature evidence is missing. **Validation** `make test` **Merge order** Can merge before the first managed repo release. Registry recording still depends on the registry API PR for kind-aware catalog records.
1 parent c10a891 commit b22c92e

13 files changed

Lines changed: 1185 additions & 158 deletions

.github/workflows/release.yaml

Lines changed: 122 additions & 117 deletions
Large diffs are not rendered by default.

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ test: test-go test-scripts
1414

1515
.PHONY: test-go
1616
test-go:
17-
go test ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests
17+
go test ./cmd/extract-images ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests ./cmd/publish-public-ecr-release-tags
1818

1919
.PHONY: test-scripts
2020
test-scripts:
2121
bash scripts/test-derive-iam-role-name.sh
22+
bash scripts/test-s3-release-uploads.sh
2223

2324
.PHONY: workflow-validate
2425
workflow-validate:

README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,16 @@ jobs:
7070
7171
The release workflow accepts the following input parameters:
7272
73-
| Parameter | Required | Default | Description |
74-
| --------------------- | -------- | ------- | --------------------------------------------------------------------------- |
75-
| `tag` | Yes | - | The release tag (must be valid semver with `v` prefix, e.g., `v1.0.0`) |
76-
| `lambda` | No | `true` | Whether to release with Lambda image support |
77-
| `docker` | No | `true` | Whether to release with Docker image support |
78-
| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) |
79-
| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context |
80-
| `msi` | No | `true` | Whether to build MSI Windows installers |
81-
| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |
73+
| Parameter | Required | Default | Description |
74+
|-|-|-|-|
75+
| `tag` | Yes | - | The release tag (must be valid semver with `v` prefix, e.g., `v1.0.0`) |
76+
| `release_storage_name` | No | `""` | Optional S3 release path segment matching `^[a-z][a-z0-9-]{0,99}$`; defaults to the repository name |
77+
| `lambda` | No | `true` | Whether to release with Lambda image support |
78+
| `docker` | No | `true` | Whether to release with Docker image support |
79+
| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) |
80+
| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context |
81+
| `msi` | No | `true` | Whether to build MSI Windows installers |
82+
| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |
8283

8384
2. Ensure your repository has the following secrets configured:
8485

cmd/extract-images/main_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ ccc333 public.ecr.aws/conductorone/baton-example:0.1.2
2121
}
2222
}
2323

24+
func TestExtractPublicImagesIgnoresLatest(t *testing.T) {
25+
images := make(map[string]*pb.Image)
26+
foundECR := extractPublicImages([]byte(`
27+
aaa111 public.ecr.aws/conductorone/baton-example:latest
28+
bbb222 public.ecr.aws/conductorone/baton-example:0.1.2
29+
`), "0.1.2", images)
30+
31+
if !foundECR {
32+
t.Fatal("ECR public image was not found")
33+
}
34+
image := images["ecrPublic"]
35+
if image.GetDigest() != "sha256:bbb222" {
36+
t.Fatalf("ecrPublic digest = %q", image.GetDigest())
37+
}
38+
if image.GetTag() != "0.1.2" {
39+
t.Fatalf("ecrPublic tag = %q", image.GetTag())
40+
}
41+
}
42+
2443
func TestExtractLambdaImagePreservesECRRef(t *testing.T) {
2544
images := make(map[string]*pb.Image)
2645
found := extractLambdaImage([]byte(`
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"os"
10+
"os/exec"
11+
"regexp"
12+
"strings"
13+
)
14+
15+
const defaultRegistryURI = "public.ecr.aws/conductorone"
16+
17+
var sha256DigestPattern = regexp.MustCompile(`^(?:sha256:)?([A-Fa-f0-9]{64})$`)
18+
19+
type config struct {
20+
repositoryName string
21+
versionTag string
22+
candidateTag string
23+
digestFile string
24+
registryURI string
25+
}
26+
27+
type awsRunner interface {
28+
Run(args ...string) ([]byte, []byte, error)
29+
}
30+
31+
type execAWSRunner struct {
32+
binary string
33+
}
34+
35+
func (r execAWSRunner) Run(args ...string) ([]byte, []byte, error) {
36+
var stderr bytes.Buffer
37+
cmd := exec.Command(r.binary, args...)
38+
cmd.Stderr = &stderr
39+
stdout, err := cmd.Output()
40+
return stdout, stderr.Bytes(), err
41+
}
42+
43+
func main() {
44+
cfg := config{
45+
registryURI: defaultRegistryURI,
46+
}
47+
flag.StringVar(&cfg.repositoryName, "repository-name", "", "Public ECR repository name")
48+
flag.StringVar(&cfg.versionTag, "version-tag", "", "Immutable release version tag")
49+
flag.StringVar(&cfg.candidateTag, "candidate-tag", "", "Temporary candidate image tag pushed by GoReleaser")
50+
flag.StringVar(&cfg.digestFile, "digest-file", "", "Path to the GoReleaser docker_digest file")
51+
flag.StringVar(&cfg.registryURI, "registry-uri", defaultRegistryURI, "Public ECR registry URI")
52+
flag.Usage = func() {
53+
fmt.Fprintf(flag.CommandLine.Output(), "Usage: publish-public-ecr-release-tags -repository-name REPO -version-tag TAG -candidate-tag TAG -digest-file FILE [-registry-uri URI]\n\n")
54+
fmt.Fprintf(flag.CommandLine.Output(), "Promotes a pushed candidate Public ECR image to the immutable release version tag after checking any existing version tag digest. The latest tag is mutable convenience metadata and is not consulted by the preflight.\n\n")
55+
flag.PrintDefaults()
56+
}
57+
flag.Parse()
58+
59+
if err := cfg.validate(); err != nil {
60+
fmt.Fprintln(os.Stderr, err)
61+
flag.Usage()
62+
os.Exit(2)
63+
}
64+
65+
awsCLI := os.Getenv("AWS_CLI")
66+
if awsCLI == "" {
67+
awsCLI = "aws"
68+
}
69+
70+
if err := publish(cfg, execAWSRunner{binary: awsCLI}, os.Stdout, os.Stderr); err != nil {
71+
fmt.Fprintln(os.Stderr, err)
72+
os.Exit(1)
73+
}
74+
}
75+
76+
func (c config) validate() error {
77+
var missing []string
78+
if c.repositoryName == "" {
79+
missing = append(missing, "-repository-name")
80+
}
81+
if c.versionTag == "" {
82+
missing = append(missing, "-version-tag")
83+
}
84+
if c.candidateTag == "" {
85+
missing = append(missing, "-candidate-tag")
86+
}
87+
if c.digestFile == "" {
88+
missing = append(missing, "-digest-file")
89+
}
90+
if len(missing) > 0 {
91+
return fmt.Errorf("publish-public-ecr-release-tags: error: missing required flags: %s", strings.Join(missing, ", "))
92+
}
93+
if c.registryURI == "" {
94+
return fmt.Errorf("publish-public-ecr-release-tags: error: -registry-uri must not be empty")
95+
}
96+
return nil
97+
}
98+
99+
func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error {
100+
imageBase := strings.TrimRight(cfg.registryURI, "/") + "/" + cfg.repositoryName
101+
candidateRef := imageBase + ":" + cfg.candidateTag
102+
versionRef := imageBase + ":" + cfg.versionTag
103+
104+
candidateDigest, err := candidateDigestFromFile(cfg.digestFile, candidateRef)
105+
if err != nil {
106+
return err
107+
}
108+
109+
existingDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag)
110+
if err != nil {
111+
return err
112+
}
113+
versionTagExists := existingDigest != ""
114+
115+
if versionTagExists {
116+
if existingDigest != candidateDigest {
117+
return fmt.Errorf("::error::Public ECR tag %s:%s already points at %s, refusing to replace it with %s", cfg.repositoryName, cfg.versionTag, existingDigest, candidateDigest)
118+
}
119+
fmt.Fprintf(stdout, "Public ECR version tag already points at %s; keeping release idempotent\n", candidateDigest)
120+
} else {
121+
fmt.Fprintf(stdout, "Public ECR version tag %s:%s is available\n", cfg.repositoryName, cfg.versionTag)
122+
}
123+
124+
manifest, err := imageManifest(aws, cfg.repositoryName, candidateDigest)
125+
if err != nil {
126+
return err
127+
}
128+
129+
if !versionTagExists {
130+
if err := putImageTag(aws, cfg.repositoryName, manifest, cfg.versionTag); err != nil {
131+
return err
132+
}
133+
} else {
134+
fmt.Fprintf(stdout, "Skipped Public ECR version tag write because %s:%s already has %s\n", cfg.repositoryName, cfg.versionTag, candidateDigest)
135+
}
136+
137+
publishedDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag)
138+
if err != nil {
139+
return err
140+
}
141+
if publishedDigest == "" {
142+
return fmt.Errorf("::error::Public ECR tag %s:%s was not found after publication", cfg.repositoryName, cfg.versionTag)
143+
}
144+
if publishedDigest != candidateDigest {
145+
return fmt.Errorf("::error::Public ECR tag %s:%s points at %s after publication, expected %s", cfg.repositoryName, cfg.versionTag, publishedDigest, candidateDigest)
146+
}
147+
148+
if err := putImageTag(aws, cfg.repositoryName, manifest, "latest"); err != nil {
149+
return err
150+
}
151+
152+
if err := os.WriteFile(cfg.digestFile, []byte(fmt.Sprintf("%s %s\n", candidateDigest, versionRef)), 0o644); err != nil {
153+
return fmt.Errorf("publish-public-ecr-release-tags: error: rewriting digest file: %w", err)
154+
}
155+
156+
if err := deleteImageTag(aws, cfg.repositoryName, cfg.candidateTag); err != nil {
157+
fmt.Fprintf(stderr, "::warning::Could not remove temporary Public ECR candidate tag %s: %v\n", cfg.candidateTag, err)
158+
} else {
159+
fmt.Fprintf(stdout, "Removed temporary Public ECR candidate tag %s\n", cfg.candidateTag)
160+
}
161+
162+
fmt.Fprintf(stdout, "Published %s at %s\n", versionRef, candidateDigest)
163+
return nil
164+
}
165+
166+
func candidateDigestFromFile(path, candidateRef string) (string, error) {
167+
content, err := os.ReadFile(path)
168+
if err != nil {
169+
if os.IsNotExist(err) {
170+
return "", fmt.Errorf("Digest file not found: %s", path)
171+
}
172+
return "", fmt.Errorf("publish-public-ecr-release-tags: error: reading digest file %s: %w", path, err)
173+
}
174+
175+
for _, line := range strings.Split(string(content), "\n") {
176+
fields := strings.Fields(line)
177+
if len(fields) < 2 || fields[1] != candidateRef {
178+
continue
179+
}
180+
digest, err := normalizeDigest(fields[0])
181+
if err != nil {
182+
return "", fmt.Errorf("publish-public-ecr-release-tags: error: candidate digest for %s in %s is invalid: %w", candidateRef, path, err)
183+
}
184+
return digest, nil
185+
}
186+
187+
return "", fmt.Errorf("Could not find candidate ref %s in %s\n%s", candidateRef, path, content)
188+
}
189+
190+
func describeImageDigest(aws awsRunner, repositoryName, tag string) (string, error) {
191+
args := []string{
192+
"ecr-public", "describe-images",
193+
"--repository-name", repositoryName,
194+
"--image-ids", "imageTag=" + tag,
195+
"--output", "json",
196+
}
197+
stdout, stderr, err := aws.Run(args...)
198+
if err != nil {
199+
if isImageNotFound(stderr) {
200+
return "", nil
201+
}
202+
return "", awsError(args, stderr, err)
203+
}
204+
205+
var response struct {
206+
ImageDetails []struct {
207+
ImageDigest string `json:"imageDigest"`
208+
} `json:"imageDetails"`
209+
}
210+
if err := json.Unmarshal(stdout, &response); err != nil {
211+
return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing describe-images response for %s:%s: %w", repositoryName, tag, err)
212+
}
213+
if len(response.ImageDetails) == 0 || response.ImageDetails[0].ImageDigest == "" {
214+
return "", nil
215+
}
216+
217+
digest, err := normalizeDigest(response.ImageDetails[0].ImageDigest)
218+
if err != nil {
219+
return "", fmt.Errorf("publish-public-ecr-release-tags: error: describe-images returned invalid digest for %s:%s: %w", repositoryName, tag, err)
220+
}
221+
return digest, nil
222+
}
223+
224+
func imageManifest(aws awsRunner, repositoryName, digest string) (string, error) {
225+
args := []string{
226+
"ecr-public", "batch-get-image",
227+
"--repository-name", repositoryName,
228+
"--image-ids", "imageDigest=" + digest,
229+
"--accepted-media-types",
230+
"application/vnd.oci.image.index.v1+json",
231+
"application/vnd.docker.distribution.manifest.list.v2+json",
232+
"application/vnd.oci.image.manifest.v1+json",
233+
"application/vnd.docker.distribution.manifest.v2+json",
234+
"--output", "json",
235+
}
236+
stdout, stderr, err := aws.Run(args...)
237+
if err != nil {
238+
return "", awsError(args, stderr, err)
239+
}
240+
241+
var response struct {
242+
Images []struct {
243+
ImageManifest string `json:"imageManifest"`
244+
} `json:"images"`
245+
}
246+
if err := json.Unmarshal(stdout, &response); err != nil {
247+
return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing batch-get-image response for %s@%s: %w", repositoryName, digest, err)
248+
}
249+
if len(response.Images) == 0 || response.Images[0].ImageManifest == "" {
250+
return "", fmt.Errorf("Could not fetch manifest for %s@%s\n%s", repositoryName, digest, stdout)
251+
}
252+
return response.Images[0].ImageManifest, nil
253+
}
254+
255+
func putImageTag(aws awsRunner, repositoryName, manifest, tag string) error {
256+
args := []string{
257+
"ecr-public", "put-image",
258+
"--repository-name", repositoryName,
259+
"--image-manifest", manifest,
260+
"--image-tag", tag,
261+
}
262+
_, stderr, err := aws.Run(args...)
263+
if err != nil {
264+
return awsError(args, stderr, err)
265+
}
266+
return nil
267+
}
268+
269+
func deleteImageTag(aws awsRunner, repositoryName, tag string) error {
270+
args := []string{
271+
"ecr-public", "batch-delete-image",
272+
"--repository-name", repositoryName,
273+
"--image-ids", "imageTag=" + tag,
274+
}
275+
_, stderr, err := aws.Run(args...)
276+
if err != nil {
277+
return awsError(args, stderr, err)
278+
}
279+
return nil
280+
}
281+
282+
func normalizeDigest(raw string) (string, error) {
283+
raw = strings.TrimSpace(raw)
284+
matches := sha256DigestPattern.FindStringSubmatch(raw)
285+
if matches == nil {
286+
return "", fmt.Errorf("expected sha256 digest, got %q", raw)
287+
}
288+
return "sha256:" + strings.ToLower(matches[1]), nil
289+
}
290+
291+
func isImageNotFound(stderr []byte) bool {
292+
message := string(stderr)
293+
return strings.Contains(message, "ImageNotFound") ||
294+
strings.Contains(message, "ImageNotFoundException") ||
295+
strings.Contains(message, "RepositoryNotFound") ||
296+
strings.Contains(message, "RepositoryNotFoundException")
297+
}
298+
299+
func awsError(args []string, stderr []byte, err error) error {
300+
message := strings.TrimSpace(string(stderr))
301+
if message == "" {
302+
return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %w", strings.Join(args, " "), err)
303+
}
304+
return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %s: %w", strings.Join(args, " "), message, err)
305+
}

0 commit comments

Comments
 (0)