diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ff3f272..97bca96 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,6 +17,11 @@ on: tag: required: true type: string + release_storage_name: + required: false + type: string + default: "" + description: "Optional release storage path segment. Defaults to the repository name." lambda: required: false type: boolean @@ -71,6 +76,10 @@ env: permissions: {} +concurrency: + group: release-${{ github.repository }}-${{ inputs.tag }} + cancel-in-progress: false + jobs: validate-inputs: runs-on: ubuntu-latest @@ -94,6 +103,16 @@ jobs: echo "::error::dockerfile_template can only be used when lambda is false" exit 1 + - name: Validate release_storage_name has safe path segment + if: inputs.release_storage_name != '' + env: + RELEASE_STORAGE_NAME: ${{ inputs.release_storage_name }} + run: | + if [[ ! "$RELEASE_STORAGE_NAME" =~ ^[a-z][a-z0-9-]{0,99}$ ]]; then + echo "::error::release_storage_name must match ^[a-z][a-z0-9-]{0,99}$ to align with registry release target validation. Got: $RELEASE_STORAGE_NAME" + exit 1 + fi + - name: Validate dockerfile_template has safe path if: inputs.dockerfile_template != '' env: @@ -229,16 +248,17 @@ jobs: run: | ORG="${{ github.event.repository.owner.login }}" REPO="${{ github.event.repository.name }}" + STORAGE_NAME="${{ inputs.release_storage_name }}" TAG="${{ inputs.tag }}" - echo "S3_DIRECTORY=releases/$ORG/$REPO/$TAG" >> "$GITHUB_OUTPUT" + if [ -z "$STORAGE_NAME" ]; then + STORAGE_NAME="$REPO" + fi + echo "S3_DIRECTORY=releases/$ORG/$STORAGE_NAME/$TAG" >> "$GITHUB_OUTPUT" - name: Generate configs for binaries working-directory: _workflows env: REPO_NAME: ${{ github.event.repository.name }} - S3_BUCKET: ${{ env.S3_BUCKET }} - S3_REGION: "us-west-2" - S3_DIRECTORY: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} # For provenance predicate template WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }} RELEASE_TAG: ${{ inputs.tag }} @@ -363,34 +383,18 @@ jobs: echo "Generated SBOM bundles: ${SIGNED_COUNT}" ls "${CALLER_DIST}"/*.sbom.sigstore.json 2>/dev/null || echo "ℹ️ No SBOM bundles generated (GoReleaser may not have generated SBOMs)" - - name: Upload attestation bundles to S3 + - name: Upload binary release artifacts to S3 working-directory: _workflows env: - CALLER_DIST: ../_caller/dist + S3_BUCKET: ${{ env.S3_BUCKET }} + S3_DIRECTORY: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} shell: bash run: | set -euo pipefail - BUCKET="${{ env.S3_BUCKET }}" - DIRECTORY="${{ steps.s3-directory.outputs.S3_DIRECTORY }}" - - UPLOAD_COUNT=0 - - # Upload all sigstore bundles (provenance and SBOM) - for bundle in "${CALLER_DIST}"/*.sigstore.json; do - [ -f "$bundle" ] || continue - BASENAME=$(basename "$bundle") - echo "Uploading $BASENAME to S3..." - aws s3 cp "$bundle" "s3://$BUCKET/$DIRECTORY/$BASENAME" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "application/json" - ((UPLOAD_COUNT++)) - done - - if [ "$UPLOAD_COUNT" -eq 0 ]; then - echo "::error::No attestation bundles found to upload" - exit 1 - fi - echo "✅ Uploaded ${UPLOAD_COUNT} attestation bundles to S3" + ./scripts/upload-release-artifacts.sh \ + --bucket "$S3_BUCKET" \ + --directory "$S3_DIRECTORY" \ + --base-dir "../_caller/dist" - name: Set up Go for workflows uses: actions/setup-go@v6 @@ -672,72 +676,30 @@ jobs: - name: Calculate S3 directory id: s3-directory - shell: pwsh + shell: bash run: | - $org = "${{ github.event.repository.owner.login }}" - $repo = "${{ github.event.repository.name }}" - $tag = "${{ inputs.tag }}" - "S3_DIRECTORY=releases/$org/$repo/$tag" >> $env:GITHUB_OUTPUT + ORG="${{ github.event.repository.owner.login }}" + REPO="${{ github.event.repository.name }}" + STORAGE_NAME="${{ inputs.release_storage_name }}" + TAG="${{ inputs.tag }}" + if [ -z "$STORAGE_NAME" ]; then + STORAGE_NAME="$REPO" + fi + echo "S3_DIRECTORY=releases/$ORG/$STORAGE_NAME/$TAG" >> "$GITHUB_OUTPUT" - name: Upload Windows artifacts to S3 - shell: pwsh + working-directory: _workflows + shell: bash env: S3_BUCKET: ${{ env.S3_BUCKET }} S3_DIRECTORY: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} run: | - # Upload zip files - $zipFiles = Get-ChildItem "_caller/dist/*.zip" -ErrorAction SilentlyContinue - foreach ($zip in $zipFiles) { - Write-Host "Uploading $($zip.Name) to S3..." - aws s3 cp $zip.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($zip.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/zip" - } - - # Upload MSI files (flattened to dist root by earlier step) - $msiFiles = Get-ChildItem "_caller/dist/*.msi" -ErrorAction SilentlyContinue - foreach ($msi in $msiFiles) { - Write-Host "Uploading $($msi.Name) to S3..." - aws s3 cp $msi.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($msi.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/x-msi" - } - - # Upload signatures (.sig files) - $sigFiles = Get-ChildItem "_caller/dist/*.sig" -ErrorAction SilentlyContinue - foreach ($sig in $sigFiles) { - Write-Host "Uploading $($sig.Name) to S3..." - aws s3 cp $sig.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($sig.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/octet-stream" - } - - # Upload certificates (.cert files) - $certFiles = Get-ChildItem "_caller/dist/*.cert" -ErrorAction SilentlyContinue - foreach ($cert in $certFiles) { - Write-Host "Uploading $($cert.Name) to S3..." - aws s3 cp $cert.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($cert.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/x-pem-file" - } - - # Upload SBOM json files (before attestation signing) - $sbomFiles = Get-ChildItem "_caller/dist/*.sbom.json" -ErrorAction SilentlyContinue - foreach ($sbom in $sbomFiles) { - Write-Host "Uploading $($sbom.Name) to S3..." - aws s3 cp $sbom.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($sbom.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/json" - } - - # Upload attestation bundles (provenance and SBOM sigstore bundles) - $bundles = Get-ChildItem "_caller/dist/*.sigstore.json" -ErrorAction SilentlyContinue - foreach ($bundle in $bundles) { - Write-Host "Uploading $($bundle.Name) to S3..." - aws s3 cp $bundle.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($bundle.Name)" ` - --cache-control "public,max-age=31536000,immutable" ` - --content-type "application/json" - } + set -euo pipefail + ./scripts/upload-release-artifacts.sh \ + --bucket "$S3_BUCKET" \ + --directory "$S3_DIRECTORY" \ + --base-dir "../_caller/dist" \ + --include-sbom-documents - name: Set up Go for workflows tools uses: actions/setup-go@v6 @@ -845,6 +807,7 @@ jobs: REPO_NAME: ${{ github.event.repository.name }} DOCKERFILE_PATH: ../_workflows/_generated/Dockerfile DIST_DIR: dist/oci + PUBLIC_ECR_PUBLISH_TAG: release-candidate-${{ github.run_id }}-${{ github.run_attempt }} # For provenance predicate template WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }} RELEASE_TAG: ${{ inputs.tag }} @@ -941,6 +904,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} COSIGN_EXPERIMENTAL: "1" + - name: Set up Go for Public ECR helper + if: inputs.docker == true + uses: actions/setup-go@v6 + with: + go-version-file: "_workflows/go.mod" + cache: false + + - name: Preflight and publish Public ECR release tags + if: inputs.docker == true + working-directory: _workflows + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + RELEASE_TAG: ${{ inputs.tag }} + CANDIDATE_TAG: release-candidate-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + VERSION="${RELEASE_TAG#v}" + go run ./cmd/publish-public-ecr-release-tags \ + -repository-name "$REPO_NAME" \ + -version-tag "$VERSION" \ + -candidate-tag "$CANDIDATE_TAG" \ + -digest-file "../_caller/dist/oci/${REPO_NAME}_${VERSION}_digests.txt" + - name: Configure Lambda ECR AWS credentials via OIDC if: inputs.lambda == true uses: aws-actions/configure-aws-credentials@v5 @@ -952,6 +939,13 @@ jobs: if: inputs.lambda == true uses: aws-actions/amazon-ecr-login@v2 + - name: Set up Go for caller Lambda release + if: inputs.docker == true && inputs.lambda == true + uses: actions/setup-go@v6 + with: + go-version-file: "_caller/go.mod" + cache: false + - name: Run GoReleaser for Lambda if: inputs.lambda == true uses: goreleaser/goreleaser-action@v6 @@ -1017,7 +1011,7 @@ jobs: # Attest each multi-arch index image (tagged with just version, not version-arch) while IFS= read -r line || [ -n "$line" ]; do [ -z "$line" ] && continue - DIGEST_HEX=$(echo "$line" | awk '{print $1}') + DIGEST=$(echo "$line" | awk '{print $1}') REF=$(echo "$line" | awk '{print $2}') # Only attest multi-arch index images (tagged with just version) @@ -1026,8 +1020,11 @@ jobs: fi # Build the digest-pinned reference + if [[ "$DIGEST" != sha256:* ]]; then + DIGEST="sha256:${DIGEST}" + fi IMAGE_BASE="${REF%:*}" - URI="${IMAGE_BASE}@sha256:${DIGEST_HEX}" + URI="${IMAGE_BASE}@${DIGEST}" echo "Attesting image: $URI" cosign attest \ @@ -1225,18 +1222,20 @@ jobs: VERSION_NO_V="${VERSION#v}" CHECKSUMS_FILE="${REPO_NAME}_${VERSION_NO_V}_checksums.txt" - aws s3 cp "./${CHECKSUMS_FILE}" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "text/plain" - aws s3 cp "./${CHECKSUMS_FILE}.sig" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.sig" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "application/octet-stream" - aws s3 cp "./${CHECKSUMS_FILE}.cert" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.cert" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "application/x-pem-file" - aws s3 cp "./${CHECKSUMS_FILE}.provenance.sigstore.json" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.provenance.sigstore.json" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "application/json" + upload_checksum_file() { + local file="$1" + local content_type="$2" + ../scripts/s3-put-object-if-none-match.sh \ + --bucket "$BUCKET" \ + --key "${DIRECTORY}/${file}" \ + --body "./${file}" \ + --content-type "$content_type" + } + + upload_checksum_file "${CHECKSUMS_FILE}" "text/plain" + upload_checksum_file "${CHECKSUMS_FILE}.sig" "application/octet-stream" + upload_checksum_file "${CHECKSUMS_FILE}.cert" "application/x-pem-file" + upload_checksum_file "${CHECKSUMS_FILE}.provenance.sigstore.json" "application/json" echo "✅ Checksums file and signatures uploaded" - name: Upload manifest.json to S3 @@ -1254,30 +1253,35 @@ jobs: echo "::error::manifest.json not found in $(pwd)" exit 1 fi - if aws s3 cp "manifest.json" "s3://$BUCKET/$DIRECTORY/manifest.json" \ - --cache-control "public,max-age=31536000,immutable" \ - --content-type "application/json"; then - MANIFEST_URL="$CDN_BASE/$DIRECTORY/manifest.json" - echo "manifest_url=$MANIFEST_URL" >> "$GITHUB_OUTPUT" - echo "✅ Manifest uploaded successfully: $MANIFEST_URL" - else - echo "::error::Failed to upload manifest.json to S3" - exit 1 - fi + ../scripts/s3-put-object-if-none-match.sh \ + --bucket "$BUCKET" \ + --key "$DIRECTORY/manifest.json" \ + --body "manifest.json" \ + --content-type "application/json" + MANIFEST_URL="$CDN_BASE/$DIRECTORY/manifest.json" + echo "manifest_url=$MANIFEST_URL" >> "$GITHUB_OUTPUT" + echo "✅ Manifest uploaded successfully: $MANIFEST_URL" + 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" \ + ../scripts/s3-put-object-if-none-match.sh \ + --bucket "$BUCKET" \ + --key "$DIRECTORY/manifest.json.sig" \ + --body "manifest.json.sig" \ --content-type "application/octet-stream" - aws s3 cp "manifest.json.cert" "s3://$BUCKET/$DIRECTORY/manifest.json.cert" \ - --cache-control "public,max-age=31536000,immutable" \ + ../scripts/s3-put-object-if-none-match.sh \ + --bucket "$BUCKET" \ + --key "$DIRECTORY/manifest.json.cert" \ + --body "manifest.json.cert" \ --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" \ + ../scripts/s3-put-object-if-none-match.sh \ + --bucket "$BUCKET" \ + --key "$DIRECTORY/manifest.json.sigstore.json" \ + --body "manifest.json.sigstore.json" \ --content-type "application/json" # ================================================================ @@ -1449,8 +1453,9 @@ jobs: env: ORG_REPO: ${{ github.event.repository.owner.login }}/${{ github.event.repository.name }} VERSION: ${{ inputs.tag }} + RELEASE_STORAGE_NAME: ${{ inputs.release_storage_name }} run: | - ./scripts/validate-release-artifacts.sh "$ORG_REPO" "$VERSION" + ./scripts/validate-release-artifacts.sh "$ORG_REPO" "$VERSION" "$RELEASE_STORAGE_NAME" notify-release-failure: needs: diff --git a/Makefile b/Makefile index ecd701b..1ec505b 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,12 @@ test: test-go test-scripts .PHONY: test-go test-go: - go test ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests + go test ./cmd/extract-images ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests ./cmd/publish-public-ecr-release-tags .PHONY: test-scripts test-scripts: bash scripts/test-derive-iam-role-name.sh + bash scripts/test-s3-release-uploads.sh .PHONY: workflow-validate workflow-validate: diff --git a/README.md b/README.md index a8c6aaa..6276dbe 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,16 @@ jobs: The release workflow accepts the following input parameters: -| Parameter | Required | Default | Description | -| --------------------- | -------- | ------- | --------------------------------------------------------------------------- | -| `tag` | Yes | - | The release tag (must be valid semver with `v` prefix, e.g., `v1.0.0`) | -| `lambda` | No | `true` | Whether to release with Lambda image support | -| `docker` | No | `true` | Whether to release with Docker image support | -| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) | -| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context | -| `msi` | No | `true` | Whether to build MSI Windows installers | -| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) | +| Parameter | Required | Default | Description | +|-|-|-|-| +| `tag` | Yes | - | The release tag (must be valid semver with `v` prefix, e.g., `v1.0.0`) | +| `release_storage_name` | No | `""` | Optional S3 release path segment matching `^[a-z][a-z0-9-]{0,99}$`; defaults to the repository name | +| `lambda` | No | `true` | Whether to release with Lambda image support | +| `docker` | No | `true` | Whether to release with Docker image support | +| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) | +| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context | +| `msi` | No | `true` | Whether to build MSI Windows installers | +| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) | 2. Ensure your repository has the following secrets configured: diff --git a/cmd/extract-images/main_test.go b/cmd/extract-images/main_test.go index 452f066..2e07bc3 100644 --- a/cmd/extract-images/main_test.go +++ b/cmd/extract-images/main_test.go @@ -21,6 +21,25 @@ ccc333 public.ecr.aws/conductorone/baton-example:0.1.2 } } +func TestExtractPublicImagesIgnoresLatest(t *testing.T) { + images := make(map[string]*pb.Image) + foundECR := extractPublicImages([]byte(` +aaa111 public.ecr.aws/conductorone/baton-example:latest +bbb222 public.ecr.aws/conductorone/baton-example:0.1.2 +`), "0.1.2", images) + + if !foundECR { + t.Fatal("ECR public image was not found") + } + image := images["ecrPublic"] + if image.GetDigest() != "sha256:bbb222" { + t.Fatalf("ecrPublic digest = %q", image.GetDigest()) + } + if image.GetTag() != "0.1.2" { + t.Fatalf("ecrPublic tag = %q", image.GetTag()) + } +} + func TestExtractLambdaImagePreservesECRRef(t *testing.T) { images := make(map[string]*pb.Image) found := extractLambdaImage([]byte(` diff --git a/cmd/publish-public-ecr-release-tags/main.go b/cmd/publish-public-ecr-release-tags/main.go new file mode 100644 index 0000000..dc46fb0 --- /dev/null +++ b/cmd/publish-public-ecr-release-tags/main.go @@ -0,0 +1,305 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" +) + +const defaultRegistryURI = "public.ecr.aws/conductorone" + +var sha256DigestPattern = regexp.MustCompile(`^(?:sha256:)?([A-Fa-f0-9]{64})$`) + +type config struct { + repositoryName string + versionTag string + candidateTag string + digestFile string + registryURI string +} + +type awsRunner interface { + Run(args ...string) ([]byte, []byte, error) +} + +type execAWSRunner struct { + binary string +} + +func (r execAWSRunner) Run(args ...string) ([]byte, []byte, error) { + var stderr bytes.Buffer + cmd := exec.Command(r.binary, args...) + cmd.Stderr = &stderr + stdout, err := cmd.Output() + return stdout, stderr.Bytes(), err +} + +func main() { + cfg := config{ + registryURI: defaultRegistryURI, + } + flag.StringVar(&cfg.repositoryName, "repository-name", "", "Public ECR repository name") + flag.StringVar(&cfg.versionTag, "version-tag", "", "Immutable release version tag") + flag.StringVar(&cfg.candidateTag, "candidate-tag", "", "Temporary candidate image tag pushed by GoReleaser") + flag.StringVar(&cfg.digestFile, "digest-file", "", "Path to the GoReleaser docker_digest file") + flag.StringVar(&cfg.registryURI, "registry-uri", defaultRegistryURI, "Public ECR registry URI") + flag.Usage = func() { + 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") + 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") + flag.PrintDefaults() + } + flag.Parse() + + if err := cfg.validate(); err != nil { + fmt.Fprintln(os.Stderr, err) + flag.Usage() + os.Exit(2) + } + + awsCLI := os.Getenv("AWS_CLI") + if awsCLI == "" { + awsCLI = "aws" + } + + if err := publish(cfg, execAWSRunner{binary: awsCLI}, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func (c config) validate() error { + var missing []string + if c.repositoryName == "" { + missing = append(missing, "-repository-name") + } + if c.versionTag == "" { + missing = append(missing, "-version-tag") + } + if c.candidateTag == "" { + missing = append(missing, "-candidate-tag") + } + if c.digestFile == "" { + missing = append(missing, "-digest-file") + } + if len(missing) > 0 { + return fmt.Errorf("publish-public-ecr-release-tags: error: missing required flags: %s", strings.Join(missing, ", ")) + } + if c.registryURI == "" { + return fmt.Errorf("publish-public-ecr-release-tags: error: -registry-uri must not be empty") + } + return nil +} + +func publish(cfg config, aws awsRunner, stdout, stderr io.Writer) error { + imageBase := strings.TrimRight(cfg.registryURI, "/") + "/" + cfg.repositoryName + candidateRef := imageBase + ":" + cfg.candidateTag + versionRef := imageBase + ":" + cfg.versionTag + + candidateDigest, err := candidateDigestFromFile(cfg.digestFile, candidateRef) + if err != nil { + return err + } + + existingDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag) + if err != nil { + return err + } + versionTagExists := existingDigest != "" + + if versionTagExists { + if existingDigest != candidateDigest { + 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) + } + fmt.Fprintf(stdout, "Public ECR version tag already points at %s; keeping release idempotent\n", candidateDigest) + } else { + fmt.Fprintf(stdout, "Public ECR version tag %s:%s is available\n", cfg.repositoryName, cfg.versionTag) + } + + manifest, err := imageManifest(aws, cfg.repositoryName, candidateDigest) + if err != nil { + return err + } + + if !versionTagExists { + if err := putImageTag(aws, cfg.repositoryName, manifest, cfg.versionTag); err != nil { + return err + } + } else { + fmt.Fprintf(stdout, "Skipped Public ECR version tag write because %s:%s already has %s\n", cfg.repositoryName, cfg.versionTag, candidateDigest) + } + + publishedDigest, err := describeImageDigest(aws, cfg.repositoryName, cfg.versionTag) + if err != nil { + return err + } + if publishedDigest == "" { + return fmt.Errorf("::error::Public ECR tag %s:%s was not found after publication", cfg.repositoryName, cfg.versionTag) + } + if publishedDigest != candidateDigest { + return fmt.Errorf("::error::Public ECR tag %s:%s points at %s after publication, expected %s", cfg.repositoryName, cfg.versionTag, publishedDigest, candidateDigest) + } + + if err := putImageTag(aws, cfg.repositoryName, manifest, "latest"); err != nil { + return err + } + + if err := os.WriteFile(cfg.digestFile, []byte(fmt.Sprintf("%s %s\n", candidateDigest, versionRef)), 0o644); err != nil { + return fmt.Errorf("publish-public-ecr-release-tags: error: rewriting digest file: %w", err) + } + + if err := deleteImageTag(aws, cfg.repositoryName, cfg.candidateTag); err != nil { + fmt.Fprintf(stderr, "::warning::Could not remove temporary Public ECR candidate tag %s: %v\n", cfg.candidateTag, err) + } else { + fmt.Fprintf(stdout, "Removed temporary Public ECR candidate tag %s\n", cfg.candidateTag) + } + + fmt.Fprintf(stdout, "Published %s at %s\n", versionRef, candidateDigest) + return nil +} + +func candidateDigestFromFile(path, candidateRef string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("Digest file not found: %s", path) + } + return "", fmt.Errorf("publish-public-ecr-release-tags: error: reading digest file %s: %w", path, err) + } + + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[1] != candidateRef { + continue + } + digest, err := normalizeDigest(fields[0]) + if err != nil { + return "", fmt.Errorf("publish-public-ecr-release-tags: error: candidate digest for %s in %s is invalid: %w", candidateRef, path, err) + } + return digest, nil + } + + return "", fmt.Errorf("Could not find candidate ref %s in %s\n%s", candidateRef, path, content) +} + +func describeImageDigest(aws awsRunner, repositoryName, tag string) (string, error) { + args := []string{ + "ecr-public", "describe-images", + "--repository-name", repositoryName, + "--image-ids", "imageTag=" + tag, + "--output", "json", + } + stdout, stderr, err := aws.Run(args...) + if err != nil { + if isImageNotFound(stderr) { + return "", nil + } + return "", awsError(args, stderr, err) + } + + var response struct { + ImageDetails []struct { + ImageDigest string `json:"imageDigest"` + } `json:"imageDetails"` + } + if err := json.Unmarshal(stdout, &response); err != nil { + return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing describe-images response for %s:%s: %w", repositoryName, tag, err) + } + if len(response.ImageDetails) == 0 || response.ImageDetails[0].ImageDigest == "" { + return "", nil + } + + digest, err := normalizeDigest(response.ImageDetails[0].ImageDigest) + if err != nil { + return "", fmt.Errorf("publish-public-ecr-release-tags: error: describe-images returned invalid digest for %s:%s: %w", repositoryName, tag, err) + } + return digest, nil +} + +func imageManifest(aws awsRunner, repositoryName, digest string) (string, error) { + args := []string{ + "ecr-public", "batch-get-image", + "--repository-name", repositoryName, + "--image-ids", "imageDigest=" + digest, + "--accepted-media-types", + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "--output", "json", + } + stdout, stderr, err := aws.Run(args...) + if err != nil { + return "", awsError(args, stderr, err) + } + + var response struct { + Images []struct { + ImageManifest string `json:"imageManifest"` + } `json:"images"` + } + if err := json.Unmarshal(stdout, &response); err != nil { + return "", fmt.Errorf("publish-public-ecr-release-tags: error: parsing batch-get-image response for %s@%s: %w", repositoryName, digest, err) + } + if len(response.Images) == 0 || response.Images[0].ImageManifest == "" { + return "", fmt.Errorf("Could not fetch manifest for %s@%s\n%s", repositoryName, digest, stdout) + } + return response.Images[0].ImageManifest, nil +} + +func putImageTag(aws awsRunner, repositoryName, manifest, tag string) error { + args := []string{ + "ecr-public", "put-image", + "--repository-name", repositoryName, + "--image-manifest", manifest, + "--image-tag", tag, + } + _, stderr, err := aws.Run(args...) + if err != nil { + return awsError(args, stderr, err) + } + return nil +} + +func deleteImageTag(aws awsRunner, repositoryName, tag string) error { + args := []string{ + "ecr-public", "batch-delete-image", + "--repository-name", repositoryName, + "--image-ids", "imageTag=" + tag, + } + _, stderr, err := aws.Run(args...) + if err != nil { + return awsError(args, stderr, err) + } + return nil +} + +func normalizeDigest(raw string) (string, error) { + raw = strings.TrimSpace(raw) + matches := sha256DigestPattern.FindStringSubmatch(raw) + if matches == nil { + return "", fmt.Errorf("expected sha256 digest, got %q", raw) + } + return "sha256:" + strings.ToLower(matches[1]), nil +} + +func isImageNotFound(stderr []byte) bool { + message := string(stderr) + return strings.Contains(message, "ImageNotFound") || + strings.Contains(message, "ImageNotFoundException") || + strings.Contains(message, "RepositoryNotFound") || + strings.Contains(message, "RepositoryNotFoundException") +} + +func awsError(args []string, stderr []byte, err error) error { + message := strings.TrimSpace(string(stderr)) + if message == "" { + return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %w", strings.Join(args, " "), err) + } + return fmt.Errorf("publish-public-ecr-release-tags: error: aws %s: %s: %w", strings.Join(args, " "), message, err) +} diff --git a/cmd/publish-public-ecr-release-tags/main_test.go b/cmd/publish-public-ecr-release-tags/main_test.go new file mode 100644 index 0000000..04b3e23 --- /dev/null +++ b/cmd/publish-public-ecr-release-tags/main_test.go @@ -0,0 +1,382 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +var ( + digestA = "sha256:" + strings.Repeat("a", 64) + digestAHex = strings.TrimPrefix(digestA, "sha256:") + digestB = "sha256:" + strings.Repeat("b", 64) +) + +func TestPublishPublicECRTagsPublishesAvailableVersion(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestAHex)) + fake := &fakeAWS{ + describeResults: []describeResult{ + {notFound: true}, + {digest: digestA}, + }, + } + + if _, _, err := runPublishForTest(t, digestFile, fake); err != nil { + t.Fatalf("publish: %v", err) + } + + if got := fake.countDescribeTag("1.2.3"); got != 2 { + t.Fatalf("version tag describe count = %d, want 2", got) + } + if !fake.calledPutTag("1.2.3") { + t.Fatal("version tag was not published") + } + if !fake.calledPutTag("latest") { + t.Fatal("latest tag was not published") + } + if fake.countDescribeTag("latest") != 0 { + t.Fatal("latest must not be part of the ECR release preflight") + } + if !fake.calledCommand("batch-delete-image") { + t.Fatal("temporary candidate tag was not deleted") + } + + want := fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:1.2.3\n", digestA) + got, err := os.ReadFile(digestFile) + if err != nil { + t.Fatalf("read digest file: %v", err) + } + if string(got) != want { + t.Fatalf("digest file = %q, want %q", got, want) + } +} + +func TestPublishPublicECRTagsKeepsSameVersionIdempotent(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{ + {digest: digestA}, + {digest: digestA}, + }, + } + + if _, _, err := runPublishForTest(t, digestFile, fake); err != nil { + t.Fatalf("publish: %v", err) + } + + if fake.calledPutTag("1.2.3") { + t.Fatal("same digest should not rewrite the version tag") + } + if !fake.calledPutTag("latest") { + t.Fatal("latest tag should still be refreshed") + } +} + +func TestPublishPublicECRTagsRejectsDifferentExistingDigest(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{{digest: digestB}}, + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "already points at "+digestB) { + t.Fatalf("error = %v", err) + } + if fake.calledCommand("batch-get-image") { + t.Fatal("different digest must fail before fetching the candidate manifest") + } + if fake.calledCommand("put-image") { + t.Fatal("different digest must fail before tag publication") + } +} + +func TestPublishPublicECRTagsFailsClosedOnDescribeError(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{{stderr: "AccessDeniedException: denied"}}, + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "AccessDeniedException") { + t.Fatalf("error = %v", err) + } + if fake.calledCommand("put-image") { + t.Fatal("AWS describe errors must fail before tag publication") + } +} + +func TestPublishPublicECRTagsRejectsMissingCandidateDigest(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:latest\n", digestA)) + fake := &fakeAWS{} + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "release-candidate-123-1") { + t.Fatalf("error = %v", err) + } + if len(fake.calls) != 0 { + t.Fatalf("AWS calls = %#v, want none", fake.calls) + } +} + +func TestPublishPublicECRTagsRejectsMalformedCandidateDigest(t *testing.T) { + digestFile := writeDigestFile(t, "not-a-digest public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n") + fake := &fakeAWS{} + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "candidate digest") { + t.Fatalf("error = %v", err) + } + if len(fake.calls) != 0 { + t.Fatalf("AWS calls = %#v, want none", fake.calls) + } +} + +func TestPublishPublicECRTagsRejectsPostWriteDigestMismatch(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{ + {notFound: true}, + {digest: digestB}, + }, + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "after publication, expected "+digestA) { + t.Fatalf("error = %v", err) + } + if !fake.calledPutTag("1.2.3") { + t.Fatal("version tag should be written before post-write verification") + } + if fake.calledPutTag("latest") { + t.Fatal("latest must not be published after version digest verification fails") + } +} + +func TestPublishPublicECRTagsRejectsMissingManifest(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + emptyManifest := "" + fake := &fakeAWS{ + describeResults: []describeResult{{notFound: true}}, + manifest: &emptyManifest, + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "Could not fetch manifest") { + t.Fatalf("error = %v", err) + } + if fake.calledCommand("put-image") { + t.Fatal("missing manifest must fail before tag publication") + } +} + +func TestPublishPublicECRTagsFailsClosedOnBatchGetImageError(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{{notFound: true}}, + batchGetErr: "AccessDeniedException: denied", + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "AccessDeniedException") { + t.Fatalf("error = %v", err) + } + if fake.calledCommand("put-image") { + t.Fatal("batch-get-image errors must fail before tag publication") + } +} + +func TestPublishPublicECRTagsFailsClosedOnVersionPutImageError(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{{notFound: true}}, + putErr: "AccessDeniedException: denied", + } + + _, _, err := runPublishForTest(t, digestFile, fake) + if err == nil { + t.Fatal("publish succeeded, want failure") + } + if !strings.Contains(err.Error(), "AccessDeniedException") { + t.Fatalf("error = %v", err) + } + if !fake.calledPutTag("1.2.3") { + t.Fatal("version tag write should have been attempted") + } + if got := fake.countDescribeTag("1.2.3"); got != 1 { + t.Fatalf("version tag describe count = %d, want 1", got) + } + if fake.calledPutTag("latest") { + t.Fatal("latest must not be published after version tag write fails") + } +} + +func TestPublishPublicECRTagsCandidateCleanupIsBestEffort(t *testing.T) { + digestFile := writeDigestFile(t, fmt.Sprintf("%s public.ecr.aws/conductorone/bridge-client:release-candidate-123-1\n", digestA)) + fake := &fakeAWS{ + describeResults: []describeResult{ + {notFound: true}, + {digest: digestA}, + }, + deleteErr: "delete denied", + } + + _, stderr, err := runPublishForTest(t, digestFile, fake) + if err != nil { + t.Fatalf("publish: %v", err) + } + if !strings.Contains(stderr, "::warning::Could not remove temporary Public ECR candidate tag") { + t.Fatalf("stderr = %q", stderr) + } +} + +func runPublishForTest(t *testing.T, digestFile string, fake *fakeAWS) (string, string, error) { + t.Helper() + cfg := config{ + repositoryName: "bridge-client", + versionTag: "1.2.3", + candidateTag: "release-candidate-123-1", + digestFile: digestFile, + registryURI: defaultRegistryURI, + } + var stdout, stderr bytes.Buffer + err := publish(cfg, fake, &stdout, &stderr) + return stdout.String(), stderr.String(), err +} + +func writeDigestFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "digests.txt") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write digest file: %v", err) + } + return path +} + +type describeResult struct { + digest string + notFound bool + stderr string +} + +type fakeAWS struct { + describeResults []describeResult + manifest *string + batchGetErr string + putErr string + deleteErr string + calls [][]string +} + +func (f *fakeAWS) Run(args ...string) ([]byte, []byte, error) { + f.calls = append(f.calls, append([]string(nil), args...)) + + if len(args) < 2 || args[0] != "ecr-public" { + return nil, []byte("unexpected aws call"), errors.New("aws failed") + } + + switch args[1] { + case "describe-images": + if len(f.describeResults) == 0 { + return nil, []byte("unexpected describe-images call"), errors.New("aws failed") + } + result := f.describeResults[0] + f.describeResults = f.describeResults[1:] + if result.stderr != "" { + return nil, []byte(result.stderr), errors.New("aws failed") + } + if result.notFound { + return nil, []byte("ImageNotFoundException: image not found"), errors.New("aws failed") + } + stdout, err := json.Marshal(map[string]any{ + "imageDetails": []map[string]string{{"imageDigest": result.digest}}, + }) + return stdout, nil, err + case "batch-get-image": + if f.batchGetErr != "" { + return nil, []byte(f.batchGetErr), errors.New("aws failed") + } + manifest := "manifest-json" + if f.manifest != nil { + manifest = *f.manifest + } + stdout, err := json.Marshal(map[string]any{ + "images": []map[string]string{{"imageManifest": manifest}}, + }) + return stdout, nil, err + case "put-image": + if f.putErr != "" { + return nil, []byte(f.putErr), errors.New("aws failed") + } + return []byte(`{"image":{}}`), nil, nil + case "batch-delete-image": + if f.deleteErr != "" { + return nil, []byte(f.deleteErr), errors.New("aws failed") + } + return []byte(`{}`), nil, nil + default: + return nil, []byte("unexpected aws call"), errors.New("aws failed") + } +} + +func (f *fakeAWS) calledCommand(command string) bool { + for _, call := range f.calls { + if len(call) >= 2 && call[1] == command { + return true + } + } + return false +} + +func (f *fakeAWS) calledPutTag(tag string) bool { + for _, call := range f.calls { + if len(call) >= 2 && call[1] == "put-image" && argValue(call, "--image-tag") == tag { + return true + } + } + return false +} + +func (f *fakeAWS) countDescribeTag(tag string) int { + var count int + for _, call := range f.calls { + if len(call) >= 2 && call[1] == "describe-images" && argValue(call, "--image-ids") == "imageTag="+tag { + count++ + } + } + return count +} + +func argValue(args []string, name string) string { + for i, arg := range args { + if arg == name && i+1 < len(args) { + return args[i+1] + } + } + return "" +} diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 93f84fe..52685fe 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -23,6 +23,8 @@ When a tag is pushed to a connector repository, the shared release workflow: Validates workflow inputs before proceeding: - Ensures tag is valid semver starting with 'v' (e.g., `v1.2.3`) +- Ensures `release_storage_name`, when set, matches + `^[a-z][a-z0-9-]{0,99}$` - Ensures `dockerfile_template` is only used when `lambda: false` - Ensures `docker_extra_files` is only used when `dockerfile_template` is set - Ensures `msi_wxs_path` has no path traversal (`..` or absolute paths) @@ -41,7 +43,7 @@ Builds and signs binary archives for macOS and Linux: - Generates SBOMs using Syft - Creates SLSA v1 provenance attestations - Signs SBOMs as attestation bundles -- Uploads all artifacts to S3 +- Uploads all artifacts to S3 with no-overwrite writes **Outputs:** `*.zip` (macOS), `*.tar.gz` (Linux), `*.provenance.sigstore.json`, `*.sbom.sigstore.json` @@ -55,7 +57,7 @@ Builds Windows zip and MSI installer: - Deterministic UpgradeCode via UUID v5 from repository name - Supports custom WXS templates via `msi_wxs_path` input - Generates SBOMs and SLSA v1 provenance attestations -- Uploads all artifacts to S3 +- Uploads all artifacts to S3 with no-overwrite writes **Outputs:** `*.zip`, `*.msi`, `*.provenance.sigstore.json`, `*.sbom.sigstore.json` @@ -66,7 +68,9 @@ Builds Windows zip and MSI installer: Builds and publishes container images: - Multi-arch Docker images (amd64/arm64) -- Pushes to ECR Public (for Lambda deployment) +- Pushes a candidate image to ECR Public +- Promotes the candidate digest to the version tag after preflight +- Updates `latest` as mutable convenience metadata - Attaches provenance attestations to images (OCI referrers) **Outputs:** ECR Public images with attached attestations @@ -78,7 +82,7 @@ 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 +- Uploads manifest and checksums to S3 with no-overwrite writes - Exposes the final manifest to the registry API recording job ### record-registry-api @@ -100,6 +104,22 @@ Post-release validation (non-blocking): ## Security Properties +### Immutable S3 Release Objects + +Versioned release objects are uploaded through S3 `PutObject` with +`If-None-Match: *`. Existing objects under +`releases/{org}/{release_storage_name}/{tag}/...` cause the release to fail +instead of being overwritten. When `release_storage_name` is not set, the +workflow uses the repository name, preserving the existing connector path. + +### Public ECR Version Tags + +Public ECR image publication writes a temporary candidate tag first. The +workflow compares that candidate digest to any existing version tag before +publishing the version tag. A matching digest is allowed; a different digest is +rejected. The `latest` tag is updated after the version check and is not used +in release identity data. + ### Release Source Identity Release jobs check out caller code from `refs/tags/` and verify the @@ -195,7 +215,7 @@ $GITHUB_WORKSPACE/ ## S3 File Structure ``` -releases/{org}/{repo}/{tag}/ +releases/{org}/{release_storage_name}/{tag}/ ├── manifest.json ├── manifest.json.sig ├── manifest.json.cert diff --git a/scripts/s3-put-object-if-none-match.sh b/scripts/s3-put-object-if-none-match.sh new file mode 100755 index 0000000..a1d7a49 --- /dev/null +++ b/scripts/s3-put-object-if-none-match.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: s3-put-object-if-none-match.sh --bucket BUCKET --key KEY --body FILE --content-type TYPE [--cache-control VALUE] + +Uploads one immutable S3 object through s3api PutObject with If-None-Match: *. +If an object already exists with matching sha256 metadata, the upload is skipped. +USAGE +} + +bucket="" +key="" +body="" +content_type="" +cache_control="public,max-age=31536000,immutable" + +while [[ $# -gt 0 ]]; do + case "$1" in + --bucket) + bucket="${2:-}" + shift 2 + ;; + --key) + key="${2:-}" + shift 2 + ;; + --body) + body="${2:-}" + shift 2 + ;; + --content-type) + content_type="${2:-}" + shift 2 + ;; + --cache-control) + cache_control="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$bucket" || -z "$key" || -z "$body" || -z "$content_type" ]]; then + usage + exit 2 +fi + +if [[ ! -f "$body" ]]; then + echo "Body file not found: $body" >&2 + exit 1 +fi + +aws_cli="${AWS_CLI:-aws}" + +compute_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + return + fi + shasum -a 256 "$1" | awk '{print $1}' +} + +body_sha256="$(compute_sha256 "$body")" + +if existing="$("$aws_cli" s3api head-object --bucket "$bucket" --key "$key" 2>/dev/null)"; then + existing_sha256="$(printf '%s' "$existing" | jq -r '.Metadata.sha256 // .Metadata.Sha256 // empty')" + if [[ "$existing_sha256" == "$body_sha256" ]]; then + echo "S3 object already exists with matching sha256 metadata, skipping: s3://${bucket}/${key}" + exit 0 + fi + echo "::error::S3 object already exists with different or missing sha256 metadata: s3://${bucket}/${key}" >&2 + exit 1 +fi + +"$aws_cli" s3api put-object \ + --bucket "$bucket" \ + --key "$key" \ + --body "$body" \ + --cache-control "$cache_control" \ + --content-type "$content_type" \ + --metadata "sha256=$body_sha256" \ + --if-none-match "*" >/dev/null diff --git a/scripts/test-s3-release-uploads.sh b/scripts/test-s3-release-uploads.sh new file mode 100755 index 0000000..cbb63d0 --- /dev/null +++ b/scripts/test-s3-release-uploads.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +fake_aws="${tmp_dir}/aws" +args_log="${tmp_dir}/aws-args.log" +cat > "$fake_aws" <<'FAKE_AWS' +#!/usr/bin/env bash +printf '%s\n' "$@" >> "$AWS_ARGS_LOG" +if [[ "${1:-}" == "s3api" && "${2:-}" == "head-object" ]]; then + if [[ -n "${FAKE_HEAD_OBJECT_JSON:-}" ]]; then + printf '%s\n' "$FAKE_HEAD_OBJECT_JSON" + exit 0 + fi + exit 255 +fi +FAKE_AWS +chmod +x "$fake_aws" + +body="${tmp_dir}/artifact.txt" +printf 'artifact\n' > "$body" + +AWS_CLI="$fake_aws" AWS_ARGS_LOG="$args_log" \ + "${ROOT_DIR}/scripts/s3-put-object-if-none-match.sh" \ + --bucket release-bucket \ + --key releases/ConductorOne/example/v1.2.3/artifact.txt \ + --body "$body" \ + --content-type text/plain + +grep -Fqx -- "s3api" "$args_log" +grep -Fqx -- "put-object" "$args_log" +grep -Fqx -- "--bucket" "$args_log" +grep -Fqx -- "release-bucket" "$args_log" +grep -Fqx -- "--key" "$args_log" +grep -Fqx -- "releases/ConductorOne/example/v1.2.3/artifact.txt" "$args_log" +grep -Fqx -- "--if-none-match" "$args_log" +grep -Fqx -- "*" "$args_log" +grep -Fqx -- "--metadata" "$args_log" + +if command -v sha256sum >/dev/null 2>&1; then + body_sha256="$(sha256sum "$body" | awk '{print $1}')" +else + body_sha256="$(shasum -a 256 "$body" | awk '{print $1}')" +fi +: > "$args_log" +FAKE_HEAD_OBJECT_JSON="{\"Metadata\":{\"sha256\":\"${body_sha256}\"}}" \ + AWS_CLI="$fake_aws" AWS_ARGS_LOG="$args_log" \ + "${ROOT_DIR}/scripts/s3-put-object-if-none-match.sh" \ + --bucket release-bucket \ + --key releases/ConductorOne/example/v1.2.3/artifact.txt \ + --body "$body" \ + --content-type text/plain + +grep -Fqx -- "head-object" "$args_log" +if grep -Fqx -- "put-object" "$args_log"; then + echo "matching existing object should not be uploaded" >&2 + exit 1 +fi + +dist_dir="${tmp_dir}/dist" +mkdir -p "$dist_dir" +printf 'zip\n' > "${dist_dir}/example.zip" +printf 'sig\n' > "${dist_dir}/example.zip.sig" +printf 'cert\n' > "${dist_dir}/example.zip.cert" +printf '{}\n' > "${dist_dir}/example.zip.sbom.json" +printf '{}\n' > "${dist_dir}/example.zip.sbom.sigstore.json" +printf 'ignore\n' > "${dist_dir}/ignore.txt" +: > "$args_log" + +AWS_CLI="$fake_aws" AWS_ARGS_LOG="$args_log" \ + "${ROOT_DIR}/scripts/upload-release-artifacts.sh" \ + --bucket release-bucket \ + --directory releases/ConductorOne/example/v1.2.3 \ + --base-dir "$dist_dir" \ + --include-sbom-documents + +grep -Fq -- "releases/ConductorOne/example/v1.2.3/example.zip" "$args_log" +grep -Fq -- "releases/ConductorOne/example/v1.2.3/example.zip.sig" "$args_log" +grep -Fq -- "releases/ConductorOne/example/v1.2.3/example.zip.cert" "$args_log" +grep -Fq -- "releases/ConductorOne/example/v1.2.3/example.zip.sbom.json" "$args_log" +grep -Fq -- "releases/ConductorOne/example/v1.2.3/example.zip.sbom.sigstore.json" "$args_log" +if grep -Fq -- "ignore.txt" "$args_log"; then + echo "unexpected upload for ignore.txt" >&2 + exit 1 +fi + +echo "s3 release upload tests passed" diff --git a/scripts/upload-release-artifacts.sh b/scripts/upload-release-artifacts.sh new file mode 100755 index 0000000..e808165 --- /dev/null +++ b/scripts/upload-release-artifacts.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: upload-release-artifacts.sh --bucket BUCKET --directory S3_DIRECTORY --base-dir DIR [--include-sbom-documents] + +Uploads immutable release artifacts from DIR to s3://BUCKET/S3_DIRECTORY with +If-None-Match: *. Raw *.sbom.json documents are only uploaded when requested. +USAGE +} + +bucket="" +directory="" +base_dir="" +include_sbom_documents=false +cache_control="public,max-age=31536000,immutable" + +while [[ $# -gt 0 ]]; do + case "$1" in + --bucket) + bucket="${2:-}" + shift 2 + ;; + --directory) + directory="${2:-}" + shift 2 + ;; + --base-dir) + base_dir="${2:-}" + shift 2 + ;; + --include-sbom-documents) + include_sbom_documents=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$bucket" || -z "$directory" || -z "$base_dir" ]]; then + usage + exit 2 +fi + +if [[ ! -d "$base_dir" ]]; then + echo "Base directory not found: $base_dir" >&2 + exit 1 +fi + +content_type_for() { + local name="$1" + case "$name" in + *.tar.gz) + printf '%s\n' "application/gzip" + ;; + *.zip) + printf '%s\n' "application/zip" + ;; + *.msi) + printf '%s\n' "application/x-msi" + ;; + *.sig) + printf '%s\n' "application/octet-stream" + ;; + *.cert) + printf '%s\n' "application/x-pem-file" + ;; + *.sigstore.json) + printf '%s\n' "application/json" + ;; + *.sbom.json) + if [[ "$include_sbom_documents" == true ]]; then + printf '%s\n' "application/json" + fi + ;; + esac + return 0 +} + +upload_count=0 +while IFS= read -r -d '' file; do + name="$(basename "$file")" + content_type="$(content_type_for "$name")" + if [[ -z "$content_type" ]]; then + continue + fi + + echo "Uploading $name to S3 without overwrite..." + "$(dirname "$0")/s3-put-object-if-none-match.sh" \ + --bucket "$bucket" \ + --key "${directory}/${name}" \ + --body "$file" \ + --cache-control "$cache_control" \ + --content-type "$content_type" + upload_count=$((upload_count + 1)) +done < <(find "$base_dir" -maxdepth 1 -type f -print0) + +if [[ "$upload_count" -eq 0 ]]; then + echo "No release artifacts found in $base_dir" >&2 + exit 1 +fi + +echo "Uploaded ${upload_count} release artifacts to S3" diff --git a/scripts/validate-release-artifacts.sh b/scripts/validate-release-artifacts.sh index 4e9e701..023dcd0 100755 --- a/scripts/validate-release-artifacts.sh +++ b/scripts/validate-release-artifacts.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # validate-release-artifacts.sh - Validates release artifacts and attestations # -# Usage: validate-release-artifacts.sh ORG/REPO VERSION +# Usage: validate-release-artifacts.sh ORG/REPO VERSION [RELEASE_STORAGE_NAME] # Example: validate-release-artifacts.sh ConductorOne/baton-github-test v0.1.102 # # Validates: @@ -31,12 +31,27 @@ ORG_REPO="${1:-}" VERSION="${2:-}" if [[ -z "$ORG_REPO" || -z "$VERSION" ]]; then - echo "Usage: validate-release-artifacts.sh ORG/REPO VERSION" + echo "Usage: validate-release-artifacts.sh ORG/REPO VERSION [RELEASE_STORAGE_NAME]" echo "Example: validate-release-artifacts.sh ConductorOne/baton-github-test v0.1.102" exit 1 fi -MANIFEST_URL="${BASE_URL}/${ORG_REPO}/${VERSION}/manifest.json" +ORG="${ORG_REPO%%/*}" +REPO="${ORG_REPO#*/}" +if [[ -z "$ORG" || -z "$REPO" || "$ORG" == "$ORG_REPO" ]]; then + echo "ORG/REPO must be in owner/name form, got: $ORG_REPO" >&2 + exit 1 +fi + +RELEASE_TARGET_NAME_REGEX='^[a-z][a-z0-9-]{0,99}$' +RELEASE_STORAGE_NAME="${3:-$REPO}" +if [[ ! "$RELEASE_STORAGE_NAME" =~ $RELEASE_TARGET_NAME_REGEX ]]; then + echo "release_storage_name must match ${RELEASE_TARGET_NAME_REGEX} to align with registry release target validation, got: $RELEASE_STORAGE_NAME" >&2 + exit 1 +fi + +RELEASE_BASE_URL="${BASE_URL}/${ORG}/${RELEASE_STORAGE_NAME}/${VERSION}" +MANIFEST_URL="${RELEASE_BASE_URL}/manifest.json" TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT @@ -208,9 +223,9 @@ fi # 5. Validate manifest signature (if present) echo "" echo "=== Manifest Signature Validation ===" -MANIFEST_SIG_URL="${BASE_URL}/${ORG_REPO}/${VERSION}/manifest.json.sig" -MANIFEST_CERT_URL="${BASE_URL}/${ORG_REPO}/${VERSION}/manifest.json.cert" -MANIFEST_BUNDLE_URL="${BASE_URL}/${ORG_REPO}/${VERSION}/manifest.json.sigstore.json" +MANIFEST_SIG_URL="${RELEASE_BASE_URL}/manifest.json.sig" +MANIFEST_CERT_URL="${RELEASE_BASE_URL}/manifest.json.cert" +MANIFEST_BUNDLE_URL="${RELEASE_BASE_URL}/manifest.json.sigstore.json" if curl -sfL "$MANIFEST_BUNDLE_URL" -o "$TEMP_DIR/manifest.json.sigstore.json" 2>/dev/null; then if cosign verify-blob \ @@ -235,7 +250,7 @@ elif curl -sfL "$MANIFEST_SIG_URL" -o "$TEMP_DIR/manifest.json.sig" 2>/dev/null fail "Manifest signature verification failed" fi else - warn "Manifest signature files not found (may be legacy release)" + fail "Manifest signature files not found" fi # Summary diff --git a/templates/.goreleaser-binaries-template.yaml.tmpl b/templates/.goreleaser-binaries-template.yaml.tmpl index 0664a9e..df5cb0b 100644 --- a/templates/.goreleaser-binaries-template.yaml.tmpl +++ b/templates/.goreleaser-binaries-template.yaml.tmpl @@ -104,19 +104,3 @@ changelog: - typo - lint - Merge pull request -blobs: - - provider: s3 - bucket: "${S3_BUCKET}" - region: "${S3_REGION}" - directory: "${S3_DIRECTORY}" - ids: - - linux-archive - - darwin-archive - # Note: checksum NOT included - merged with Windows and uploaded by registry job - # Note: Windows archives uploaded by dedicated goreleaser-windows job - extra_files: - - glob: dist/*.sig - - glob: dist/*.cert - cache_control: - - "public,max-age=31536000,immutable" - content_disposition: "-" diff --git a/templates/.goreleaser-docker-oci-template.yaml.tmpl b/templates/.goreleaser-docker-oci-template.yaml.tmpl index 45f7a71..86c7540 100644 --- a/templates/.goreleaser-docker-oci-template.yaml.tmpl +++ b/templates/.goreleaser-docker-oci-template.yaml.tmpl @@ -2,7 +2,7 @@ ## GoReleaser >= 2.12 required for dockers_v2 ## ## Template variables (substituted via envsubst): -## REPO_NAME, DIST_DIR, DOCKERFILE_PATH, EXTRA_FILES_BLOCK +## REPO_NAME, DIST_DIR, DOCKERFILE_PATH, PUBLIC_ECR_PUBLISH_TAG, EXTRA_FILES_BLOCK version: 2 project_name: "${REPO_NAME}" dist: "${DIST_DIR}" @@ -22,8 +22,7 @@ dockers_v2: images: - "public.ecr.aws/conductorone/${REPO_NAME}" tags: - - "{{ .Version }}" - - latest + - "${PUBLIC_ECR_PUBLISH_TAG}" platforms: - linux/amd64 - linux/arm64