Compare commits

..

6 Commits

Author SHA1 Message Date
CamrynCarter
9051a06810 trim library/ 2026-03-20 14:51:00 -07:00
CamrynCarter
e084eb3e1f fixed keep registry logic 2026-03-19 11:13:26 -07:00
Camryn Carter
aa9b883d4c add cherry-pick workflow for release branches (#533)
this workflow automates cherry-picking changes from merged pull requests to specified release branches based on comments... it handles permission checks, version parsing, and conflict resolution during the cherry-pick process.

Signed-off-by: Camryn Carter <camryn.carter@ranchergovernment.com>
2026-03-18 23:26:13 -04:00
dependabot[bot]
565b27d54b bump google.golang.org/grpc in the go_modules group across 1 directory (#536)
bumps the go_modules group with 1 update in the / directory: [google.golang.org/grpc](https://github.com/grpc/grpc-go).

updates `google.golang.org/grpc` from 1.78.0 to 1.79.3
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.78.0...v1.79.3)

---

updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: indirect
  dependency-group: go_modules

...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 23:25:22 -04:00
Adam Martin
3adb9257b7 adjust hauler's kind annotation to not reflect cosign (#535)
Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
2026-03-18 23:24:47 -04:00
Adam Martin
268485f6d6 fix dockerhub default host bug (#534)
Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
2026-03-18 23:24:05 -04:00
12 changed files with 765 additions and 18 deletions

298
.github/workflows/cherrypick.yml vendored Normal file
View File

@@ -0,0 +1,298 @@
name: Cherry-pick to release branch
on:
issue_comment:
types: [created]
pull_request:
types: [closed]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
# ──────────────────────────────────────────────────────────────
# Trigger 1: /cherrypick-X.Y comment on a PR
# - If already merged → run cherry-pick immediately
# - If not yet merged → add label, cherry-pick will run on merge
# ──────────────────────────────────────────────────────────────
handle-comment:
if: >
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/cherrypick-')
runs-on: ubuntu-latest
steps:
- name: Check commenter permissions
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${COMMENTER}/permission \
--jq '.permission')
echo "Permission level for $COMMENTER: $PERMISSION"
if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "maintain" && "$PERMISSION" != "write" ]]; then
echo "::warning::User $COMMENTER does not have write access, ignoring cherry-pick request"
exit 1
fi
- name: Parse version from comment
id: parse
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
VERSION=$(echo "$COMMENT_BODY" | head -1 | grep -oP '(?<=/cherrypick-)\d+\.\d+')
if [ -z "$VERSION" ]; then
echo "::error::Could not parse version from comment"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "target_branch=release/$VERSION" >> "$GITHUB_OUTPUT"
echo "label=cherrypick/$VERSION" >> "$GITHUB_OUTPUT"
- name: React to comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
-f content='+1'
- name: Check if PR is merged
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_JSON=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
MERGED=$(echo "$PR_JSON" | jq -r '.merged')
echo "merged=$MERGED" >> "$GITHUB_OUTPUT"
echo "pr_title=$(echo "$PR_JSON" | jq -r '.title')" >> "$GITHUB_OUTPUT"
echo "base_sha=$(echo "$PR_JSON" | jq -r '.base.sha')" >> "$GITHUB_OUTPUT"
echo "head_sha=$(echo "$PR_JSON" | jq -r '.head.sha')" >> "$GITHUB_OUTPUT"
- name: Add cherry-pick label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LABEL: ${{ steps.parse.outputs.label }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
gh api repos/${{ github.repository }}/labels \
-f name="$LABEL" -f color="fbca04" -f description="Queued for cherry-pick" 2>/dev/null || true
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/labels \
-f "labels[]=$LABEL"
- name: Notify if queued (not yet merged)
if: steps.check.outputs.merged != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LABEL: ${{ steps.parse.outputs.label }}
TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
-f body="🏷️ Labeled \`$LABEL\` — backport to \`$TARGET_BRANCH\` will be created automatically when this PR is merged."
- name: Checkout repository
if: steps.check.outputs.merged == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify target branch exists
if: steps.check.outputs.merged == 'true'
env:
TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
-f body="❌ Cannot cherry-pick: branch \`$TARGET_BRANCH\` does not exist."
exit 1
fi
- name: Apply PR diff and push
if: steps.check.outputs.merged == 'true'
id: apply
env:
TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }}
PR_NUMBER: ${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${TARGET_BRANCH//\//-}"
echo "backport_branch=$BACKPORT_BRANCH" >> "$GITHUB_OUTPUT"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout "$TARGET_BRANCH"
git checkout -b "$BACKPORT_BRANCH"
# Download the PR's patch from GitHub (pure diff of the PR's changes)
gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} \
-H "Accept: application/vnd.github.v3.patch" > /tmp/pr.patch
# Apply the patch
HAS_CONFLICTS="false"
CONFLICTED_FILES=""
if git apply --check /tmp/pr.patch 2>/dev/null; then
# Clean apply
git apply /tmp/pr.patch
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}"
elif git apply --3way /tmp/pr.patch; then
# Applied with 3-way merge (auto-resolved)
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" || true
else
# Has real conflicts — apply what we can
HAS_CONFLICTS="true"
CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ',' | sed 's/,$//')
# Take the incoming version for conflicted files
git diff --name-only --diff-filter=U | while read -r file; do
git checkout --theirs -- "$file"
done
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH} (conflicts)" || true
fi
echo "has_conflicts=$HAS_CONFLICTS" >> "$GITHUB_OUTPUT"
echo "conflicted_files=$CONFLICTED_FILES" >> "$GITHUB_OUTPUT"
git push origin "$BACKPORT_BRANCH"
- name: Create backport PR
if: steps.check.outputs.merged == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }}
VERSION: ${{ steps.parse.outputs.version }}
PR_TITLE: ${{ steps.check.outputs.pr_title }}
PR_NUMBER: ${{ github.event.issue.number }}
BACKPORT_BRANCH: ${{ steps.apply.outputs.backport_branch }}
run: |
TITLE="[${VERSION}] ${PR_TITLE}"
BODY="Backport of #${PR_NUMBER} to \`${TARGET_BRANCH}\`."
PR_URL=$(gh pr create \
--base "$TARGET_BRANCH" \
--head "$BACKPORT_BRANCH" \
--title "$TITLE" \
--body "$BODY")
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
-f body="✅ Backport PR created: ${PR_URL}"
# ──────────────────────────────────────────────────────────────
# Trigger 2: PR merged → process any queued cherrypick/* labels
# ──────────────────────────────────────────────────────────────
handle-merge:
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Collect cherry-pick labels
id: labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
LABELS=$(gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/labels \
--jq '[.[] | select(.name | startswith("cherrypick/")) | .name] | join(",")')
if [ -z "$LABELS" ]; then
echo "No cherrypick labels found, nothing to do."
echo "has_labels=false" >> "$GITHUB_OUTPUT"
else
echo "Found labels: $LABELS"
echo "has_labels=true" >> "$GITHUB_OUTPUT"
echo "labels=$LABELS" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.labels.outputs.has_labels == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download PR patch
if: steps.labels.outputs.has_labels == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} \
-H "Accept: application/vnd.github.v3.patch" > /tmp/pr.patch
- name: Process each cherry-pick label
if: steps.labels.outputs.has_labels == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LABELS: ${{ steps.labels.outputs.labels }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
IFS=',' read -ra LABEL_ARRAY <<< "$LABELS"
for LABEL in "${LABEL_ARRAY[@]}"; do
VERSION="${LABEL#cherrypick/}"
TARGET_BRANCH="release/$VERSION"
echo "=== Processing backport to $TARGET_BRANCH ==="
# Verify target branch exists
if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
-f body="❌ Cannot cherry-pick to \`$TARGET_BRANCH\`: branch does not exist."
continue
fi
BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${TARGET_BRANCH//\//-}"
git checkout "$TARGET_BRANCH"
git checkout -b "$BACKPORT_BRANCH"
# Apply the patch
HAS_CONFLICTS="false"
CONFLICTED_FILES=""
if git apply --check /tmp/pr.patch 2>/dev/null; then
git apply /tmp/pr.patch
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}"
elif git apply --3way /tmp/pr.patch; then
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH}" || true
else
HAS_CONFLICTS="true"
CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ',' | sed 's/,$//')
git diff --name-only --diff-filter=U | while read -r file; do
git checkout --theirs -- "$file"
done
git add -A
git commit -m "Backport PR #${PR_NUMBER} to ${TARGET_BRANCH} (conflicts)" || true
fi
git push origin "$BACKPORT_BRANCH"
# Build PR title and body
TITLE="[${VERSION}] ${PR_TITLE}"
BODY="Backport of #${PR_NUMBER} to \`${TARGET_BRANCH}\`."
PR_URL=$(gh pr create \
--base "$TARGET_BRANCH" \
--head "$BACKPORT_BRANCH" \
--title "$TITLE" \
--body "$BODY")
gh api repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
-f body="✅ Backport PR to \`$TARGET_BRANCH\` created: ${PR_URL}"
# Clean up for next iteration
git checkout "$TARGET_BRANCH"
git branch -D "$BACKPORT_BRANCH"
done

View File

@@ -133,6 +133,7 @@ func storeImage(ctx context.Context, s *store.Layout, i v1.Image, platform strin
}
if rewrite != "" {
rawRewrite := rewrite
rewrite = strings.TrimPrefix(rewrite, "/")
if !strings.Contains(rewrite, ":") {
if tag, ok := r.(name.Tag); ok {
@@ -146,7 +147,7 @@ func storeImage(ctx context.Context, s *store.Layout, i v1.Image, platform strin
if err != nil {
return fmt.Errorf("unable to parse rewrite name [%s]: %w", rewrite, err)
}
if err := rewriteReference(ctx, s, r, newRef); err != nil {
if err := rewriteReference(ctx, s, r, newRef, rawRewrite); err != nil {
return err
}
}
@@ -155,7 +156,7 @@ func storeImage(ctx context.Context, s *store.Layout, i v1.Image, platform strin
return nil
}
func rewriteReference(ctx context.Context, s *store.Layout, oldRef name.Reference, newRef name.Reference) error {
func rewriteReference(ctx context.Context, s *store.Layout, oldRef name.Reference, newRef name.Reference, rawRewrite string) error {
l := log.FromContext(ctx)
if err := s.OCI.LoadIndex(); err != nil {
@@ -184,8 +185,9 @@ func rewriteReference(ctx context.Context, s *store.Layout, oldRef name.Referenc
newRegistry := newRefContext.RegistryStr()
// If user omitted a registry in the rewrite string, go-containerregistry defaults to
// index.docker.io. Preserve the original registry when the source is non-docker.
if newRegistry == "index.docker.io" && oldRegistry != "index.docker.io" {
if newRegistry == "index.docker.io" && !strings.HasPrefix(rawRewrite, "docker.io") && !strings.HasPrefix(rawRewrite, "index.docker.io") {
newRegistry = oldRegistry
newRepo = strings.TrimPrefix(newRepo, "library/") //if rewrite has library/ prefix in path it is stripped off unless registry specified in rewrite
}
oldTotal := oldRepo + ":" + oldTag
newTotal := newRepo + ":" + newTag

View File

@@ -107,6 +107,9 @@ func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDi
if _, exists := idx.Manifests[i].Annotations[consts.KindAnnotationName]; !exists {
idx.Manifests[i].Annotations[consts.KindAnnotationName] = consts.KindAnnotationImage
}
// Translate legacy dev.cosignproject.cosign values to dev.hauler equivalents.
kind := idx.Manifests[i].Annotations[consts.KindAnnotationName]
idx.Manifests[i].Annotations[consts.KindAnnotationName] = consts.NormalizeLegacyKind(kind)
if ref, ok := idx.Manifests[i].Annotations[consts.ContainerdImageNameKey]; ok {
if slash := strings.Index(ref, "/"); slash != -1 {
ref = ref[slash+1:]

View File

@@ -287,6 +287,99 @@ func TestUnarchiveLayoutTo_AnnotationBackfill(t *testing.T) {
}
}
// --------------------------------------------------------------------------
// TestUnarchiveLayoutTo_LegacyKindMigration
// --------------------------------------------------------------------------
// TestUnarchiveLayoutTo_LegacyKindMigration crafts a haul archive whose
// index.json contains old dev.cosignproject.cosign kind values, then verifies
// that unarchiveLayoutTo translates them to dev.hauler equivalents.
func TestUnarchiveLayoutTo_LegacyKindMigration(t *testing.T) {
ctx := newTestContext(t)
// Step 1: Extract the real test archive to obtain a valid OCI layout on disk.
extractDir := t.TempDir()
if err := archives.Unarchive(ctx, testHaulArchive, extractDir); err != nil {
t.Fatalf("Unarchive: %v", err)
}
// Step 2: Read index.json and inject old dev.cosignproject.cosign kind values.
indexPath := filepath.Join(extractDir, "index.json")
data, err := os.ReadFile(indexPath)
if err != nil {
t.Fatalf("read index.json: %v", err)
}
var idx ocispec.Index
if err := json.Unmarshal(data, &idx); err != nil {
t.Fatalf("unmarshal index.json: %v", err)
}
if len(idx.Manifests) == 0 {
t.Skip("testdata/haul.tar.zst has no top-level manifests — cannot test legacy kind migration")
}
// Replace all kind annotations with old-prefix equivalents so we can verify
// that unarchiveLayoutTo normalizes them to the new dev.hauler prefix.
const legacyPrefix = "dev.cosignproject.cosign"
const newPrefix = "dev.hauler"
for i := range idx.Manifests {
if idx.Manifests[i].Annotations == nil {
idx.Manifests[i].Annotations = make(map[string]string)
}
kind := idx.Manifests[i].Annotations[consts.KindAnnotationName]
if kind == "" {
kind = consts.KindAnnotationImage
}
// Rewrite dev.hauler/* → dev.cosignproject.cosign/* to simulate legacy archive.
if strings.HasPrefix(kind, newPrefix) {
kind = legacyPrefix + kind[len(newPrefix):]
}
idx.Manifests[i].Annotations[consts.KindAnnotationName] = kind
}
out, err := json.MarshalIndent(idx, "", " ")
if err != nil {
t.Fatalf("marshal legacy index.json: %v", err)
}
if err := os.WriteFile(indexPath, out, 0644); err != nil {
t.Fatalf("write legacy index.json: %v", err)
}
// Step 3: Re-archive with files at the archive root (no subdir prefix).
legacyArchive := filepath.Join(t.TempDir(), "legacy.tar.zst")
if err := createRootLevelArchive(extractDir, legacyArchive); err != nil {
t.Fatalf("createRootLevelArchive: %v", err)
}
// Step 4: Load the legacy archive.
destDir := t.TempDir()
tempDir := t.TempDir()
if err := unarchiveLayoutTo(ctx, legacyArchive, destDir, tempDir); err != nil {
t.Fatalf("unarchiveLayoutTo legacy: %v", err)
}
// Step 5: Every descriptor in the dest store must now have a dev.hauler kind.
s, err := store.NewLayout(destDir)
if err != nil {
t.Fatalf("store.NewLayout(destDir): %v", err)
}
if err := s.OCI.Walk(func(_ string, desc ocispec.Descriptor) error {
kind := desc.Annotations[consts.KindAnnotationName]
if strings.HasPrefix(kind, legacyPrefix) {
t.Errorf("descriptor %s still has legacy kind %q; expected dev.hauler prefix",
desc.Digest, kind)
}
if !strings.HasPrefix(kind, newPrefix) {
t.Errorf("descriptor %s has unexpected kind %q; expected dev.hauler prefix",
desc.Digest, kind)
}
return nil
}); err != nil {
t.Fatalf("Walk: %v", err)
}
}
// --------------------------------------------------------------------------
// TestClearDir
// --------------------------------------------------------------------------

2
go.mod
View File

@@ -324,7 +324,7 @@ require (
google.golang.org/api v0.267.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect

4
go.sum
View File

@@ -1294,8 +1294,8 @@ google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@@ -44,16 +44,16 @@ const (
// annotation keys
ContainerdImageNameKey = "io.containerd.image.name"
KindAnnotationName = "kind"
KindAnnotationImage = "dev.cosignproject.cosign/image"
KindAnnotationIndex = "dev.cosignproject.cosign/imageIndex"
KindAnnotationSigs = "dev.cosignproject.cosign/sigs"
KindAnnotationAtts = "dev.cosignproject.cosign/atts"
KindAnnotationSboms = "dev.cosignproject.cosign/sboms"
KindAnnotationImage = "dev.hauler/image"
KindAnnotationIndex = "dev.hauler/imageIndex"
KindAnnotationSigs = "dev.hauler/sigs"
KindAnnotationAtts = "dev.hauler/atts"
KindAnnotationSboms = "dev.hauler/sboms"
// KindAnnotationReferrers is the kind prefix for OCI 1.1 referrer manifests (cosign v3
// new-bundle-format). Each referrer gets a unique kind with the referrer manifest digest
// appended (e.g. "dev.cosignproject.cosign/referrers/sha256hex") so multiple referrers
// for the same base image coexist in the OCI index.
KindAnnotationReferrers = "dev.cosignproject.cosign/referrers"
// appended (e.g. "dev.hauler/referrers/sha256hex") so multiple referrers for the same
// base image coexist in the OCI index.
KindAnnotationReferrers = "dev.hauler/referrers"
// Sigstore / OCI 1.1 artifact media types used by cosign v3 new-bundle-format.
SigstoreBundleMediaType = "application/vnd.dev.sigstore.bundle.v0.3+json"

19
pkg/consts/migrate.go Normal file
View File

@@ -0,0 +1,19 @@
package consts
import "strings"
// NormalizeLegacyKind translates old dev.cosignproject.cosign kind annotation
// values to their dev.hauler equivalents. Returns the input unchanged if it is
// already a current value or empty.
//
// This handles all cases including the dynamic referrer suffix:
//
// dev.cosignproject.cosign/referrers/<sha256hex> → dev.hauler/referrers/<sha256hex>
func NormalizeLegacyKind(kind string) string {
const oldPrefix = "dev.cosignproject.cosign"
const newPrefix = "dev.hauler"
if strings.HasPrefix(kind, oldPrefix) {
return newPrefix + kind[len(oldPrefix):]
}
return kind
}

View File

@@ -0,0 +1,30 @@
package consts
import "testing"
func TestNormalizeLegacyKind(t *testing.T) {
tests := []struct {
input string
want string
}{
// Old dev.cosignproject.cosign values → new dev.hauler equivalents
{"dev.cosignproject.cosign/image", "dev.hauler/image"},
{"dev.cosignproject.cosign/imageIndex", "dev.hauler/imageIndex"},
{"dev.cosignproject.cosign/sigs", "dev.hauler/sigs"},
{"dev.cosignproject.cosign/atts", "dev.hauler/atts"},
{"dev.cosignproject.cosign/sboms", "dev.hauler/sboms"},
{"dev.cosignproject.cosign/referrers/abc123def456", "dev.hauler/referrers/abc123def456"},
// Already-new values pass through unchanged
{"dev.hauler/image", "dev.hauler/image"},
{"dev.hauler/imageIndex", "dev.hauler/imageIndex"},
{"dev.hauler/referrers/abc123", "dev.hauler/referrers/abc123"},
// Empty string passes through unchanged
{"", ""},
}
for _, tt := range tests {
got := NormalizeLegacyKind(tt.input)
if got != tt.want {
t.Errorf("NormalizeLegacyKind(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"sort"
@@ -91,12 +92,21 @@ func (o *OCI) LoadIndex() error {
continue
}
// Set default kind if missing
// Set default kind if missing; normalize legacy dev.cosignproject.cosign values.
kind := desc.Annotations[consts.KindAnnotationName]
kind = consts.NormalizeLegacyKind(kind)
if kind == "" {
kind = consts.KindAnnotationImage
}
// Write the normalized kind back into a copy of the annotations map so
// that Walk() callers receive descriptors with dev.hauler/... values.
// We copy the map to avoid mutating the slice element's shared map.
normalized := make(map[string]string, len(desc.Annotations)+1)
maps.Copy(normalized, desc.Annotations)
normalized[consts.KindAnnotationName] = kind
desc.Annotations = normalized
if strings.TrimSpace(key.String()) != "--" {
switch key.(type) {
case name.Digest:
@@ -130,7 +140,7 @@ func (o *OCI) SaveIndex() error {
kindI := descs[i].Annotations["kind"]
kindJ := descs[j].Annotations["kind"]
// Objects with the prefix of "dev.cosignproject.cosign/image" should be at the top.
// Objects with the prefix of KindAnnotationImage should be at the top.
if strings.HasPrefix(kindI, consts.KindAnnotationImage) && !strings.HasPrefix(kindJ, consts.KindAnnotationImage) {
return true
} else if !strings.HasPrefix(kindI, consts.KindAnnotationImage) && strings.HasPrefix(kindJ, consts.KindAnnotationImage) {
@@ -299,11 +309,18 @@ func (p *ociPusher) Push(ctx context.Context, d ocispec.Descriptor) (ccontent.Wr
if err := p.oci.LoadIndex(); err != nil {
return nil, err
}
// Use compound key format: "reference-kind"
// Use compound key format: "reference-kind"; normalize legacy values.
kind := d.Annotations[consts.KindAnnotationName]
kind = consts.NormalizeLegacyKind(kind)
if kind == "" {
kind = consts.KindAnnotationImage
}
// Copy annotations map to avoid mutating the caller's descriptor,
// then write the normalized kind so Walk() callers see dev.hauler/... values.
normalizedAnnotations := make(map[string]string, len(d.Annotations)+1)
maps.Copy(normalizedAnnotations, d.Annotations)
normalizedAnnotations[consts.KindAnnotationName] = kind
d.Annotations = normalizedAnnotations
key := fmt.Sprintf("%s-%s", p.ref, kind)
p.oci.nameMap.Store(key, d)
if err := p.oci.SaveIndex(); err != nil {

281
pkg/content/oci_test.go Normal file
View File

@@ -0,0 +1,281 @@
package content
// oci_test.go covers the annotation-normalization correctness of LoadIndex()
// and ociPusher.Push(). Specifically, it verifies that descriptors returned
// by Walk() carry the normalized dev.hauler/... kind annotation value, not the
// legacy dev.cosignproject.cosign/... value that may be present on disk.
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"hauler.dev/go/hauler/pkg/consts"
)
// buildMinimalOCILayout writes the smallest valid OCI layout (oci-layout marker
// + index.json with the supplied descriptors) into dir. No blobs are written;
// this is sufficient for testing LoadIndex/Walk without a full store.
func buildMinimalOCILayout(t *testing.T, dir string, manifests []ocispec.Descriptor) {
t.Helper()
// oci-layout marker
layoutMarker := map[string]string{"imageLayoutVersion": "1.0.0"}
markerData, err := json.Marshal(layoutMarker)
if err != nil {
t.Fatalf("marshal oci-layout: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ocispec.ImageLayoutFile), markerData, 0644); err != nil {
t.Fatalf("write oci-layout: %v", err)
}
// index.json
idx := ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: manifests,
}
data, err := json.MarshalIndent(idx, "", " ")
if err != nil {
t.Fatalf("marshal index.json: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ocispec.ImageIndexFile), data, 0644); err != nil {
t.Fatalf("write index.json: %v", err)
}
}
// fakeDigest returns a syntactically valid digest string that can be used in
// test descriptors without any real blob.
func fakeDigest(hex string) digest.Digest {
// pad hex to 64 chars
for len(hex) < 64 {
hex += "0"
}
return digest.Digest("sha256:" + hex)
}
// --------------------------------------------------------------------------
// TestLoadIndex_NormalizesLegacyKindInDescriptorAnnotations
// --------------------------------------------------------------------------
// TestLoadIndex_NormalizesLegacyKindInDescriptorAnnotations verifies that
// after LoadIndex() (called implicitly by Walk()), every descriptor returned
// by Walk carries a normalized dev.hauler/... kind annotation, not the legacy
// dev.cosignproject.cosign/... value stored on disk.
func TestLoadIndex_NormalizesLegacyKindInDescriptorAnnotations(t *testing.T) {
dir := t.TempDir()
legacyKinds := []string{
"dev.cosignproject.cosign/image",
"dev.cosignproject.cosign/imageIndex",
"dev.cosignproject.cosign/sigs",
"dev.cosignproject.cosign/atts",
"dev.cosignproject.cosign/sboms",
}
var manifests []ocispec.Descriptor
for i, legacyKind := range legacyKinds {
d := ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageManifest,
Digest: fakeDigest(strings.Repeat(string(rune('a'+i)), 1)),
Size: 100,
Annotations: map[string]string{
ocispec.AnnotationRefName: "example.com/repo:tag" + strings.Repeat(string(rune('a'+i)), 1),
consts.KindAnnotationName: legacyKind,
},
}
manifests = append(manifests, d)
}
buildMinimalOCILayout(t, dir, manifests)
o, err := NewOCI(dir)
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
var walked []ocispec.Descriptor
if err := o.Walk(func(_ string, desc ocispec.Descriptor) error {
walked = append(walked, desc)
return nil
}); err != nil {
t.Fatalf("Walk: %v", err)
}
if len(walked) == 0 {
t.Fatal("Walk returned no descriptors")
}
const legacyPrefix = "dev.cosignproject.cosign"
const newPrefix = "dev.hauler"
for _, desc := range walked {
kind := desc.Annotations[consts.KindAnnotationName]
if strings.HasPrefix(kind, legacyPrefix) {
t.Errorf("descriptor %s: Walk returned legacy kind %q; want normalized dev.hauler/... value",
desc.Digest, kind)
}
if !strings.HasPrefix(kind, newPrefix) {
t.Errorf("descriptor %s: Walk returned unexpected kind %q; want dev.hauler/... prefix",
desc.Digest, kind)
}
}
}
// --------------------------------------------------------------------------
// TestLoadIndex_DoesNotMutateOnDiskAnnotations
// --------------------------------------------------------------------------
// TestLoadIndex_DoesNotMutateOnDiskAnnotations verifies that the normalization
// performed by LoadIndex() is in-memory only: the index.json on disk must
// still carry the original (legacy) annotation values after a Walk() call.
func TestLoadIndex_DoesNotMutateOnDiskAnnotations(t *testing.T) {
dir := t.TempDir()
legacyKind := "dev.cosignproject.cosign/image"
manifests := []ocispec.Descriptor{
{
MediaType: ocispec.MediaTypeImageManifest,
Digest: fakeDigest("b"),
Size: 100,
Annotations: map[string]string{
ocispec.AnnotationRefName: "example.com/repo:tagb",
consts.KindAnnotationName: legacyKind,
},
},
}
buildMinimalOCILayout(t, dir, manifests)
o, err := NewOCI(dir)
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
// Trigger LoadIndex via Walk.
if err := o.Walk(func(_ string, _ ocispec.Descriptor) error { return nil }); err != nil {
t.Fatalf("Walk: %v", err)
}
// Re-read index.json from disk and verify the annotation is unchanged.
data, err := os.ReadFile(filepath.Join(dir, ocispec.ImageIndexFile))
if err != nil {
t.Fatalf("read index.json: %v", err)
}
var idx ocispec.Index
if err := json.Unmarshal(data, &idx); err != nil {
t.Fatalf("unmarshal index.json: %v", err)
}
for _, desc := range idx.Manifests {
got := desc.Annotations[consts.KindAnnotationName]
if got != legacyKind {
t.Errorf("on-disk kind was mutated: got %q, want %q", got, legacyKind)
}
}
}
// --------------------------------------------------------------------------
// TestPush_NormalizesLegacyKindInStoredDescriptor
// --------------------------------------------------------------------------
// TestPush_NormalizesLegacyKindInStoredDescriptor verifies that after a Push()
// that matches the root digest, the descriptor stored in nameMap (and therefore
// returned by subsequent Walk() calls) carries the normalized dev.hauler/...
// kind annotation rather than the legacy value.
func TestPush_NormalizesLegacyKindInStoredDescriptor(t *testing.T) {
dir := t.TempDir()
buildMinimalOCILayout(t, dir, nil) // start with empty index
o, err := NewOCI(dir)
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
// Build a minimal manifest blob so Push() can write it to disk.
manifest := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageConfig,
Digest: fakeDigest("config0"),
Size: 2,
},
}
manifestData, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
manifestDigest := digest.FromBytes(manifestData)
// Ensure the blobs directory exists so Push can write.
blobsDir := filepath.Join(dir, ocispec.ImageBlobsDir, "sha256")
if err := os.MkdirAll(blobsDir, 0755); err != nil {
t.Fatalf("mkdir blobs: %v", err)
}
legacyKind := "dev.cosignproject.cosign/sigs"
baseRef := "example.com/repo:tagsig"
pusher, err := o.Pusher(context.Background(), baseRef+"@"+manifestDigest.String())
if err != nil {
t.Fatalf("Pusher: %v", err)
}
desc := ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageManifest,
Digest: manifestDigest,
Size: int64(len(manifestData)),
Annotations: map[string]string{
ocispec.AnnotationRefName: baseRef,
consts.KindAnnotationName: legacyKind,
},
}
w, err := pusher.Push(context.Background(), desc)
if err != nil {
t.Fatalf("Push: %v", err)
}
if _, err := w.Write(manifestData); err != nil {
t.Fatalf("Write manifest: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("Close writer: %v", err)
}
// Now Walk and verify the descriptor in nameMap has the normalized kind.
// We need a fresh OCI instance so Walk calls LoadIndex (which reads SaveIndex output).
o2, err := NewOCI(dir)
if err != nil {
t.Fatalf("NewOCI second: %v", err)
}
const legacyPrefix = "dev.cosignproject.cosign"
const newPrefix = "dev.hauler"
var found bool
if err := o2.Walk(func(_ string, d ocispec.Descriptor) error {
found = true
kind := d.Annotations[consts.KindAnnotationName]
if strings.HasPrefix(kind, legacyPrefix) {
t.Errorf("Push stored descriptor with legacy kind %q; want normalized dev.hauler/... value", kind)
}
if !strings.HasPrefix(kind, newPrefix) {
t.Errorf("Push stored descriptor with unexpected kind %q; want dev.hauler/... prefix", kind)
}
return nil
}); err != nil {
t.Fatalf("Walk: %v", err)
}
if !found {
t.Fatal("Walk returned no descriptors after Push")
}
// Also verify the caller's original descriptor map was NOT mutated.
if desc.Annotations[consts.KindAnnotationName] != legacyKind {
t.Errorf("Push mutated caller's descriptor annotations: got %q, want %q",
desc.Annotations[consts.KindAnnotationName], legacyKind)
}
}

View File

@@ -47,6 +47,10 @@ func NewRegistryTarget(host string, opts RegistryOptions) *RegistryTarget {
)
hosts := func(h string) ([]cdocker.RegistryHost, error) {
host, err := cdocker.DefaultHost(h)
if err != nil {
return nil, err
}
scheme := "https"
if opts.PlainHTTP || opts.Insecure {
scheme = "http"
@@ -55,7 +59,7 @@ func NewRegistryTarget(host string, opts RegistryOptions) *RegistryTarget {
Client: http.DefaultClient,
Authorizer: authorizer,
Scheme: scheme,
Host: h,
Host: host,
Path: "/v2",
Capabilities: cdocker.HostCapabilityPull | cdocker.HostCapabilityResolve | cdocker.HostCapabilityPush,
}}, nil