Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 19 additions & 78 deletions internal/runtimes/python/python.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,61 +281,6 @@ func (a *PythonAdapter) GetLatestVersion(ctx context.Context, opts runtime.Versi
return filtered[0], nil
}

// findLastVersionWithBinaries finds the last patch version that has Windows/macOS binary installers.
// For security-only releases, Python stops publishing binary installers, so we need to find
// the last version that had them by checking HEAD requests to python.org.
// Returns the version string (e.g., "3.11.9") or empty string if none found.
func (a *PythonAdapter) findLastVersionWithBinaries(ctx context.Context, majorMinor string, latestPatch string) string {
// Parse the patch number from latestPatch (e.g., "3.11.14" -> 14)
parts := strings.Split(latestPatch, ".")
if len(parts) != 3 {
return ""
}

var patchNum int
if _, err := fmt.Sscanf(parts[2], "%d", &patchNum); err != nil {
return ""
}

// Get base URL
baseURL := "https://www.python.org/ftp/python"
if a.config != nil && a.config.Download.BaseURL != "" {
baseURL = a.config.Download.BaseURL
}

// Create HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Second,
}

// Search backwards from latest patch to find last version with Windows binary
for patch := patchNum; patch >= 0; patch-- {
testVersion := fmt.Sprintf("%s.%d", majorMinor, patch)
testURL := fmt.Sprintf("%s/%s/python-%s-amd64.exe", baseURL, testVersion, testVersion)

req, err := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
if err != nil {
continue
}

resp, err := client.Do(req)
if err != nil {
continue
}
resp.Body.Close()

if resp.StatusCode == http.StatusOK {
a.stdout.Info("found last Python version with binaries",
"major_minor", majorMinor,
"version_with_binaries", testVersion,
"latest_patch", latestPatch)
return testVersion
}
}

return ""
}

// CreateDownloadTasks generates download tasks for the specified Python version and platforms.
func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platforms []platform.Platform, outputDir string) ([]runtime.DownloadTask, error) {
// POLICY VALIDATION: Check if the version is supported or under_review before creating download tasks
Expand All @@ -355,22 +300,17 @@ func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platf
platforms = a.GetSupportedPlatforms()
}

// For security-only releases, find the last version that has Windows/macOS binaries
var lastVersionWithBinaries string
// For security-only (EOAS) Python releases, upstream stops publishing the
// Windows .exe and macOS .pkg installers. We deliberately do NOT fall back
// to a previous patch's installer because that produced misleading entries
// in the published index (e.g., python-3.12.10-macos11.pkg listed under
// version 3.12.12). Linux is still built from source for the security
// patch, so only Windows/macOS are skipped.
if version.IsEOAS {
a.stdout.Info("processing security-only Python release",
a.stdout.Info("processing security-only Python release; skipping windows/mac binaries",
"version", version.Version,
"latest_patch", version.LatestPatch,
"is_eoas", version.IsEOAS)

// Find the last version that has Windows/macOS binary installers
lastVersionWithBinaries = a.findLastVersionWithBinaries(context.Background(), version.Version, version.LatestPatch)
if lastVersionWithBinaries != "" {
a.stdout.Info("will use last version with binaries for Windows/macOS",
"version", version.Version,
"binary_version", lastVersionWithBinaries,
"source_version", version.LatestPatch)
}
}

// Fix platform file extensions to match Python's specific requirements
Expand Down Expand Up @@ -409,16 +349,18 @@ func (a *PythonAdapter) CreateDownloadTasks(version endoflife.VersionInfo, platf

// Create tasks for main binary/source files
for _, plat := range fixedPlatforms {
// Determine which version to use for this platform
downloadVersion := version.LatestPatch

// For security-only releases, use last version with binaries for Windows/macOS
if version.IsEOAS && lastVersionWithBinaries != "" {
if plat.OS == "windows" || plat.OS == "mac" {
downloadVersion = lastVersionWithBinaries
}
// Skip Windows/macOS for security-only releases: upstream does not
// publish installers for these patch versions and we will not
// substitute an older patch's installer.
if version.IsEOAS && (plat.OS == "windows" || plat.OS == "mac") {
a.stdout.Info("skipping platform for security-only Python release",
"platform_os", plat.OS,
"version", version.LatestPatch)
continue
}

downloadVersion := version.LatestPatch

url := a.constructDownloadURL(downloadVersion, plat)
if url == "" {
continue // Skip unsupported platform combinations
Expand Down Expand Up @@ -469,9 +411,8 @@ type platformVersion struct {
version string
}

// createVerificationTasksWithVersions creates download tasks for verification files
// using specific versions per platform (needed for security-only releases where
// Windows/macOS use an older version than Linux)
// createVerificationTasksWithVersions creates download tasks for verification
// files using the specific version recorded for each platform's main artifact.
func (a *PythonAdapter) createVerificationTasksWithVersions(platformVersions []platformVersion, outputDir, userAgent string) []runtime.DownloadTask {
var tasks []runtime.DownloadTask

Expand Down
127 changes: 72 additions & 55 deletions internal/sitegen/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,90 +65,107 @@ func LoadReleases(reader ReleaseReader) ([]ReleaseWithArtifacts, error) {
return result, nil
}

// filterArtifactsForVersion filters artifacts to only include those matching the specified version.
// For security-only releases where binaries use older patch versions (e.g., 3.12.10 for version 3.12.12),
// this function matches by major.minor prefix as a fallback.
// filterArtifactsForVersion filters artifacts to only include those whose
// filenames contain an exact match for the specified version.
//
// For aggregated security-only releases, upstream sometimes only ships a binary
// for the latest patch in a minor line (e.g., python.org publishes
// python-3.12.10-macos11.pkg as the macOS installer for the entire 3.12.x line).
// We deliberately do NOT attach such binaries to other advertised patch
// versions (e.g., 3.12.12), because doing so produced misleading entries like
// `mac/python/3.12/3.12.12/python-3.12.10-macos11.pkg` with version "3.12.12".
// If no binary in the release exactly matches the advertised version for a
// platform, that platform is simply omitted from this version's entries.
func filterArtifactsForVersion(artifacts storage.ReleaseArtifacts, version string) storage.ReleaseArtifacts {
filtered := storage.ReleaseArtifacts{
Platforms: []storage.PlatformArtifact{},
CommonFiles: artifacts.CommonFiles, // Keep common files for all versions
Metadata: artifacts.Metadata,
}

// Extract major.minor from version for fallback matching
// e.g., "3.12.12" -> "3.12"
majorMinor := extractMajorMinor(version)

// Filter platform artifacts by version in filename
for _, platform := range artifacts.Platforms {
// Check if any artifact in this platform matches the version
hasMatchingArtifact := false

if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) {
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) {
hasMatchingArtifact = true
}
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) {
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) {
hasMatchingArtifact = true
}
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) {
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) {
hasMatchingArtifact = true
}

if hasMatchingArtifact {
// Create a filtered copy of the platform with only matching artifacts
filteredPlatform := storage.PlatformArtifact{
Platform: platform.Platform,
PlatformOS: platform.PlatformOS,
PlatformArch: platform.PlatformArch,
}
if !hasMatchingArtifact {
continue
}

if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version, majorMinor) {
filteredPlatform.Binary = platform.Binary
}
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version, majorMinor) {
filteredPlatform.Audit = platform.Audit
}
if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version, majorMinor) {
filteredPlatform.Signature = platform.Signature
}
if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version, majorMinor) {
filteredPlatform.Certificate = platform.Certificate
}
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version, majorMinor) {
filteredPlatform.MetadataFile = platform.MetadataFile
}
filteredPlatform := storage.PlatformArtifact{
Platform: platform.Platform,
PlatformOS: platform.PlatformOS,
PlatformArch: platform.PlatformArch,
}

filtered.Platforms = append(filtered.Platforms, filteredPlatform)
if platform.Binary != nil && matchesVersion(platform.Binary.Filename, version) {
filteredPlatform.Binary = platform.Binary
}
if platform.Audit != nil && matchesVersion(platform.Audit.Filename, version) {
filteredPlatform.Audit = platform.Audit
}
if platform.Signature != nil && matchesVersion(platform.Signature.Filename, version) {
filteredPlatform.Signature = platform.Signature
}
if platform.Certificate != nil && matchesVersion(platform.Certificate.Filename, version) {
filteredPlatform.Certificate = platform.Certificate
}
if platform.MetadataFile != nil && matchesVersion(platform.MetadataFile.Filename, version) {
filteredPlatform.MetadataFile = platform.MetadataFile
}

filtered.Platforms = append(filtered.Platforms, filteredPlatform)
}

return filtered
}

// extractMajorMinor extracts the major.minor portion from a semver version.
// e.g., "3.12.12" -> "3.12", "22.15.0" -> "22.15"
func extractMajorMinor(version string) string {
parts := strings.Split(version, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
// matchesVersion reports whether a filename advertises the given exact version.
//
// Matching is intentionally strict: only an exact substring match on the full
// version string counts. We previously fell back to a major.minor prefix
// match, but that caused mac/windows installers from one patch version to be
// re-published under unrelated patch versions in the same security-only
// aggregated release (see filterArtifactsForVersion for context).
//
// To avoid false positives such as filenames containing "3.12.123" matching
// version "3.12.12", the version must be bounded on both sides by either a
// non-digit character or the start/end of the filename.
func matchesVersion(filename, version string) bool {
idx := 0
for {
i := strings.Index(filename[idx:], version)
if i < 0 {
return false
}
start := idx + i
end := start + len(version)
if !isVersionDigit(filename, start-1) && !isVersionDigit(filename, end) {
return true
}
idx = start + 1
if idx >= len(filename) {
return false
}
}
return version
}

// matchesVersion checks if a filename matches the given version.
// First tries exact version match, then falls back to major.minor match.
// This handles security-only releases where binaries use older patch versions.
func matchesVersion(filename, version, majorMinor string) bool {
// First try exact version match
if strings.Contains(filename, version) {
return true
}
// Fallback to major.minor match for security-only releases
// e.g., "python-3.12.10-amd64.exe" matches version "3.12.12" via "3.12"
if strings.Contains(filename, majorMinor+".") {
return true
// isVersionDigit reports whether the byte at position i in s is an ASCII digit.
// Out-of-bounds positions return false (treated as a non-digit boundary).
func isVersionDigit(s string, i int) bool {
if i < 0 || i >= len(s) {
return false
}
return false
c := s[i]
return c >= '0' && c <= '9'
}

// ReleaseWithArtifacts combines a Release with its parsed artifacts.
Expand Down
Loading