package main import ( "bufio" "context" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "sync/atomic" "time" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "gitgud.io/mike/mpv-manager/pkg/releaseprovenance" "lukechampine.com/blake3" ) const releaseFilesDir = "release-files" const ( // userAgent identifies this tool to upstream APIs and CDNs. userAgent = "mpv-manager-generate-info (+https://mpv.rocks)" // maxAttempts is the number of tries for HTTP requests before giving up; // transient failures are retried with a short linear backoff. maxAttempts = 3 // defaultMaxReleaseArtifactBytes bounds every upstream artifact admitted to the // release cache. Current reviewed assets are far below one GiB. defaultMaxReleaseArtifactBytes int64 = 1 << 30 ) // GitGud.io API constants const ( gitGudProjectID = "45219" gitGudBaseURL = "https://gitgud.io/api/v4" gitGudProjectURL = "https://gitgud.io/mike/mpv-manager" ) // Command-line flags var ( autoMode = flag.Bool("auto", false, "Run in non-interactive mode (use fetched defaults, exit on errors)") managerVersion = flag.String("manager-version", "", "MPV Manager version to use (required in auto mode)") outputFile = flag.String("output", "releases.json", "Output file path") quiet = flag.Bool("quiet", false, "Suppress progress output (useful for cron)") repoStats = flag.Bool("repo-stats", false, "Generate repo-stats.json instead of releases.json") statsOutput = flag.String("stats-output", "", "Output path for repo-stats.json (default: same directory as releases.json)") apiToken = flag.String("token", "", "Project Access Token for authenticated GitLab API requests (optional; GITLAB_TOKEN env var takes precedence)") provenanceLock = flag.String("provenance-lock", "", "Reviewed tag-bound upstream provenance lock (required in auto release mode)") managerDownloadBase = flag.String("manager-download-base", "", "Optional immutable HTTPS directory for manager artifacts (defaults to tag-scoped GitLab packages)") managerFiles = flag.String("manager-artifacts-dir", "", "Directory containing pipeline-local raw manager artifacts (required in auto release mode)") releaseChannel = flag.String("channel", "", "Feed channel: stable or rc (default: infer from manager version); rc may also carry a final release") signManifest = flag.Bool("sign", false, "Sign locally from MANIFEST_SIGNING_KEY_ID/MANIFEST_SIGNING_KEY (never used by application release CI)") ) const ( manifestSigningKeyEnv = "MANIFEST_SIGNING_KEY" manifestSigningKeyIDEnv = "MANIFEST_SIGNING_KEY_ID" ) // Shared HTTP clients. apiClient bounds the whole request; downloadClient // only bounds connection setup and response headers so large files can // stream without hitting an overall deadline. var ( apiClient = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, Timeout: 60 * time.Second, } downloadClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 15 * time.Second}).DialContext, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second, }, Timeout: 20 * time.Minute, } downloadTotalTimeout = 20 * time.Minute downloadIdleTimeout = 30 * time.Second downloadRateWindow = 30 * time.Second downloadMinimumWindowBytes int64 = 30 << 10 maxReleaseArtifactBytes = defaultMaxReleaseArtifactBytes ) // retrySleep pauses between retry attempts (linear backoff, as in // pkg/version); it is a variable so tests can disable the delays. var retrySleep = func(attempt int) { time.Sleep(time.Duration(attempt) * time.Second) } // Base URLs are variables so tests can point the fetchers at httptest servers. var ( githubAPIBaseURL = "https://api.github.com" gitGudAPIBaseURL = gitGudBaseURL ) // gitlabToken returns the GitLab API token: the GITLAB_TOKEN environment // variable takes precedence over the -token flag (kept for compatibility). // Prefer the env var so the token stays out of shell history and ps output. func gitlabToken() string { if token := os.Getenv("GITLAB_TOKEN"); token != "" { return token } return *apiToken } // githubToken returns the GitHub API token from GITHUB_TOKEN, if set. // Authenticated requests get a much higher rate limit (5000 vs 60 req/h). func githubToken() string { return os.Getenv("GITHUB_TOKEN") } // newGetRequest builds a GET request with the tool's User-Agent and an // optional bearer token. func newGetRequest(url, token string) (*http.Request, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", userAgent) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return req, nil } // getWithRetry performs an HTTP GET, retrying transient failures (network // errors and HTTP 5xx) with a short linear backoff, mirroring the retry // style in pkg/version. A non-5xx response is returned as-is for the // caller to inspect. func getWithRetry(client *http.Client, url, token string) (*http.Response, error) { var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { req, err := newGetRequest(url, token) if err != nil { return nil, err } resp, err := client.Do(req) if err == nil && resp.StatusCode < 500 { return resp, nil } if err == nil { lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) resp.Body.Close() } else { lastErr = err } if attempt < maxAttempts { print(" Attempt %d/%d failed (%v), retrying...\n", attempt, maxAttempts, lastErr) retrySleep(attempt) } } return nil, lastErr } // GitHubRelease represents the response from GitHub's releases API type GitHubRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` HTMLURL string `json:"html_url"` Assets []struct { Name string `json:"name"` URL string `json:"browser_download_url"` } `json:"assets"` } // GitLabRelease represents a GitLab release API response type GitLabRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` Description string `json:"description"` ReleasedAt string `json:"released_at"` } type usedFile struct { url string filename string } var allUsedFiles []usedFile var ( activeProvenance *releaseprovenance.Lock usedProvenanceAssets = make(map[string]bool) ) // Release is shared with the production decoder so generator and consumer // cannot silently drift. type Release = releasemanifest.Manifest // GitLab API response types for repository statistics // GitLabProject represents the main project data from GitLab API type GitLabProject struct { StarCount int `json:"star_count"` ForksCount int `json:"forks_count"` LastActivityAt string `json:"last_activity_at"` CreatedAt string `json:"created_at"` License *GitLabLicense `json:"license"` Topics []string `json:"topics"` Description string `json:"description"` WebURL string `json:"web_url"` DefaultBranch string `json:"default_branch"` Visibility string `json:"visibility"` } // GitLabLicense represents license information from GitLab API type GitLabLicense struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` HTMLURL string `json:"html_url"` } // GitLabIssuesStats represents the issues statistics response from GitLab API type GitLabIssuesStats struct { Statistics struct { Counts struct { Opened int `json:"opened"` } `json:"counts"` } `json:"statistics"` } // GitLabPipeline represents a CI/CD pipeline from GitLab API type GitLabPipeline struct { Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Ref string `json:"ref"` SHA string `json:"sha"` } // RepoStatsLicense represents the license in the output JSON format type RepoStatsLicense struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` } // RepoStatsPipeline represents CI/CD pipeline info in output type RepoStatsPipeline struct { Status string `json:"status"` Ref string `json:"ref"` SHA string `json:"sha"` } // RepoStats is the combined output structure for repo-stats.json type RepoStats struct { StarCount int `json:"star_count"` ForksCount int `json:"forks_count"` OpenIssuesCount int `json:"open_issues_count"` CIStatus int `json:"ci_status"` CIPipeline *RepoStatsPipeline `json:"ci_pipeline,omitempty"` LastActivityAt string `json:"last_activity_at"` CreatedAt string `json:"created_at"` License *RepoStatsLicense `json:"license"` Topics []string `json:"topics"` Description string `json:"description"` WebURL string `json:"web_url"` DefaultBranch string `json:"default_branch"` Visibility string `json:"visibility"` FetchedAt string `json:"fetched_at"` } // fetchLatestRelease fetches the latest release from a GitHub repository func fetchLatestRelease(owner, repo string) (*GitHubRelease, error) { apiURL := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBaseURL, owner, repo) resp, err := getWithRetry(apiClient, apiURL, githubToken()) if err != nil { return nil, fmt.Errorf("failed to fetch release: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var release GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &release, nil } // fetchLatestGitLabRelease fetches the latest release from gitgud.io func fetchLatestGitLabRelease(projectPath string) (*GitLabRelease, error) { // URL encode the project path: mike/mpv-manager -> mike%2Fmpv-manager encodedPath := url.PathEscape(projectPath) apiURL := fmt.Sprintf("%s/projects/%s/releases", gitGudAPIBaseURL, encodedPath) resp, err := getWithRetry(apiClient, apiURL, gitlabToken()) if err != nil { return nil, fmt.Errorf("failed to fetch from GitLab API: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitLab API returned status %d", resp.StatusCode) } var releases []GitLabRelease if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if len(releases) == 0 { return nil, fmt.Errorf("no releases found for project %s", projectPath) } return &releases[0], nil } // extractVersionFromTag removes 'v' prefix from tag names func extractVersionFromTag(tag string) string { return strings.TrimPrefix(tag, "v") } // parseWindowsBuildInfo extracts timestamp and hashes from Windows build release // zhongfly/mpv-winbuild tag format: "2026-04-09-ec4d50f" (date with dashes + MPV commit hash) // MPV filename: mpv-x86_64-20260409-git-ec4d50f.7z (date without dashes) // FFmpeg hash is extracted from release asset filenames func parseWindowsBuildInfo(release *GitHubRelease) (fullTag, timestamp, mpvHash, ffmpegHash string) { if release == nil { return "", "", "", "" } // Parse the tag: "2026-04-09-ec4d50f" → fullTag=tag, date="20260409", mpvHash="ec4d50f" fullTag = release.TagName tag := release.TagName // Extract MPV hash from the tag (last segment after the date) // Tag format: YYYY-MM-DD-HASH tagParts := strings.Split(tag, "-") if len(tagParts) >= 4 { // Date parts: tagParts[0]="2026", tagParts[1]="04", tagParts[2]="09" // Hash part: tagParts[3] (and potentially more if hash has dashes, but it shouldn't) timestamp = tagParts[0] + tagParts[1] + tagParts[2] // "20260409" mpvHash = strings.Join(tagParts[3:], "-") // "ec4d50f" } else { // Fallback: use raw tag as timestamp (for backward compatibility) timestamp = tag } // FFmpeg pattern: ffmpeg-x86_64-git-d3d0b7a5e.7z ffmpegRe := regexp.MustCompile(`ffmpeg-x86_64-git-([a-f0-9]+)\.7z$`) for _, asset := range release.Assets { if ffmpegHash == "" { if matches := ffmpegRe.FindStringSubmatch(asset.URL); len(matches) > 1 { ffmpegHash = matches[1] } } if ffmpegHash != "" { break } } return fullTag, timestamp, mpvHash, ffmpegHash } // fetchAllVersions fetches latest versions from all GitHub repositories. // In auto (unattended) mode any fetch failure is reported via the returned // error so stale fallbacks are never published; in interactive mode the // fallbacks are used as prompt defaults instead. func fetchAllVersions() (map[string]string, string, string, error) { versions := make(map[string]string) var winTimestamp string var fetchErr error recordErr := func(name string, err error) { if *autoMode && fetchErr == nil { fetchErr = fmt.Errorf("failed to fetch %s version: %w", name, err) } } println("\n--- Fetching Latest Versions from GitHub ---") // MPV print(" MPV... ") if release, err := fetchLatestRelease("mpv-player", "mpv"); err != nil { print("ERROR: %v\n", err) recordErr("MPV", err) versions["mpv"] = "0.41.0" } else { versions["mpv"] = extractVersionFromTag(release.TagName) println(versions["mpv"]) } // uOSC print(" uOSC... ") if release, err := fetchLatestRelease("tomasklaen", "uosc"); err != nil { print("ERROR: %v\n", err) recordErr("uOSC", err) versions["uosc"] = "5.12.0" } else { versions["uosc"] = release.TagName // uOSC uses tags without 'v' prefix println(versions["uosc"]) } // ModernZ print(" ModernZ... ") if release, err := fetchLatestRelease("Samillion", "ModernZ"); err != nil { print("ERROR: %v\n", err) recordErr("ModernZ", err) versions["modernz"] = "0.3.3" } else { versions["modernz"] = extractVersionFromTag(release.TagName) println(versions["modernz"]) } // MPC-QT print(" MPC-QT... ") if release, err := fetchLatestRelease("mpc-qt", "mpc-qt"); err != nil { print("ERROR: %v\n", err) recordErr("MPC-QT", err) versions["mpcqt"] = "26.07" } else { versions["mpcqt"] = extractVersionFromTag(release.TagName) println(versions["mpcqt"]) } // IINA print(" IINA... ") if release, err := fetchLatestRelease("iina", "iina"); err != nil { print("ERROR: %v\n", err) recordErr("IINA", err) versions["iina"] = "1.4.4" } else { versions["iina"] = extractVersionFromTag(release.TagName) println(versions["iina"]) } // Windows builds (zhongfly/mpv-winbuild) print(" Windows builds... ") var winMpvHash, winFFmpegHash, winFullTag string if release, err := fetchLatestRelease("zhongfly", "mpv-winbuild"); err != nil { print("ERROR: %v\n", err) recordErr("Windows builds", err) winTimestamp = time.Now().Format("20060102") winFullTag = winTimestamp winMpvHash = "unknown" winFFmpegHash = "unknown" } else { winFullTag, winTimestamp, winMpvHash, winFFmpegHash = parseWindowsBuildInfo(release) print("%s (mpv: %s, ffmpeg: %s)\n", winTimestamp, winMpvHash, winFFmpegHash) } versions["win_mpv_hash"] = winMpvHash versions["win_ffmpeg_hash"] = winFFmpegHash versions["win_full_tag"] = winFullTag return versions, winTimestamp, winMpvHash, fetchErr } func extractFilenameFromURL(url string) string { parts := strings.Split(url, "/") return parts[len(parts)-1] } func getLocalFilePath(url string) string { filename := extractFilenameFromURL(url) return filepath.Join(releaseFilesDir, filename) } func managerArtifactPath(url string) string { if strings.TrimSpace(*managerFiles) != "" { return filepath.Join(*managerFiles, extractFilenameFromURL(url)) } return getLocalFilePath(url) } func managerAsset(url, hash, goos, goarch, cpuBaseline, nativeSigning string) (releasemanifest.Asset, error) { info, err := os.Stat(managerArtifactPath(url)) if err != nil { return releasemanifest.Asset{}, fmt.Errorf("stat manager artifact for %s/%s: %w", goos, goarch, err) } if info.Size() <= 0 { return releasemanifest.Asset{}, fmt.Errorf("manager artifact for %s/%s is empty", goos, goarch) } return releasemanifest.Asset{ GOOS: goos, GOARCH: goarch, CPUBaseline: cpuBaseline, Format: releasemanifest.ManagerAssetFormat, URL: url, BLAKE3: hash, Size: info.Size(), InstallScope: releasemanifest.ManagerInstallScope, UpdateStrategy: releasemanifest.ManagerUpdateStrategy, NativeSigning: nativeSigning, ExpectedProduct: "mpv-manager", ExpectedComponent: releasemanifest.ManagerPortableComponentID, }, nil } func populateManagerComponent(release *Release) error { assetInputs := []struct { url, hash, goos, goarch, cpuBaseline, nativeSigning string }{ {release.Manager.LinuxAMD64.URL, release.Manager.LinuxAMD64.BLAKE3, "linux", "amd64", "x86-64-v2", ""}, {release.Manager.LinuxARM64.URL, release.Manager.LinuxARM64.BLAKE3, "linux", "arm64", "arm64", ""}, // Native signing metadata must describe the artifact that was actually // produced. v1.3 raw binaries are authenticated by this signed manifest; // Authenticode and Apple signing/notarization remain separate release // gates and can populate these fields once their CI jobs exist. {release.Manager.WinX86_64.URL, release.Manager.WinX86_64.BLAKE3, "windows", "amd64", "x86-64-v2", ""}, {release.Manager.WinARM64.URL, release.Manager.WinARM64.BLAKE3, "windows", "arm64", "arm64", ""}, {release.Manager.MacosIntel.URL, release.Manager.MacosIntel.BLAKE3, "darwin", "amd64", "x86-64-v2", ""}, {release.Manager.MacosARM.URL, release.Manager.MacosARM.BLAKE3, "darwin", "arm64", "arm64", ""}, } assets := make([]releasemanifest.Asset, 0, len(assetInputs)) for _, input := range assetInputs { asset, err := managerAsset(input.url, input.hash, input.goos, input.goarch, input.cpuBaseline, input.nativeSigning) if err != nil { return err } assets = append(assets, asset) } release.Components = map[string]releasemanifest.Component{ releasemanifest.ManagerPortableComponentID: { Version: release.Version, Assets: assets, }, } return nil } func signRelease(release *Release) error { keyID := strings.TrimSpace(os.Getenv(manifestSigningKeyIDEnv)) encodedKey := strings.TrimSpace(os.Getenv(manifestSigningKeyEnv)) if keyID == "" || encodedKey == "" { return fmt.Errorf("%s and %s are required", manifestSigningKeyIDEnv, manifestSigningKeyEnv) } privateKey, err := releasemanifest.ParsePrivateKey(encodedKey) if err != nil { return err } return release.Sign(keyID, privateKey) } // getCachedURL reads the URL from a .url sidecar file for a cached file func getCachedURL(localPath string) string { urlFile := localPath + ".url" data, err := os.ReadFile(urlFile) if err != nil { return "" } return strings.TrimSpace(string(data)) } // setCachedURL writes the URL to a .url sidecar file for a cached file func setCachedURL(localPath, url string) error { urlFile := localPath + ".url" return os.WriteFile(urlFile, []byte(url), 0644) } func downloadFileIfNotExists(url string) (string, error) { localPath := getLocalFilePath(url) // The lock retains the original URL as provenance, while a reviewed // digest-addressed mirror may be the only remaining source of those bytes. // Keep cache identity tied to the original URL so offline reviewed caches // remain usable; computeHashForURL still authenticates the resulting bytes. downloadURL := url if pinned, ok := activeProvenance.ArtifactForURL(url); ok && pinned.DownloadURL != "" { downloadURL = pinned.DownloadURL } // Check if file exists AND the URL matches (to handle version changes) if _, err := os.Stat(localPath); err == nil { cachedURL := getCachedURL(localPath) if cachedURL == url { print(" Using cached file: %s\n", extractFilenameFromURL(url)) return localPath, nil } // URL changed (e.g., new version), need to re-download print(" Cache outdated (URL changed), re-downloading: %s\n", extractFilenameFromURL(url)) os.Remove(localPath) os.Remove(localPath + ".url") } print(" Downloading: %s\n", url) print(" Saving to: %s\n", extractFilenameFromURL(url)) if err := os.MkdirAll(releaseFilesDir, 0755); err != nil { return "", fmt.Errorf("failed to create directory: %w", err) } // Retry transient failures (network errors, HTTP 5xx, interrupted // transfers) with a short linear backoff, as in pkg/version. var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { lastErr = downloadToFile(downloadURL, localPath) if lastErr == nil { break } if attempt < maxAttempts { print(" Attempt %d/%d failed (%v), retrying...\n", attempt, maxAttempts, lastErr) retrySleep(attempt) } } if lastErr != nil { return "", lastErr } // Save the URL to sidecar file for cache validation if err := setCachedURL(localPath, url); err != nil { print(" Warning: Failed to save cache metadata: %v\n", err) } return localPath, nil } // downloadToFile performs a single download attempt: it streams url to a // temp file and renames it into place, so a failure never leaves a partial // file at localPath. func downloadToFile(url, localPath string) error { req, err := newGetRequest(url, "") if err != nil { return err } ctx, cancel := context.WithTimeout(req.Context(), downloadTotalTimeout) defer cancel() req = req.WithContext(ctx) resp, err := downloadClient.Do(req) if err != nil { return fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } if resp.ContentLength > maxReleaseArtifactBytes { return fmt.Errorf("download is %d bytes; limit is %d", resp.ContentLength, maxReleaseArtifactBytes) } tmpPath := localPath + ".tmp" out, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } written, copyErr := copyDownloadWithLimits(ctx, out, resp.Body) closeErr := out.Close() if copyErr == nil && written > maxReleaseArtifactBytes { copyErr = fmt.Errorf("download exceeded %d-byte limit", maxReleaseArtifactBytes) } if copyErr != nil || closeErr != nil { os.Remove(tmpPath) if copyErr != nil { return fmt.Errorf("failed to save download: %w", copyErr) } return fmt.Errorf("failed to save download: %w", closeErr) } if err := os.Rename(tmpPath, localPath); err != nil { os.Remove(tmpPath) return fmt.Errorf("failed to finalize download: %w", err) } return nil } type downloadCopyResult struct { written int64 err error } type downloadProgressWriter struct { destination io.Writer total *atomic.Int64 activity chan<- struct{} } func (writer downloadProgressWriter) Write(data []byte) (int, error) { written, err := writer.destination.Write(data) writer.total.Add(int64(written)) select { case writer.activity <- struct{}{}: default: } return written, err } // copyDownloadWithLimits enforces body-idle and sustained-low-speed bounds in // addition to the request's total context deadline. Closing an HTTP response // body interrupts an in-flight read, allowing the copy worker to join. func copyDownloadWithLimits(ctx context.Context, destination io.Writer, body io.ReadCloser) (int64, error) { activity := make(chan struct{}, 1) result := make(chan downloadCopyResult, 1) var total atomic.Int64 go func() { written, err := io.CopyBuffer( downloadProgressWriter{destination: destination, total: &total, activity: activity}, io.LimitReader(body, maxReleaseArtifactBytes+1), make([]byte, 64<<10), ) result <- downloadCopyResult{written: written, err: err} }() idleTimer := time.NewTimer(downloadIdleTimeout) defer idleTimer.Stop() rateTicker := time.NewTicker(downloadRateWindow) defer rateTicker.Stop() lastWindowTotal := int64(0) abort := func(reason error) (int64, error) { _ = body.Close() copyResult := <-result return copyResult.written, reason } for { select { case copyResult := <-result: return copyResult.written, copyResult.err case <-activity: if !idleTimer.Stop() { select { case <-idleTimer.C: default: } } idleTimer.Reset(downloadIdleTimeout) case <-idleTimer.C: return abort(fmt.Errorf("download made no progress for %s", downloadIdleTimeout)) case <-rateTicker.C: currentTotal := total.Load() if currentTotal-lastWindowTotal < downloadMinimumWindowBytes { return abort(fmt.Errorf("download transferred fewer than %d bytes in %s", downloadMinimumWindowBytes, downloadRateWindow)) } lastWindowTotal = currentTotal case <-ctx.Done(): return abort(fmt.Errorf("download deadline exceeded or cancelled: %w", ctx.Err())) } } } func cleanupUnusedFiles(usedFiles []usedFile) { entries, err := os.ReadDir(releaseFilesDir) if err != nil { if os.IsNotExist(err) { return } if !*quiet { fmt.Printf("Error reading release-files directory: %v\n", err) } return } if len(entries) == 0 { if !*quiet { fmt.Println("\nNo files in release-files directory to clean up.") } return } usedFilenames := make(map[string]bool) for _, file := range usedFiles { usedFilenames[file.filename] = true } var unusedFiles []string for _, entry := range entries { if entry.IsDir() { continue } // Skip .url sidecar files - they're cleaned up with their main file if strings.HasSuffix(entry.Name(), ".url") { continue } if !usedFilenames[entry.Name()] { unusedFiles = append(unusedFiles, entry.Name()) } } if len(unusedFiles) == 0 { if !*quiet { fmt.Println("\nAll files in release-files are in use.") } return } // In auto mode, remove unused files silently if *autoMode { for _, file := range unusedFiles { filePath := filepath.Join(releaseFilesDir, file) os.Remove(filePath) // Also remove .url sidecar file if it exists os.Remove(filePath + ".url") } return } // Interactive mode - show files and ask fmt.Printf("\nFound %d unused file(s) in release-files:\n", len(unusedFiles)) for _, file := range unusedFiles { fmt.Printf(" - %s\n", file) } fmt.Printf("\nRemove unused files? [Y/n]: ") reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { return } input = strings.TrimSpace(input) if strings.ToLower(input) == "n" { fmt.Println("Keeping unused files.") return } for _, file := range unusedFiles { filePath := filepath.Join(releaseFilesDir, file) if err := os.Remove(filePath); err != nil { fmt.Printf("Error removing %s: %v\n", file, err) } else { fmt.Printf("Removed: %s\n", file) } // Also remove .url sidecar file if it exists os.Remove(filePath + ".url") } fmt.Println("Cleanup complete.") } // print outputs text unless quiet mode is enabled func print(format string, args ...interface{}) { if !*quiet { fmt.Printf(format, args...) } } // println outputs a line unless quiet mode is enabled func println(args ...interface{}) { if !*quiet { fmt.Println(args...) } } // --- GitLab API functions for repository statistics --- // mapPipelineStatus converts GitLab pipeline status string to numeric code func mapPipelineStatus(status string) int { switch status { case "success": return 0 case "pending": return 1 case "running": return 2 case "failed": return 3 case "canceled": return 4 case "skipped": return 5 default: return -1 } } // doGitLabRequest performs an HTTP request to GitLab API, authenticated when // a token is available (GITLAB_TOKEN env var or -token flag) func doGitLabRequest(url string) (*http.Response, error) { return getWithRetry(apiClient, url, gitlabToken()) } // fetchGitLabProject fetches main project information from GitLab API func fetchGitLabProject() (*GitLabProject, error) { url := fmt.Sprintf("%s/projects/%s?license=true", gitGudBaseURL, gitGudProjectID) print(" Fetching project info... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch project: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var project GitLabProject if err := json.NewDecoder(resp.Body).Decode(&project); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } // Ensure WebURL is set if project.WebURL == "" { project.WebURL = gitGudProjectURL } println(project.StarCount, "stars,", project.ForksCount, "forks") return &project, nil } // fetchGitLabIssuesStats fetches issues statistics from GitLab API func fetchGitLabIssuesStats() (*GitLabIssuesStats, error) { url := fmt.Sprintf("%s/projects/%s/issues_statistics", gitGudBaseURL, gitGudProjectID) print(" Fetching issues stats... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch issues stats: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var stats GitLabIssuesStats if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } println(stats.Statistics.Counts.Opened, "open issues") return &stats, nil } // fetchGitLabPipelineStatus fetches the latest pipeline status from GitLab API func fetchGitLabPipelineStatus() (*GitLabPipeline, error) { url := fmt.Sprintf("%s/projects/%s/pipelines?per_page=1", gitGudBaseURL, gitGudProjectID) print(" Fetching pipeline status... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch pipeline: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var pipelines []GitLabPipeline if err := json.NewDecoder(resp.Body).Decode(&pipelines); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } if len(pipelines) == 0 { println("no pipelines found") return nil, fmt.Errorf("no pipelines found") } println(pipelines[0].Status, "-", pipelines[0].Ref) return &pipelines[0], nil } // generateRepoStats creates a RepoStats structure with fetched data // Falls back to defaults for any data that couldn't be fetched func generateRepoStats() *RepoStats { stats := &RepoStats{ StarCount: 0, ForksCount: 0, OpenIssuesCount: 0, CIStatus: -1, LastActivityAt: "", CreatedAt: "", License: nil, Topics: []string{}, Description: "", WebURL: gitGudProjectURL, DefaultBranch: "master", Visibility: "public", FetchedAt: time.Now().UTC().Format(time.RFC3339), } // Fetch project info if project, err := fetchGitLabProject(); err == nil { stats.StarCount = project.StarCount stats.ForksCount = project.ForksCount stats.LastActivityAt = project.LastActivityAt stats.CreatedAt = project.CreatedAt stats.Topics = project.Topics stats.Description = project.Description stats.WebURL = project.WebURL stats.DefaultBranch = project.DefaultBranch stats.Visibility = project.Visibility // Process license if project.License != nil { licenseURL := project.License.URL if licenseURL == "" && project.License.HTMLURL != "" { licenseURL = project.License.HTMLURL } stats.License = &RepoStatsLicense{ Key: project.License.Key, Name: project.License.Name, URL: licenseURL, } } } else { print(" Warning: Using defaults for project info\n") } // Fetch issues stats if issues, err := fetchGitLabIssuesStats(); err == nil { stats.OpenIssuesCount = issues.Statistics.Counts.Opened } else { print(" Warning: Using 0 for open issues count\n") } // Fetch pipeline status if pipeline, err := fetchGitLabPipelineStatus(); err == nil { stats.CIStatus = mapPipelineStatus(pipeline.Status) stats.CIPipeline = &RepoStatsPipeline{ Status: pipeline.Status, Ref: pipeline.Ref, SHA: pipeline.SHA, } } else { print(" Warning: Using -1 for CI status\n") } return stats } // writeRepoStatsFile writes the RepoStats to a JSON file func writeRepoStatsFile(stats *RepoStats, outputPath string) error { // Ensure directory exists dir := filepath.Dir(outputPath) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } } data, err := json.MarshalIndent(stats, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } if err := fileops.AtomicWrite(outputPath, data, 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } // computeHashForURL downloads a file and computes its BLAKE3 hash // In auto mode, returns error on failure; in interactive mode, returns default hash func computeHashForURL(prompt, url, defaultHash string) (string, error) { filePath, err := downloadFileIfNotExists(url) if err != nil { if *autoMode { return "", fmt.Errorf("failed to download %s: %w", url, err) } print(" Error downloading: %v\n", err) print(" Using default value: %s\n", defaultHash) return defaultHash, nil } hash, err := computeBLAKE3(filePath) if err != nil { if *autoMode { return "", fmt.Errorf("failed to compute hash for %s: %w", url, err) } print(" Error computing hash: %v\n", err) print(" Using default value: %s\n", defaultHash) return defaultHash, nil } if activeProvenance != nil { pinned, ok := activeProvenance.ArtifactForURL(url) if !ok { return "", fmt.Errorf("%s URL %s is absent from the reviewed provenance lock", prompt, url) } if hash != pinned.BLAKE3 { return "", fmt.Errorf("%s does not match reviewed digest: got %s, want %s", prompt, hash, pinned.BLAKE3) } usedProvenanceAssets[url] = true } allUsedFiles = append(allUsedFiles, usedFile{ url: url, filename: extractFilenameFromURL(url), }) print(" Computed BLAKE3: %s\n", hash) return hash, nil } func computeManagerArtifactHash(label, artifactURL string) (string, error) { path := managerArtifactPath(artifactURL) if strings.TrimSpace(*managerFiles) == "" { var err error path, err = downloadFileIfNotExists(artifactURL) if err != nil { return "", fmt.Errorf("download interactive %s: %w", label, err) } } info, err := os.Stat(path) if err != nil { return "", fmt.Errorf("stat pipeline-local %s: %w", label, err) } if !info.Mode().IsRegular() || info.Size() <= 0 { return "", fmt.Errorf("pipeline-local %s is not a non-empty regular file", label) } hash, err := computeBLAKE3(path) if err != nil { return "", fmt.Errorf("hash pipeline-local %s: %w", label, err) } return hash, nil } func ensureAllProvenanceAssetsUsed() error { if activeProvenance == nil { return nil } var unused []string for _, artifact := range activeProvenance.Artifacts { if !usedProvenanceAssets[artifact.URL] { unused = append(unused, artifact.ID) } } if len(unused) > 0 { return fmt.Errorf("reviewed provenance lock contains unused artifacts: %s", strings.Join(unused, ", ")) } return nil } // runRepoStatsMode handles the -repo-stats flag to generate repository statistics func runRepoStatsMode() { if !*quiet { fmt.Println("MPV.Rocks Manager - Repository Stats Generator") fmt.Println("==============================================") fmt.Println("Fetching repository statistics from GitGud.io") fmt.Println() } // Generate stats (with graceful error handling) stats := generateRepoStats() // Determine output path outputPath := *statsOutput if outputPath == "" { // Default: same directory as releases.json outputPath = filepath.Join(filepath.Dir(*outputFile), "repo-stats.json") // If releases.json path has no directory, use current directory if outputPath == "repo-stats.json" || outputPath == "./repo-stats.json" { outputPath = "repo-stats.json" } } // Write to file if err := writeRepoStatsFile(stats, outputPath); err != nil { fmt.Fprintf(os.Stderr, "Error writing repo-stats.json: %v\n", err) os.Exit(1) } print("\nāœ“ Generated %s\n", outputPath) print(" Stars: %d, Forks: %d, Open Issues: %d, CI Status: %d\n", stats.StarCount, stats.ForksCount, stats.OpenIssuesCount, stats.CIStatus) } func printUsage() { fmt.Fprintf(os.Stderr, "MPV.Rocks Manager - Info Generator\n") fmt.Fprintf(os.Stderr, "===================================\n\n") fmt.Fprintf(os.Stderr, "This tool generates JSON files for https://mpv.rocks\n\n") fmt.Fprintf(os.Stderr, "USAGE:\n") fmt.Fprintf(os.Stderr, " generate-info [OPTIONS] Generate releases.json (default)\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats [OPTS] Generate repo-stats.json\n\n") fmt.Fprintf(os.Stderr, "MODES:\n") fmt.Fprintf(os.Stderr, " (default) Fetch release versions from GitHub, download binaries,\n") fmt.Fprintf(os.Stderr, " compute BLAKE3 hashes, generate releases.json\n") fmt.Fprintf(os.Stderr, " -repo-stats Fetch repository statistics from GitGud.io API\n") fmt.Fprintf(os.Stderr, " (stars, forks, issues, CI status) and generate repo-stats.json\n\n") fmt.Fprintf(os.Stderr, "OPTIONS:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nEXAMPLES:\n") fmt.Fprintf(os.Stderr, " # Generate releases.json (interactive mode)\n") fmt.Fprintf(os.Stderr, " generate-info\n\n") fmt.Fprintf(os.Stderr, " # Generate an unsigned release candidate from reviewed inputs\n") fmt.Fprintf(os.Stderr, " generate-info -auto -quiet -manager-version 1.3.0 \\\n") fmt.Fprintf(os.Stderr, " -provenance-lock release/provenance-v1.3.0.json \\\n") fmt.Fprintf(os.Stderr, " -manager-artifacts-dir dist\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json with custom output path\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats -stats-output /var/www/api/repo-stats.json\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json with API token\n") fmt.Fprintf(os.Stderr, " # (env vars keep tokens out of shell history and process lists;\n") fmt.Fprintf(os.Stderr, " # GITLAB_TOKEN takes precedence over -token, GITHUB_TOKEN raises\n") fmt.Fprintf(os.Stderr, " # the GitHub API rate limit for cron jobs)\n") fmt.Fprintf(os.Stderr, " GITLAB_TOKEN=glpat-xxxxxxxxxxxx GITHUB_TOKEN=ghp_xxxx generate-info -repo-stats -quiet\n") } func main() { flag.Usage = printUsage flag.Parse() if *releaseChannel != "" && *releaseChannel != releasemanifest.StableChannel && *releaseChannel != releasemanifest.ReleaseCandidateChannel { fmt.Fprintln(os.Stderr, "Error: -channel must be stable or rc") os.Exit(1) } // Handle repo-stats mode if *repoStats { runRepoStatsMode() return } if !*quiet { fmt.Println("MPV.Rocks Manager - Releases JSON Generator") fmt.Println("============================================") fmt.Println("This generates a releases.json file for https://mpv.rocks") if *autoMode { fmt.Println("Running in AUTO mode (non-interactive)") } else { fmt.Println("Version information is fetched automatically from GitHub.") } fmt.Println() } // An explicit manager version always wins. Release CI must never silently // sign metadata for an older "latest" GitLab release while publishing a new // tag whose release object has not been created yet. var managerVer string if *autoMode { if *managerVersion != "" { managerVer = extractVersionFromTag(*managerVersion) print(" MPV Manager version: %s (explicit)\n", managerVer) } else { fmt.Fprintln(os.Stderr, "Error: --manager-version is required in auto release mode") os.Exit(1) } if strings.TrimSpace(*provenanceLock) == "" || strings.TrimSpace(*managerFiles) == "" { fmt.Fprintln(os.Stderr, "Error: --provenance-lock and --manager-artifacts-dir are required in auto release mode") os.Exit(1) } } var versions map[string]string var winTimestamp string if strings.TrimSpace(*provenanceLock) != "" { lock, err := releaseprovenance.Load(*provenanceLock, managerVer) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } activeProvenance = lock usedProvenanceAssets = make(map[string]bool) versions = lock.Versions() winTimestamp = lock.Sources.WindowsTimestamp print(" Upstream provenance: reviewed lock %s\n", *provenanceLock) } else { // Interactive discovery is useful for preparing a candidate lock, but // unattended release generation is prohibited from trusting "latest". var err error versions, winTimestamp, _, err = fetchAllVersions() if err != nil { fmt.Fprintf(os.Stderr, "Error fetching versions: %v\n", err) os.Exit(1) } } release := Release{} var mpvVersion, releaseDate, winMpvHash, winFFmpegHash, winFullTag string var uoscVersion, modernZVersion, mpcqtVersion, iinaVersion string if *autoMode { // Use fetched defaults automatically mpvVersion = versions["mpv"] releaseDate = time.Now().Format("2006-01-02") winFullTag = versions["win_full_tag"] winMpvHash = versions["win_mpv_hash"] winFFmpegHash = versions["win_ffmpeg_hash"] uoscVersion = versions["uosc"] modernZVersion = versions["modernz"] mpcqtVersion = versions["mpcqt"] iinaVersion = versions["iina"] } else { // Interactive mode - prompt for versions reader := bufio.NewReader(os.Stdin) println("\n--- Confirm/Override Versions ---") println("(Press Enter to use the fetched version)") mpvVersion = promptString(reader, "MPV Version", versions["mpv"]) releaseDate = promptString(reader, "Release Date", time.Now().Format("2006-01-02")) fmt.Println("\n--- Windows & FFmpeg Builds ---") winFullTag = promptString(reader, "Windows Build Full Tag", versions["win_full_tag"]) winTimestamp = promptString(reader, "Windows Build Timestamp (YYYYMMDD)", winTimestamp) winMpvHash = promptString(reader, "Windows MPV Commit Hash", versions["win_mpv_hash"]) winFFmpegHash = promptString(reader, "FFmpeg Commit Hash", versions["win_ffmpeg_hash"]) uoscVersion = promptString(reader, "uOSC Version", versions["uosc"]) modernZVersion = promptString(reader, "ModernZ Version", versions["modernz"]) mpcqtVersion = promptString(reader, "MPC-QT Version", versions["mpcqt"]) iinaVersion = promptString(reader, "IINA Version", versions["iina"]) fmt.Println("\n--- MPV Manager ---") if *managerVersion != "" { managerVer = extractVersionFromTag(*managerVersion) fmt.Printf("MPV Manager Version: %s (from flag)\n", managerVer) } else { // Try to fetch from GitLab print(" Fetching MPV Manager version from GitLab... ") if gitlabRelease, err := fetchLatestGitLabRelease("mike/mpv-manager"); err != nil { print("ERROR: %v\n", err) managerVer = promptString(reader, "MPV Manager Version", "1.2.0") } else { managerVer = extractVersionFromTag(gitlabRelease.TagName) println(managerVer) } } } // Now process all items print("\n--- Processing URLs and Computing Hashes ---\n") release.Version = managerVer release.MpvVersion = mpvVersion release.Date = releaseDate release.SchemaVersion = releasemanifest.SchemaVersion release.Channel = releasemanifest.ChannelForVersion(managerVer) if *releaseChannel != "" { release.Channel = *releaseChannel } release.PublishedAt = time.Now().UTC().Format(time.RFC3339) release.MinimumUpdaterVersion = "1.2.0" // Helper to compute hash and exit on error in auto mode hashOrExit := func(prompt, url, defaultHash string) string { hash, err := computeHashForURL(prompt, url, defaultHash) if err != nil { if *autoMode { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } return defaultHash } return hash } managerHashOrExit := func(label, artifactURL string) string { hash, err := computeManagerArtifactHash(label, artifactURL) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } return hash } // Generate Windows URLs print("Computing hashes for Windows binaries...\n") release.Windows.X8664.URL = generateWindowsURL(winFullTag, winTimestamp, "x86_64", winMpvHash) release.Windows.X8664.BLAKE3 = hashOrExit("Windows x86-64", release.Windows.X8664.URL, "blake3:HASH") release.Windows.X8664v3.URL = generateWindowsURL(winFullTag, winTimestamp, "x86_64-v3", winMpvHash) release.Windows.X8664v3.BLAKE3 = hashOrExit("Windows x86-64-v3", release.Windows.X8664v3.URL, "blake3:HASH") release.Windows.Aarch64.URL = generateWindowsURL(winFullTag, winTimestamp, "aarch64", winMpvHash) release.Windows.Aarch64.BLAKE3 = hashOrExit("Windows aarch64", release.Windows.Aarch64.URL, "blake3:HASH") // Generate FFmpeg URLs (uses different hash than Windows MPV builds) print("Computing hashes for FFmpeg binaries...\n") release.FFmpeg.X8664.URL = generateFFmpegURL(winFullTag, winTimestamp, "x86_64", winFFmpegHash) release.FFmpeg.X8664.BLAKE3 = hashOrExit("FFmpeg x86-64", release.FFmpeg.X8664.URL, "blake3:HASH") release.FFmpeg.X8664v3.URL = generateFFmpegURL(winFullTag, winTimestamp, "x86_64-v3", winFFmpegHash) release.FFmpeg.X8664v3.BLAKE3 = hashOrExit("FFmpeg x86-64-v3", release.FFmpeg.X8664v3.URL, "blake3:HASH") release.FFmpeg.Aarch64.URL = generateFFmpegURL(winFullTag, winTimestamp, "aarch64", winFFmpegHash) release.FFmpeg.Aarch64.BLAKE3 = hashOrExit("FFmpeg aarch64", release.FFmpeg.Aarch64.URL, "blake3:HASH") release.FFmpeg.AppVersion = fmt.Sprintf("%s-%s", winTimestamp, winFFmpegHash) // macOS binaries print("Computing hashes for macOS binaries...\n") release.MacOS.ARMLatest.URL = generateMacOSURL(mpvVersion, "macos", "26-arm") release.MacOS.ARMLatest.BLAKE3 = hashOrExit("macOS ARM Latest", release.MacOS.ARMLatest.URL, "blake3:HASH") release.MacOS.ARM15.URL = generateMacOSURL(mpvVersion, "macos", "15-arm") release.MacOS.ARM15.BLAKE3 = hashOrExit("macOS ARM 15", release.MacOS.ARM15.URL, "blake3:HASH") release.MacOS.Intel15.URL = generateMacOSURL(mpvVersion, "macos", "15-intel") release.MacOS.Intel15.BLAKE3 = hashOrExit("macOS Intel 15", release.MacOS.Intel15.URL, "blake3:HASH") // uOSC print("Computing hashes for uOSC...\n") release.UOSC.URL = generateUOSCURL(uoscVersion) release.UOSC.BLAKE3 = hashOrExit("uOSC", release.UOSC.URL, "blake3:HASH") release.UOSC.ConfURL = fmt.Sprintf("https://github.com/tomasklaen/uosc/releases/download/%s/uosc.conf", uoscVersion) release.UOSC.ConfBLAKE3 = hashOrExit("uOSC Config", release.UOSC.ConfURL, "blake3:HASH") release.UOSC.AppVersion = uoscVersion // ModernZ print("Computing hashes for ModernZ...\n") release.ModernZ.ScriptURL = generateModernZScriptURL(modernZVersion) release.ModernZ.ScriptBLAKE3 = hashOrExit("ModernZ Script", release.ModernZ.ScriptURL, "blake3:HASH") release.ModernZ.FontURL = generateModernZFontURL(modernZVersion) release.ModernZ.FontBLAKE3 = hashOrExit("ModernZ Font", release.ModernZ.FontURL, "blake3:HASH") release.ModernZ.ConfURL = fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz.conf", modernZVersion) release.ModernZ.ConfBLAKE3 = hashOrExit("ModernZ Config", release.ModernZ.ConfURL, "blake3:HASH") release.ModernZ.AppVersion = modernZVersion // MPC-QT print("Computing hashes for MPC-QT...\n") release.MPCQT.X8664.URL = generateMPCQTURL(mpcqtVersion) release.MPCQT.X8664.BLAKE3 = hashOrExit("MPC-QT x86-64", release.MPCQT.X8664.URL, "blake3:HASH") release.MPCQT.AppVersion = mpcqtVersion // IINA print("Computing hashes for IINA...\n") release.IINA.ARM.URL = generateIINAURL(iinaVersion) release.IINA.ARM.BLAKE3 = hashOrExit("IINA ARM", release.IINA.ARM.URL, "blake3:HASH") release.IINA.Intel.URL = generateIINAURL(iinaVersion) release.IINA.Intel.BLAKE3 = hashOrExit("IINA Intel", release.IINA.Intel.URL, "blake3:HASH") release.IINA.AppVersion = iinaVersion // Manager - generate all platforms (using GitLab Generic Package Registry for direct downloads) base, err := managerDownloadDirectory(*managerDownloadBase, managerVer) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } // URL format: https://gitgud.io/api/v4/projects/mike%2Fmpv-manager/packages/generic/mpv-manager/v1.0.0/mpv-manager-linux-amd64 print("Computing hashes for MPV Manager...\n") release.Manager.LinuxAMD64.URL = base + "/mpv-manager-linux-amd64" release.Manager.LinuxAMD64.BLAKE3 = managerHashOrExit("Manager Linux AMD64", release.Manager.LinuxAMD64.URL) release.Manager.LinuxARM64.URL = base + "/mpv-manager-linux-arm64" release.Manager.LinuxARM64.BLAKE3 = managerHashOrExit("Manager Linux ARM64", release.Manager.LinuxARM64.URL) release.Manager.WinX86_64.URL = base + "/mpv-manager-win-x86_64.exe" release.Manager.WinX86_64.BLAKE3 = managerHashOrExit("Manager Windows x86-64", release.Manager.WinX86_64.URL) release.Manager.WinARM64.URL = base + "/mpv-manager-win-arm64.exe" release.Manager.WinARM64.BLAKE3 = managerHashOrExit("Manager Windows ARM64", release.Manager.WinARM64.URL) release.Manager.MacosIntel.URL = base + "/mpv-manager-macos-intel" release.Manager.MacosIntel.BLAKE3 = managerHashOrExit("Manager macOS Intel", release.Manager.MacosIntel.URL) release.Manager.MacosARM.URL = base + "/mpv-manager-macos-arm" release.Manager.MacosARM.BLAKE3 = managerHashOrExit("Manager macOS ARM", release.Manager.MacosARM.URL) if err := populateManagerComponent(&release); err != nil { fmt.Fprintf(os.Stderr, "Error building component manifest: %v\n", err) os.Exit(1) } if err := ensureAllProvenanceAssetsUsed(); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } if err := applyReviewedDownloadURLs(&release, activeProvenance); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } // Final sanity gate. Application release CI writes an unsigned candidate; // the private key exists only behind the isolated signing service. if err := validateRelease(&release); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if *signManifest { if err := signRelease(&release); err != nil { fmt.Fprintf(os.Stderr, "Error signing release manifest: %v\n", err) os.Exit(1) } } data, err := json.MarshalIndent(release, "", "\t") if err != nil { fmt.Fprintf(os.Stderr, "Error generating JSON: %v\n", err) os.Exit(1) } if err := fileops.AtomicWrite(*outputFile, data, 0644); err != nil { fmt.Fprintf(os.Stderr, "Error writing to %s: %v\n", *outputFile, err) os.Exit(1) } print("\nāœ“ Generated %s with %d bytes\n", *outputFile, len(data)) cleanupUnusedFiles(allUsedFiles) } var ( semverRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) blake3Re = regexp.MustCompile(`^blake3:[0-9a-f]{64}$`) ) // validateRelease is the final sanity gate before publishing: required // fields must be non-empty and well-formed (versions semver-ish, date // YYYY-MM-DD, URLs https, hashes 'blake3:<64 hex>' where present) so a // malformed releases.json is never written. Shared v2 schema validation is // delegated to pkg/releasemanifest; this function also validates the legacy // application fields retained for pre-v1.3 clients. func validateRelease(r *Release) error { var problems []string if err := r.Validate(); err != nil { problems = append(problems, err.Error()) } // The shared schema validates manager SemVer, including RC versions. if !semverRe.MatchString(r.MpvVersion) { problems = append(problems, fmt.Sprintf("mpv-version %q is not semver (x.y.z)", r.MpvVersion)) } if !dateRe.MatchString(r.Date) { problems = append(problems, fmt.Sprintf("date %q is not YYYY-MM-DD", r.Date)) } checkURL := func(name, u string) { if u == "" { problems = append(problems, name+" URL is empty") } else if !strings.HasPrefix(u, "https://") { problems = append(problems, fmt.Sprintf("%s URL %q is not https", name, u)) } } checkHash := func(name, h string) { if h != "" && !blake3Re.MatchString(h) { problems = append(problems, fmt.Sprintf("%s hash %q is not 'blake3:<64 hex>'", name, h)) } } check := func(name string, entry struct{ URL, BLAKE3 string }) { checkURL(name, entry.URL) checkHash(name, entry.BLAKE3) } check("windows.x86-64", r.Windows.X8664) check("windows.x86-64-v3", r.Windows.X8664v3) check("windows.aarch64", r.Windows.Aarch64) check("macos.arm-latest", r.MacOS.ARMLatest) check("macos.arm-15", r.MacOS.ARM15) check("macos.intel-15", r.MacOS.Intel15) check("ffmpeg.x86-64", r.FFmpeg.X8664) check("ffmpeg.x86-64-v3", r.FFmpeg.X8664v3) check("ffmpeg.aarch64", r.FFmpeg.Aarch64) check("mpc-qt.x86-64", r.MPCQT.X8664) check("iina.arm", r.IINA.ARM) check("iina.intel", r.IINA.Intel) check("manager.linux-amd64", r.Manager.LinuxAMD64) check("manager.linux-arm64", r.Manager.LinuxARM64) check("manager.win-x86_64", r.Manager.WinX86_64) check("manager.win-arm64", r.Manager.WinARM64) check("manager.macos-intel", r.Manager.MacosIntel) check("manager.macos-arm", r.Manager.MacosARM) checkURL("uosc", r.UOSC.URL) checkHash("uosc", r.UOSC.BLAKE3) checkURL("uosc conf", r.UOSC.ConfURL) checkHash("uosc conf", r.UOSC.ConfBLAKE3) checkURL("modernz script", r.ModernZ.ScriptURL) checkHash("modernz script", r.ModernZ.ScriptBLAKE3) checkURL("modernz font", r.ModernZ.FontURL) checkHash("modernz font", r.ModernZ.FontBLAKE3) checkURL("modernz conf", r.ModernZ.ConfURL) checkHash("modernz conf", r.ModernZ.ConfBLAKE3) for name, v := range map[string]string{ "ffmpeg app_version": r.FFmpeg.AppVersion, "uosc app_version": r.UOSC.AppVersion, "modernz app_version": r.ModernZ.AppVersion, "mpc-qt app_version": r.MPCQT.AppVersion, "iina app_version": r.IINA.AppVersion, } { if v == "" { problems = append(problems, name+" is empty") } } if len(problems) > 0 { return fmt.Errorf("release validation failed:\n - %s", strings.Join(problems, "\n - ")) } return nil } func computeBLAKE3(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", err } defer file.Close() hash := blake3.New(32, nil) if _, err := io.Copy(hash, file); err != nil { return "", err } return "blake3:" + hex.EncodeToString(hash.Sum(nil)), nil } func generateWindowsURL(fullTag, timestamp, arch, hash string) string { return fmt.Sprintf("https://github.com/zhongfly/mpv-winbuild/releases/download/%s/mpv-%s-%s-git-%s.7z", fullTag, arch, timestamp, hash) } // generateFFmpegURL builds a download URL for FFmpeg builds. // The timestamp parameter is unused but kept for API symmetry with generateWindowsURL. func generateFFmpegURL(fullTag, timestamp, arch, hash string) string { return fmt.Sprintf("https://github.com/zhongfly/mpv-winbuild/releases/download/%s/ffmpeg-%s-git-%s.7z", fullTag, arch, hash) } func generateMacOSURL(version, osType, arch string) string { return fmt.Sprintf("https://github.com/mpv-player/mpv/releases/download/v%s/mpv-v%s-%s-%s.zip", version, version, osType, arch) } func generateIINAURL(version string) string { return fmt.Sprintf("https://github.com/iina/iina/releases/download/v%s/IINA.v%s.dmg", version, version) } func generateUOSCURL(version string) string { return fmt.Sprintf("https://github.com/tomasklaen/uosc/releases/download/%s/uosc.zip", version) } func generateModernZScriptURL(version string) string { return fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz.lua", version) } func generateModernZFontURL(version string) string { return fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz-icons.ttf", version) } func generateMPCQTURL(version string) string { return fmt.Sprintf("https://github.com/mpc-qt/mpc-qt/releases/download/v%s/mpc-qt-win-x64-%s-installer.exe", version, version) } func promptString(reader *bufio.Reader, prompt, defaultVal string) string { fmt.Printf("%s [%s]: ", prompt, defaultVal) input, err := reader.ReadString('\n') if err != nil { return defaultVal } input = strings.TrimSpace(input) if input == "" { return defaultVal } return input } // managerDownloadDirectory permits an explicitly provisioned immutable artifact // directory for SSH-operated RC publication without changing GitLab defaults. func managerDownloadDirectory(override, version string) (string, error) { if override == "" { return fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s", version), nil } parsed, err := url.Parse(override) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return "", fmt.Errorf("manager-download-base must be an absolute HTTPS directory without credentials, query or fragment") } return strings.TrimRight(override, "/"), nil }