diff --git a/.github/workflows/analyze.yaml b/.github/workflows/analyze.yaml index 5bbb38bc21..ec65d8bb87 100644 --- a/.github/workflows/analyze.yaml +++ b/.github/workflows/analyze.yaml @@ -4,8 +4,6 @@ on: workflow_dispatch: push: branches: ['main'] - pull_request: - branches: ['main'] permissions: {} diff --git a/.github/workflows/boilerplate.yaml b/.github/workflows/boilerplate.yaml index 0eec4789e7..0f640eb392 100644 --- a/.github/workflows/boilerplate.yaml +++ b/.github/workflows/boilerplate.yaml @@ -1,7 +1,7 @@ name: Boilerplate on: - pull_request: + push: branches: ['main'] permissions: {} diff --git a/.github/workflows/donotsubmit.yaml b/.github/workflows/donotsubmit.yaml index d655e97657..61fe22e3f8 100644 --- a/.github/workflows/donotsubmit.yaml +++ b/.github/workflows/donotsubmit.yaml @@ -1,7 +1,7 @@ name: Do Not Submit on: - pull_request: + push: branches: ['main'] permissions: {} diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 1b317c36bb..d6eea26eb2 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1,7 +1,7 @@ name: Basic e2e test on: - pull_request: + push: branches: - 'main' diff --git a/.github/workflows/kind-e2e.yaml b/.github/workflows/kind-e2e.yaml index 271591abee..7c2ed5d1b6 100644 --- a/.github/workflows/kind-e2e.yaml +++ b/.github/workflows/kind-e2e.yaml @@ -2,7 +2,7 @@ name: KinD e2e tests on: workflow_dispatch: # Allow manual runs. - pull_request: + push: branches: - 'main' diff --git a/.github/workflows/modules-integration-test.yaml b/.github/workflows/modules-integration-test.yaml index f03f72f13b..88435ee892 100644 --- a/.github/workflows/modules-integration-test.yaml +++ b/.github/workflows/modules-integration-test.yaml @@ -1,7 +1,7 @@ name: Integration Test on: - pull_request: + push: branches: - 'main' diff --git a/.github/workflows/sbom.yaml b/.github/workflows/sbom.yaml index 8d6a58a321..e008899bd9 100644 --- a/.github/workflows/sbom.yaml +++ b/.github/workflows/sbom.yaml @@ -1,7 +1,7 @@ name: Validate SBOMs on: - pull_request: + push: branches: - 'main' diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index c3c717705c..1ed880818c 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -1,8 +1,8 @@ name: Code Style on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + push: + branches: ['main'] permissions: {} diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 127ceb59c8..445191f224 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -1,8 +1,8 @@ name: Verify on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + push: + branches: ['main'] permissions: {} diff --git a/pkg/util/platform.go b/pkg/util/platform.go new file mode 100644 index 0000000000..1c92b34331 --- /dev/null +++ b/pkg/util/platform.go @@ -0,0 +1,228 @@ +// Copyright 2018 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "fmt" + "runtime" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// Platform represents an OS/architecture combination +type Platform struct { + OS string + Architecture string + Variant string +} + +// String returns the platform as a string in the format "os/arch[/variant]" +func (p Platform) String() string { + if p.Variant != "" { + return fmt.Sprintf("%s/%s/%s", p.OS, p.Architecture, p.Variant) + } + return fmt.Sprintf("%s/%s", p.OS, p.Architecture) +} + +// ParsePlatform parses a platform string into its components +// Supports formats: "os/arch" and "os/arch/variant" +func ParsePlatform(platform string) (*Platform, error) { + if platform == "" { + return nil, fmt.Errorf("platform cannot be empty") + } + + parts := strings.Split(platform, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid platform format: %s (expected os/arch or os/arch/variant)", platform) + } + + if len(parts) > 3 { + return nil, fmt.Errorf("invalid platform format: %s (too many components)", platform) + } + + p := &Platform{ + OS: parts[0], + Architecture: parts[1], + } + + if len(parts) == 3 { + p.Variant = parts[2] + } + + // Validate components + if p.OS == "" { + return nil, fmt.Errorf("platform OS cannot be empty") + } + + if p.Architecture == "" { + return nil, fmt.Errorf("platform architecture cannot be empty") + } + + return p, nil +} + +// ToPlatform converts a v1.Platform to our Platform type +func ToPlatform(vp v1.Platform) Platform { + return Platform{ + OS: vp.OS, + Architecture: vp.Architecture, + Variant: vp.Variant, + } +} + +// ToV1Platform converts our Platform type to v1.Platform +func (p Platform) ToV1Platform() v1.Platform { + return v1.Platform{ + OS: p.OS, + Architecture: p.Architecture, + Variant: p.Variant, + } +} + +// IsValidPlatform checks if a platform string is valid +func IsValidPlatform(platform string) bool { + p, err := ParsePlatform(platform) + if err != nil { + return false + } + + // Check if OS is recognized + if !isValidOS(p.OS) { + return false + } + + // Check if arch is recognized + if !isValidArch(p.Architecture) { + return false + } + + return true +} + +// GetHostPlatform returns the current host platform +func GetHostPlatform() Platform { + return Platform{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + } +} + +// NormalizePlatform normalizes a platform string to lowercase +func NormalizePlatform(platform string) (string, error) { + p, err := ParsePlatform(platform) + if err != nil { + return "", err + } + + p.OS = strings.ToLower(p.OS) + p.Architecture = strings.ToLower(p.Architecture) + p.Variant = strings.ToLower(p.Variant) + + return p.String(), nil +} + +// MatchesPlatform checks if two platforms match, considering variants +func MatchesPlatform(p1, p2 string) (bool, error) { + platform1, err := ParsePlatform(p1) + if err != nil { + return false, fmt.Errorf("invalid platform1: %w", err) + } + + platform2, err := ParsePlatform(p2) + if err != nil { + return false, fmt.Errorf("invalid platform2: %w", err) + } + + // OS and Architecture must match + if !strings.EqualFold(platform1.OS, platform2.OS) { + return false, nil + } + + if !strings.EqualFold(platform1.Architecture, platform2.Architecture) { + return false, nil + } + + // Variants match if both are empty or both are equal + if platform1.Variant == "" && platform2.Variant == "" { + return true, nil + } + + if platform1.Variant != "" && platform2.Variant != "" { + return strings.EqualFold(platform1.Variant, platform2.Variant), nil + } + + // One has variant, one doesn't - not a match + return false, nil +} + +// FilterPlatforms filters a list of platforms keeping only valid ones +func FilterPlatforms(platforms []string) ([]string, []string) { + var valid []string + var invalid []string + + for _, p := range platforms { + if IsValidPlatform(p) { + valid = append(valid, p) + } else { + invalid = append(invalid, p) + } + } + + return valid, invalid +} + +// isValidOS checks if an OS string is recognized +func isValidOS(os string) bool { + validOSes := map[string]bool{ + "linux": true, + "darwin": true, + "windows": true, + "freebsd": true, + "openbsd": true, + "netbsd": true, + "plan9": true, + "solaris": true, + "aix": true, + "android": true, + "ios": true, + "js": true, + "wasip1": true, + } + + return validOSes[strings.ToLower(os)] +} + +// isValidArch checks if an architecture string is recognized +func isValidArch(arch string) bool { + validArches := map[string]bool{ + "386": true, + "amd64": true, + "arm": true, + "arm64": true, + "ppc64": true, + "ppc64le": true, + "mips": true, + "mipsle": true, + "mips64": true, + "mips64le": true, + "s390x": true, + "riscv64": true, + "wasm": true, + "loong64": true, + } + + return validArches[strings.ToLower(arch)] +} diff --git a/pkg/util/tags.go b/pkg/util/tags.go new file mode 100644 index 0000000000..d6e0a3adb1 --- /dev/null +++ b/pkg/util/tags.go @@ -0,0 +1,249 @@ +// Copyright 2018 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + // MaxTagLength is the maximum length for a Docker tag + MaxTagLength = 128 +) + +var ( + // ValidTagPattern matches valid Docker tag characters + // Tags must start with letter/digit, and can contain letters, digits, underscores, periods, and hyphens + ValidTagPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) +) + +// SanitizeTag converts an arbitrary string into a valid Docker tag +// by replacing invalid characters and enforcing length limits +func SanitizeTag(input string) (string, error) { + if input == "" { + return "", fmt.Errorf("tag cannot be empty") + } + + // Replace slashes with dashes (common in branch names like feature/foo) + sanitized := strings.ReplaceAll(input, "/", "-") + + // Replace spaces and other problematic characters with dashes + sanitized = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + case r == '_' || r == '.' || r == '-': + return r + default: + return '-' + } + }, sanitized) + + // Remove leading/trailing dashes, dots, and underscores + sanitized = strings.Trim(sanitized, "-._") + + // Ensure it starts with alphanumeric + if len(sanitized) > 0 { + if !isAlphanumeric(rune(sanitized[0])) { + sanitized = "v" + sanitized + } + } + + // Collapse multiple consecutive dashes/dots/underscores + sanitized = collapseRepeatedChars(sanitized) + + // Enforce maximum length + if len(sanitized) > MaxTagLength { + sanitized = sanitized[:MaxTagLength] + // Ensure we don't end with invalid characters after truncation + sanitized = strings.Trim(sanitized, "-._") + } + + // Final validation + if !IsValidTag(sanitized) { + return "", fmt.Errorf("unable to sanitize tag: %s", input) + } + + return sanitized, nil +} + +// IsValidTag checks if a string is a valid Docker tag +func IsValidTag(tag string) bool { + if tag == "" { + return false + } + + if len(tag) > MaxTagLength { + return false + } + + return ValidTagPattern.MatchString(tag) +} + +// GenerateTagFromRef generates a Docker tag from a Git reference +// Examples: +// - refs/heads/main -> main +// - refs/heads/feature/foo -> feature-foo +// - refs/tags/v1.0.0 -> v1.0.0 +// - refs/pull/123/head -> pr-123 +func GenerateTagFromRef(ref string) (string, error) { + if ref == "" { + return "", fmt.Errorf("ref cannot be empty") + } + + var tag string + + switch { + case strings.HasPrefix(ref, "refs/heads/"): + // Branch reference + tag = strings.TrimPrefix(ref, "refs/heads/") + case strings.HasPrefix(ref, "refs/tags/"): + // Tag reference + tag = strings.TrimPrefix(ref, "refs/tags/") + case strings.HasPrefix(ref, "refs/pull/"): + // Pull request reference (GitHub style) + parts := strings.Split(ref, "/") + if len(parts) >= 3 { + tag = fmt.Sprintf("pr-%s", parts[2]) + } else { + return "", fmt.Errorf("invalid pull request ref format: %s", ref) + } + default: + // Unknown format, try to use as-is + tag = ref + } + + return SanitizeTag(tag) +} + +// TruncateTag truncates a tag to the specified length while maintaining validity +func TruncateTag(tag string, maxLength int) (string, error) { + if maxLength <= 0 { + return "", fmt.Errorf("maxLength must be positive") + } + + if maxLength > MaxTagLength { + maxLength = MaxTagLength + } + + if len(tag) <= maxLength { + if !IsValidTag(tag) { + return "", fmt.Errorf("tag is invalid: %s", tag) + } + return tag, nil + } + + truncated := tag[:maxLength] + // Remove trailing invalid characters + truncated = strings.Trim(truncated, "-._") + + if !IsValidTag(truncated) { + return "", fmt.Errorf("unable to truncate tag while maintaining validity: %s", tag) + } + + return truncated, nil +} + +// NormalizeTag converts a tag to lowercase and sanitizes it +func NormalizeTag(tag string) (string, error) { + if tag == "" { + return "", fmt.Errorf("tag cannot be empty") + } + + // Convert to lowercase + normalized := strings.ToLower(tag) + + // Sanitize + return SanitizeTag(normalized) +} + +// AppendSuffix adds a suffix to a tag while respecting length limits +func AppendSuffix(tag, suffix string) (string, error) { + if tag == "" { + return "", fmt.Errorf("tag cannot be empty") + } + + if suffix == "" { + return tag, nil + } + + // Ensure suffix starts with a valid separator + if !strings.HasPrefix(suffix, "-") && !strings.HasPrefix(suffix, ".") && !strings.HasPrefix(suffix, "_") { + suffix = "-" + suffix + } + + combined := tag + suffix + + // If combined length exceeds max, truncate the original tag + if len(combined) > MaxTagLength { + maxBaseLength := MaxTagLength - len(suffix) + if maxBaseLength <= 0 { + return "", fmt.Errorf("suffix too long: %s", suffix) + } + + truncated, err := TruncateTag(tag, maxBaseLength) + if err != nil { + return "", fmt.Errorf("failed to truncate tag for suffix: %w", err) + } + + combined = truncated + suffix + } + + if !IsValidTag(combined) { + return "", fmt.Errorf("resulting tag is invalid: %s", combined) + } + + return combined, nil +} + +// isAlphanumeric checks if a rune is alphanumeric +func isAlphanumeric(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} + +// collapseRepeatedChars collapses sequences of dashes, dots, and underscores into single characters +func collapseRepeatedChars(s string) string { + var result strings.Builder + var prev rune + + for i, r := range s { + if i == 0 { + result.WriteRune(r) + prev = r + continue + } + + // Skip if both current and previous are special characters + if isSpecialChar(r) && isSpecialChar(prev) { + continue + } + + result.WriteRune(r) + prev = r + } + + return result.String() +} + +// isSpecialChar checks if a character is a special tag character (dash, dot, underscore) +func isSpecialChar(r rune) bool { + return r == '-' || r == '.' || r == '_' +}