refactor: migrate metadata discovery to pkgsite

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-22 22:30:54 +08:00
committed by Julian Zhu
parent 5064ced366
commit d19eadb416
9 changed files with 852 additions and 269 deletions
+4 -3
View File
@@ -24,7 +24,6 @@ var (
// uversionPrereleaseRegexp checks for upstream pre-release
// so that '-' can be replaced with '~' in pkgVersionFromGit.
// To be kept in sync with the regexp portion of uversionmanglePattern in spec.go
uversionPrereleaseRegexp = regexp.MustCompile(`(\d)[_\.\-\+]?(RC|rc|pre|dev|beta|alpha)[.]?(\d*)$`)
)
@@ -42,8 +41,10 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre
var cmd *exec.Cmd // the temporary shell commands we execute
// If the user specifies a valid tag as the preferred revision, that tag should be used without additional heuristics.
if u.rr != nil {
if out, err := u.rr.VCS.Tags(gitdir); err == nil && slices.Contains(out, preferredRev) {
if preferredRev != "" {
cmd = exec.Command("git", "tag", "--list", preferredRev)
cmd.Dir = gitdir
if out, err := cmd.Output(); err == nil && slices.Contains(strings.Fields(string(out)), preferredRev) {
latestTag = preferredRev
}
}
+15 -21
View File
@@ -13,8 +13,7 @@ import (
//go:embed description.json
var descriptionJSONBytes []byte
// reformatForControl reformats the wrapped description
// to conform to Debians control format.
// reformatForControl reformats wrapped text for the RPM spec's %description.
func reformatForControl(raw string) string {
output := ""
next_prefix := ""
@@ -47,8 +46,8 @@ func reformatForControl(raw string) string {
return output
}
// markdownToLongDescription converts Markdown to plain text
// and reformat it for expanded description in debian/control.
// markdownToLongDescription converts Markdown to plain text for the RPM spec's
// %description section.
func markdownToLongDescription(markdown string) (string, error) {
r, _ := glamour.NewTermRenderer(
glamour.WithStylesFromJSONBytes(descriptionJSONBytes),
@@ -63,23 +62,17 @@ func markdownToLongDescription(markdown string) (string, error) {
return reformatForControl(out), nil
}
// getDescriptionForGopkg reads from README.md (or equivalent) from GitHub,
// intended for extended description in debian/control.
// getLongDescriptionForGopkg reads README.md (or equivalent) from pkg.go.dev,
// intended for the RPM spec's %description section.
func getLongDescriptionForGopkg(gopkg string) (string, error) {
owner, repo, err := findGitHubRepo(gopkg)
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
return "", fmt.Errorf("find github repo: %w", err)
return "", fmt.Errorf("get pkgsite metadata: %w", err)
}
rr, _, err := gitHub.Repositories.GetReadme(context.TODO(), owner, repo, nil)
if err != nil {
return "", fmt.Errorf("get readme: %w", err)
}
content, err := rr.GetContent()
if err != nil {
return "", fmt.Errorf("get content: %w", err)
if info.Module.Readme == nil || strings.TrimSpace(info.Module.Readme.Contents) == "" {
return "", fmt.Errorf("pkgsite module %q has no README", info.Module.Path)
}
content := info.Module.Readme.Contents
// Supported filename suffixes are from
// https://github.com/github/markup/blob/master/README.md
@@ -88,10 +81,11 @@ func getLongDescriptionForGopkg(gopkg string) (string, error) {
// fairly involved, but itd be the most correct solution to the problem at
// hand. Our current code just knows markdown, which is good enough since
// most (Go?) projects in fact use markdown for their README files.
if !strings.HasSuffix(rr.GetName(), "md") &&
!strings.HasSuffix(rr.GetName(), "markdown") &&
!strings.HasSuffix(rr.GetName(), "mdown") &&
!strings.HasSuffix(rr.GetName(), "mkdn") {
readmeName := strings.ToLower(info.Module.Readme.Filepath)
if !strings.HasSuffix(readmeName, "md") &&
!strings.HasSuffix(readmeName, "markdown") &&
!strings.HasSuffix(readmeName, "mdown") &&
!strings.HasSuffix(readmeName, "mkdn") {
return reformatForControl(content), nil
}
-3
View File
@@ -4,11 +4,9 @@ go 1.25.3
require (
github.com/charmbracelet/glamour v0.10.0
github.com/google/go-github/v60 v60.0.0
github.com/mattn/go-isatty v0.0.20
github.com/sandrolain/httpcache v1.4.0
golang.org/x/net v0.47.0
golang.org/x/tools/go/vcs v0.1.0-deprecated
)
require (
@@ -22,7 +20,6 @@ require (
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
-10
View File
@@ -28,13 +28,6 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8=
github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -76,6 +69,3 @@ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools/go/vcs v0.1.0-deprecated h1:cOIJqWBl99H1dH5LWizPa+0ImeeJq3t3cJjaeOWUAL4=
golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+4 -37
View File
@@ -3,26 +3,11 @@ package main
import (
"net/http"
"os"
"time"
"github.com/google/go-github/v60/github"
"github.com/sandrolain/httpcache"
)
var (
gitHub *github.Client
)
// TokenTransport implements http.RoundTripper for Bearer token authentication
type TokenTransport struct {
Token string
Transport http.RoundTripper
}
func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("Authorization", "Bearer "+t.Token)
return t.Transport.RoundTrip(req)
}
func printHelp() {
helpText := `go2spec - A tool to package Go modules into RPM spec files.
@@ -41,28 +26,10 @@ If there are no commands provided, the tool will default to executing the 'pack'
}
func main() {
token := os.Getenv("GITHUB_TOKEN")
var client *http.Client
if token != "" {
// Use token authentication for better rate limits
client = &http.Client{
Transport: &TokenTransport{
Token: token,
Transport: httpcache.NewMemoryCacheTransport(),
},
}
} else {
// Fallback to basic auth if token is not provided
transport := github.BasicAuthTransport{
Username: os.Getenv("GITHUB_USERNAME"),
Password: os.Getenv("GITHUB_PASSWORD"),
OTP: os.Getenv("GITHUB_OTP"),
Transport: httpcache.NewMemoryCacheTransport(),
}
client = transport.Client()
pkgsiteHTTPClient = &http.Client{
Timeout: 30 * time.Second,
Transport: httpcache.NewMemoryCacheTransport(),
}
gitHub = github.NewClient(client)
args := os.Args[1:]
+109 -98
View File
@@ -3,128 +3,139 @@ package main
import (
"context"
"fmt"
"net/http"
"html"
"path"
"regexp"
"strings"
"golang.org/x/net/html"
)
// To update, use:
// curl -s https://api.github.com/licenses | jq '.[].key'
var githubLicenseToSPDXLicense = map[string]string{
//"agpl-3.0"
"apache-2.0": "Apache-2.0",
"artistic-2.0": "Artistic-2.0",
"bsd-2-clause": "BSD-2-Clause",
"bsd-3-clause": "BSD-3-Clause",
"cc0-1.0": "CC0-1.0",
//"epl-1.0" (eclipse public license)
"gpl-2.0": "GPL-2.0-only",
"gpl-3.0": "GPL-3.0-only",
"isc": "ISC",
"lgpl-2.1": "LGPL-2.1-only",
"lgpl-3.0": "LGPL-3.0-only",
"mit": "MIT",
"mpl-2.0": "MPL-2.0",
//"unlicense"
func getRepoURLForGopkg(gopkg string) (string, error) {
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
return "", err
}
repoURL := strings.TrimSpace(info.Module.RepoURL)
if repoURL == "" {
return "", fmt.Errorf("pkgsite module %q has no repository URL", info.Module.Path)
}
return gitCloneURLFromRepoURL(repoURL), nil
}
var githubRegexp = regexp.MustCompile(`github\.com/([^/]+/[^/]+)`)
var (
htmlTagRegexp = regexp.MustCompile(`<[^>]*>`)
markdownImageRegex = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`)
markdownLinkRegex = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
packagePrefixRegex = regexp.MustCompile(`^Package\s+\S+\s+`)
)
func findGitHubOwnerRepo(gopkg string) (string, error) {
if strings.HasPrefix(gopkg, "github.com/") {
return strings.TrimPrefix(gopkg, "github.com/"), nil
// cleanSummaryCandidate turns a godoc synopsis or README line into an
// RPM-style Summary by stripping markup, keeping the first sentence, dropping
// the leading "Package foo" convention, and capitalizing the result.
func cleanSummaryCandidate(summary string) string {
summary = html.UnescapeString(strings.TrimSpace(summary))
summary = markdownImageRegex.ReplaceAllString(summary, "")
summary = markdownLinkRegex.ReplaceAllString(summary, "$1")
summary = htmlTagRegexp.ReplaceAllString(summary, " ")
summary = strings.ReplaceAll(summary, "`", "")
summary = strings.Join(strings.Fields(summary), " ")
summary = strings.Trim(summary, " \t\n\r#*-_")
if end := strings.Index(summary, ". "); end >= 0 {
summary = summary[:end]
}
resp, err := http.Get("https://" + gopkg + "?go-get=1")
if err != nil {
return "", fmt.Errorf("HTTP get: %w", err)
summary = packagePrefixRegex.ReplaceAllString(summary, "")
if summary != "" && summary[0] >= 'a' && summary[0] <= 'z' {
summary = string(summary[0]-('a'-'A')) + summary[1:]
}
defer resp.Body.Close()
z := html.NewTokenizer(resp.Body)
for {
tt := z.Next()
if tt == html.ErrorToken {
return "", fmt.Errorf("%q is not on GitHub", gopkg)
}
token := z.Token()
if token.Data != "meta" {
if summary == "" ||
strings.HasPrefix(summary, "[!") ||
strings.HasPrefix(summary, "![") ||
strings.HasPrefix(summary, "<!--") ||
strings.ContainsAny(summary, "<>") {
return ""
}
return strings.TrimSuffix(summary, ".")
}
// summaryFromReadme returns the first prose line of a README, skipping code
// fences, headings, HTML comments, badges, admonitions, and images.
func summaryFromReadme(markdown string) string {
inFence := false
lines := strings.Split(markdown, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "```") {
inFence = !inFence
continue
}
var meta struct {
name, content string
}
for _, attr := range token.Attr {
if attr.Key == "name" {
meta.name = attr.Val
}
if attr.Key == "content" {
meta.content = attr.Val
if i+1 < len(lines) {
next := strings.TrimSpace(lines[i+1])
if next != "" && strings.Trim(next, "=-") == "" {
continue
}
}
match := func(name string, length int) string {
if f := strings.Fields(meta.content); meta.name == name && len(f) == length {
if f[0] != gopkg {
return ""
}
if repoMatch := githubRegexp.FindStringSubmatch(f[2]); repoMatch != nil {
return strings.TrimSuffix(repoMatch[1], ".git")
}
}
return ""
if inFence ||
line == "" ||
strings.HasPrefix(line, "#") ||
strings.HasPrefix(line, "[!") ||
strings.HasPrefix(line, "![") ||
strings.HasPrefix(line, "<!--") ||
strings.HasPrefix(line, "<p align=") ||
strings.HasPrefix(line, "---") ||
strings.Trim(line, "=-") == "" {
continue
}
if repo := match("go-import", 3); repo != "" {
return repo, nil
}
if repo := match("go-source", 4); repo != "" {
return repo, nil
if summary := cleanSummaryCandidate(line); summary != "" {
return summary
}
}
return ""
}
func findGitHubRepo(gopkg string) (owner string, repo string, _ error) {
ownerrepo, err := findGitHubOwnerRepo(gopkg)
if err != nil {
return "", "", fmt.Errorf("find GitHub owner repo: %w", err)
func unusableSummary(summary, gopkg string, info *pkgsiteInfo) bool {
if summary == "" {
return true
}
parts := strings.Split(ownerrepo, "/")
if got, want := len(parts), 2; got != want {
return "", "", fmt.Errorf("invalid GitHub repo: %q does not follow owner/repo", repo)
}
return parts[0], parts[1], nil
base := path.Base(gopkg)
moduleBase := path.Base(info.Module.Path)
return strings.EqualFold(summary, base) ||
strings.EqualFold(summary, moduleBase) ||
strings.EqualFold(summary, info.Package.Name)
}
func getLicenseForGopkg(gopkg string) (string, error) {
owner, repo, err := findGitHubRepo(gopkg)
if err != nil {
return "", fmt.Errorf("find GitHub repo: %w", err)
}
rl, _, err := gitHub.Repositories.License(context.TODO(), owner, repo)
if err != nil {
return "", fmt.Errorf("get license for Go package: %w", err)
}
if license, ok := githubLicenseToSPDXLicense[rl.GetLicense().GetKey()]; ok {
return license, nil
}
return "TODO", nil
}
// getDescriptionForGopkg gets the package description from GitHub,
// intended for the synopsis or the short description in debian/control.
func getDescriptionForGopkg(gopkg string) (string, error) {
owner, repo, err := findGitHubRepo(gopkg)
if err != nil {
return "", fmt.Errorf("find GitHub repo: %w", err)
}
rr, _, err := gitHub.Repositories.Get(context.TODO(), owner, repo)
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
return "", err
}
return strings.TrimSpace(rr.GetDescription()), nil
licenses := info.Module.Licenses
if len(licenses) == 0 {
licenses = info.Package.Licenses
}
return pkgsiteLicenseExpression(licenses), nil
}
// getDescriptionForGopkg gets the package synopsis from pkg.go.dev,
// intended for the summary in the RPM spec.
func getDescriptionForGopkg(gopkg string) (string, error) {
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
return "", err
}
description := cleanSummaryCandidate(info.Package.Synopsis)
if unusableSummary(description, gopkg, info) {
description = ""
}
if description == "" && info.Module.Readme != nil {
description = summaryFromReadme(info.Module.Readme.Contents)
if unusableSummary(description, gopkg, info) {
description = ""
}
}
if description == "" {
return "", fmt.Errorf("no usable synopsis for %q", gopkg)
}
return description, nil
}
+272 -73
View File
@@ -1,10 +1,12 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"golang.org/x/net/publicsuffix"
"io"
"log"
"net/http"
@@ -12,11 +14,9 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"golang.org/x/net/publicsuffix"
"golang.org/x/tools/go/vcs"
)
type packageType int
@@ -31,7 +31,8 @@ const (
// upstream describes the upstream repo we are about to package.
type upstream struct {
rr *vcs.RepoRoot
repoURL string
modulePath string
tarPath string // path to the downloaded or generated orig tarball tempfile
compression string // compression method, either "gz" or "xz"
version string // upstream version number, e.g. 0.0~git20180204.1d24609
@@ -39,6 +40,7 @@ type upstream struct {
commitIsh string // commit-ish corresponding to upstream version to be packaged
remote string // git remote, set to short hostname if upstream git history is included
firstMain string // import path of the first main package within repo, if any
packageName string // package name of the requested package from pkg.go.dev
vendorDirs []string // all vendor sub directories, relative to the repo directory
repoDeps []string // all non-stdlib imports needed for build or tests
repoRunDeps []string // non-stdlib imports needed by normal builds, excluding test-only imports
@@ -122,29 +124,92 @@ func downloadFile(filename, url string) error {
return nil
}
// get downloads the specified Go package into the provided GOPATH,
// get downloads the source module into the provided GOPATH,
// checking out the specified revision if non-empty.
func (u *upstream) get(gopath, repo, rev string) error {
func (u *upstream) get(gopath, sourceRepo, requestedPath, rev string) error {
done := make(chan struct{})
defer close(done)
go progressSize("go get", filepath.Join(gopath, "src"), done)
go progressSize("git clone", filepath.Join(gopath, "src"), done)
rr, err := vcs.RepoRootForImportPath(repo, false)
info, err := getPkgsiteInfo(context.TODO(), requestedPath)
if err != nil {
return fmt.Errorf("get repo root: %w", err)
return fmt.Errorf("get pkgsite metadata: %w", err)
}
u.rr = rr
dir := filepath.Join(gopath, "src", rr.Root)
u.packageName = info.Package.Name
u.modulePath = info.Module.Path
u.repoURL = gitCloneURLFromRepoURL(info.Module.RepoURL)
if u.repoURL == "" {
return fmt.Errorf("pkgsite module %q has no repository URL", info.Module.Path)
}
dir := filepath.Join(gopath, "src", sourceRepo)
if err := os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
return fmt.Errorf("mkdir clone parent: %w", err)
}
cmd := exec.Command("git", "clone", u.repoURL, dir)
cmd.Env = passthroughEnv()
cmd.Stderr = os.Stderr
log.Println("get: Running", cmd)
if err := cmd.Run(); err != nil {
return fmt.Errorf("git clone: %w", err)
}
if rev != "" {
// Run "git clone {repo} {dir}" and "git checkout {tag}"
return rr.VCS.CreateAtRev(dir, rr.Repo, rev)
cmd = exec.Command("git", "-c", "advice.detachedHead=false", "checkout", rev)
cmd.Dir = dir
cmd.Env = passthroughEnv()
cmd.Stderr = os.Stderr
log.Println("get: Running", cmd, "in", cmd.Dir)
if err := cmd.Run(); err != nil {
return fmt.Errorf("git checkout %q: %w", rev, err)
}
}
// Run "git clone {repo} {dir}" (or the equivalent command for hg, svn, bzr)
return rr.VCS.Create(dir, rr.Repo)
return nil
}
func (u *upstream) tarballUrl() (string, error) {
repo := strings.TrimSuffix(u.rr.Repo, ".git")
func guessedPackageType(u *upstream) packageType {
if u.packageName == "main" || (u.packageName == "" && u.firstMain != "") {
return typeProgram
}
return typeLibrary
}
// sourceImportPathForPackage returns the module root used for source checkout
// and tarball generation, while callers keep the requested import path for
// naming and go_import_path.
func sourceImportPathForPackage(requestedPath string, info *pkgsiteInfo) string {
if info != nil && info.Package.ModulePath != "" {
return info.Package.ModulePath
}
if info != nil && info.Module.Path != "" {
return info.Module.Path
}
return requestedPath
}
// gitCloneURLFromRepoURL converts repository URLs returned by pkg.go.dev into
// something git clone understands. pkg.go.dev reports golang.org/x/* modules
// with a cs.opensource.google browser URL, so rewrite those to go.googlesource.com.
func gitCloneURLFromRepoURL(repoURL string) string {
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), ".git")
u, err := url.Parse(repoURL)
if err != nil || u.Host != "cs.opensource.google" {
return repoURL
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) == 3 && parts[0] == "go" && parts[1] == "x" {
return "https://go.googlesource.com/" + parts[2]
}
return repoURL
}
func (u *upstream) tarballURLForRef(ref, compression string) (string, error) {
repo := strings.TrimSuffix(u.repoURL, ".git")
if repo == "" {
return "", fmt.Errorf("repository URL is empty")
}
repoU, err := url.Parse(repo)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
@@ -153,26 +218,138 @@ func (u *upstream) tarballUrl() (string, error) {
switch repoU.Host {
case "github.com":
return fmt.Sprintf("%s/archive/%s.tar.%s",
repo, u.tag, u.compression), nil
repo, ref, compression), nil
case "gitlab.com", "salsa.debian.org":
parts := strings.Split(repoU.Path, "/")
if len(parts) < 3 {
return "", fmt.Errorf("incomplete repo URL: %s", u.rr.Repo)
return "", fmt.Errorf("incomplete repo URL: %s", u.repoURL)
}
project := parts[2]
project := strings.TrimSuffix(parts[len(parts)-1], ".git")
return fmt.Sprintf("%s/-/archive/%s/%s-%s.tar.%s",
repo, u.tag, project, u.tag, u.compression), nil
repo, ref, project, ref, compression), nil
case "git.sr.ht":
return fmt.Sprintf("%s/archive/%s.tar.%s",
repo, u.tag, u.compression), nil
repo, ref, compression), nil
case "codeberg.org":
return fmt.Sprintf("%s/archive/%s.tar.%s",
repo, u.tag, u.compression), nil
repo, ref, compression), nil
default:
return "", errUnsupportedHoster
}
}
func (u *upstream) tarballUrl() (string, error) {
return u.tarballURLForRef(u.tag, u.compression)
}
func moduleProxyEscapedPath(modulePath string) string {
parts := strings.Split(strings.Trim(modulePath, "/"), "/")
for i, part := range parts {
parts[i] = moduleProxyEscapeString(part)
}
return strings.Join(parts, "/")
}
// moduleProxyEscapeString applies the case-escape required by the Go module
// proxy protocol: uppercase letters become "!" followed by lowercase.
// See https://go.dev/ref/mod#goproxy-protocol.
func moduleProxyEscapeString(s string) string {
var b strings.Builder
for _, r := range s {
if r >= 'A' && r <= 'Z' {
b.WriteByte('!')
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return strings.ReplaceAll(url.PathEscape(b.String()), "%21", "!")
}
// moduleProxyVersionForSpec leaves RPM macros unchanged and otherwise applies
// module proxy escaping so versions like v1.0.0-RC1 become v1.0.0-!r!c1.
func moduleProxyVersionForSpec(version string) string {
if strings.Contains(version, "%{") {
return version
}
return moduleProxyEscapeString(version)
}
func repoRootImportPath(repoURL string) string {
u, err := url.Parse(strings.TrimSuffix(repoURL, ".git"))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
switch u.Host {
case "github.com", "gitlab.com", "codeberg.org", "salsa.debian.org":
if len(parts) >= 2 {
return u.Host + "/" + strings.Join(parts[:2], "/")
}
}
return ""
}
// moduleUsesRepoSubdir reports whether modulePath lives in a repository
// subdirectory. A trailing /vN semantic import version suffix is treated as
// part of the repo-root module path, not as a subdirectory.
func moduleUsesRepoSubdir(modulePath, repoURL string) bool {
root := repoRootImportPath(repoURL)
if root == "" || !strings.HasPrefix(modulePath, root+"/") {
return false
}
rest := strings.TrimPrefix(modulePath, root+"/")
return !regexp.MustCompile(`^v\d+$`).MatchString(rest)
}
// sourceRefForSpec picks the git ref for Source0, preferring RPM macros when
// the upstream version maps cleanly to %{commit_id} or %{version}.
func (u *upstream) sourceRefForSpec() (string, error) {
ref := u.tag
if strings.Contains(u.version, "commit_id") {
ref = "%{commit_id}"
} else if u.isRelease && u.tag == "v"+u.version {
ref = "v%{version}"
} else if u.isRelease && u.tag == u.version {
ref = "%{version}"
} else if ref == "" {
ref = u.commitIsh
}
if ref == "" {
return "", fmt.Errorf("source reference is empty")
}
return ref, nil
}
// sourceURLForSpec returns the Source0 URL for the spec file. It prefers
// hoster tarballs for repo-root modules, and uses proxy.golang.org zips for
// repo subdirectories or unsupported hosters. It returns an error for
// commit-pinned subdirectory modules because the proxy requires a canonical
// pseudo-version.
func (u *upstream) sourceURLForSpec(modulePath string) (string, error) {
ref, err := u.sourceRefForSpec()
if err != nil {
return "", err
}
if moduleUsesRepoSubdir(modulePath, u.repoURL) {
if strings.Contains(ref, "commit_id") {
return "", fmt.Errorf("module proxy Source0 for commit-pinned subdirectory module %q requires a canonical pseudo-version", modulePath)
}
return fmt.Sprintf("https://proxy.golang.org/%s/@v/%s.zip#/%%{_name}-%%{version}.zip",
moduleProxyEscapedPath(modulePath), moduleProxyVersionForSpec(ref)), nil
}
tarURL, err := u.tarballURLForRef(ref, "gz")
if err == nil {
return fmt.Sprintf("%s#/%%{_name}-%%{version}.tar.gz", tarURL), nil
}
if err != errUnsupportedHoster || modulePath == "" || strings.Contains(ref, "commit_id") {
return "", err
}
return fmt.Sprintf("https://proxy.golang.org/%s/@v/%s.zip#/%%{_name}-%%{version}.zip",
moduleProxyEscapedPath(modulePath), moduleProxyVersionForSpec(ref)), nil
}
func (u *upstream) tarballFromHoster() error {
tarURL, err := u.tarballUrl()
if err != nil {
@@ -272,24 +449,29 @@ type goListPackage struct {
Imports []string
TestImports []string
XTestImports []string
Error *goListPackageError
Error *struct {
Err string
}
}
type goListPackageError struct {
Err string
func shouldIgnoreGoDependency(p, repo string) bool {
if p == "" {
return true
}
// Strip packages that are included in the repository we are packaging.
if strings.HasPrefix(p, repo+"/") || p == repo {
return true
}
if p == "C" {
// TODO: maybe parse the comments to figure out C deps from pkg-config files?
return true
}
return false
}
func addGoDependencies(deps map[string]bool, repo string, imports []string) {
for _, p := range imports {
if p == "" {
continue
}
// Strip packages that are included in the repository we are packaging.
if strings.HasPrefix(p, repo+"/") || p == repo {
continue
}
if p == "C" {
// TODO: maybe parse the comments to figure out C deps from pkg-config files?
if shouldIgnoreGoDependency(p, repo) {
continue
}
deps[p] = true
@@ -388,44 +570,30 @@ func (u *upstream) findDependencies(gopath, repo string) error {
delete(testDeps, line)
}
// Resolve all packages to the root of their repository.
//roots := make(map[string]bool)
//for dep := range godependencies {
// rr, err := vcs.RepoRootForImportPath(dep, false)
// if err != nil {
// log.Printf("Could not determine repo path for import path %q: %v\n", dep, err)
// continue
// }
// roots[rr.Root] = true
//}
//u.repoDeps = make([]string, 0, len(godependencies))
//for root := range roots {
// u.repoDeps = append(u.repoDeps, root)
//}
u.setRepoDependencies(runtimeDeps, testDeps)
return nil
}
// makeUpstreamSourceTarball downloads the specified Go package from the Internet,
// makeUpstreamSourceTarball downloads the source module from the Internet,
// checks out the specified revision, determines the version number, removes
// vendored dependencies, and creates a tarball of the upstream source code.
// It also discovers main packages and dependencies, and may check out a release
// tag different from the requested revision when pkgVersionFromGit selects one.
// It returns an upstream struct describing the downloaded package.
func makeUpstreamSourceTarball(repo, revision string, forcePrerelease bool) (*upstream, error) {
func makeUpstreamSourceTarball(requestedPath, sourceRepo, revision string, forcePrerelease bool) (*upstream, error) {
gopath, err := os.MkdirTemp("", "pack-tmp")
if err != nil {
return nil, fmt.Errorf("create tmp dir: %w", err)
}
defer os.RemoveAll(gopath)
repoDir := filepath.Join(gopath, "src", repo)
repoDir := filepath.Join(gopath, "src", sourceRepo)
var u upstream
log.Printf("Downloading %q\n", repo+"/...")
if err := u.get(gopath, repo, revision); err != nil {
return nil, fmt.Errorf("go get: %w", err)
log.Printf("Downloading %q\n", sourceRepo+"/...")
if err := u.get(gopath, sourceRepo, requestedPath, revision); err != nil {
return nil, fmt.Errorf("get source: %w", err)
}
// Verify early this repository uses git (we call pkgVersionFromGit later):
@@ -453,22 +621,43 @@ func makeUpstreamSourceTarball(repo, revision string, forcePrerelease bool) (*up
log.Printf("Determining upstream version number\n")
u.version, err = pkgVersionFromGit(repoDir, &u, revision, forcePrerelease)
preferredRevision := revision
if preferredRevision == "" {
// Reuses the cache populated by u.get above when available.
if info, err := getPkgsiteInfo(context.TODO(), requestedPath); err == nil {
preferredRevision = info.Package.Version
log.Printf("Using pkg.go.dev latest version %q as the preferred release", preferredRevision)
}
}
u.version, err = pkgVersionFromGit(repoDir, &u, preferredRevision, forcePrerelease)
if err != nil {
return nil, fmt.Errorf("get package version from Git: %w", err)
}
log.Printf("Package version is %q\n", u.version)
if err := u.findMains(gopath, repo); err != nil {
if u.isRelease && u.tag != "" {
// pkgVersionFromGit may select a release tag different from the revision
// passed by the user; dependency and tarball discovery must use that tree.
cmd := exec.Command("git", "-c", "advice.detachedHead=false", "checkout", u.tag)
cmd.Dir = repoDir
cmd.Env = passthroughEnv()
cmd.Stderr = os.Stderr
log.Println("makeUpstreamSourceTarball: Running", cmd, "in", cmd.Dir)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git checkout release tag %q: %w", u.tag, err)
}
}
if err := u.findMains(gopath, sourceRepo); err != nil {
return nil, fmt.Errorf("find mains: %w", err)
}
if err := u.findDependencies(gopath, repo); err != nil {
if err := u.findDependencies(gopath, sourceRepo); err != nil {
return nil, fmt.Errorf("find dependencies: %w", err)
}
if err := u.tar(gopath, repo); err != nil {
if err := u.tar(gopath, sourceRepo); err != nil {
return nil, fmt.Errorf("tar: %w", err)
}
@@ -538,11 +727,11 @@ func shortHostName(gopkg string, allowUnknownHoster bool) (host string, err erro
"gitlab.com": "gitlab",
"go.bug.st": "bugst",
"go.cypherpunks.ru": "cypherpunks",
"go.yaml.in": "yaml",
"go.mongodb.org": "mongodb",
"go.opentelemetry.io": "opentelemetry",
"go.step.sm": "step",
"go.uber.org": "uber",
"go.yaml.in": "yaml",
"go4.org": "go4",
"gocloud.dev": "gocloud",
"golang.org": "golang",
@@ -697,14 +886,16 @@ func mainPack(args []string, usage func()) {
// Remove URL scheme if present (https://, http://, git://, etc.)
gopkg = strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(gopkg, "https://"), "http://"), "git://")
// Verify that the provided argument is a valid Go package import path
rr, err := vcs.RepoRootForImportPath(gopkg, false)
// Verify the provided argument using the official pkg.go.dev API. Keep the
// requested path for naming and go_import_path; use the module path only for
// source checkout and tarball generation.
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
log.Fatalf("Verifying arguments: %v — did you specify a Go package import path?", err)
}
if gopkg != rr.Root {
log.Printf("Continuing with repository root %q instead of specified import path %q", rr.Root, gopkg)
gopkg = rr.Root
sourceGopkg := sourceImportPathForPackage(gopkg, info)
if sourceGopkg != gopkg {
log.Printf("Using module root %q as source checkout for specified import path %q", sourceGopkg, gopkg)
}
// Set default source and binary package names.
@@ -750,25 +941,33 @@ func mainPack(args []string, usage func()) {
// NOTE: directory existence is checked after determining final openRuyiSrc
// Create a tarball of the upstream source
u, err := makeUpstreamSourceTarball(gopkg, gitRevision, forcePrerelease)
u, err := makeUpstreamSourceTarball(gopkg, sourceGopkg, gitRevision, forcePrerelease)
if err != nil {
log.Fatalf("Could not create a tarball of the upstream source: %v\n", err)
}
if pkgType == typeGuess {
if u.firstMain != "" {
log.Printf("Assuming you are packaging a program (because %q defines a main package), use -type to override\n", u.firstMain)
switch guessedPackageType(u) {
case typeProgram:
if u.packageName == "main" {
log.Printf("Assuming you are packaging a program (because pkg.go.dev reports package %q as main), use -type to override\n", gopkg)
} else {
log.Printf("Assuming you are packaging a program (because %q defines a main package), use -type to override\n", u.firstMain)
}
pkgType = typeProgram
openRuyiSrc = nameFromGopkg(gopkg, pkgType, customProgPkgName, allowUnknownHoster)
} else {
default:
if u.firstMain != "" {
log.Printf("Found main package %q, but pkg.go.dev reports root package %q; assuming library, use -type to override\n", u.firstMain, u.packageName)
}
pkgType = typeLibrary
}
}
// Now that we know the final package name, check output directory
info, err := os.Stat(openRuyiSrc)
dirInfo, err := os.Stat(openRuyiSrc)
if err == nil {
if !info.IsDir() {
if !dirInfo.IsDir() {
log.Fatalf("%q exists but is not a directory\n", openRuyiSrc)
}
entries, err := os.ReadDir(openRuyiSrc)
+352
View File
@@ -58,6 +58,278 @@ func TestPkgsiteLicenseExpression(t *testing.T) {
}
}
func TestPkgsiteLicenseFilePaths(t *testing.T) {
licenses := []pkgsiteLicense{
{FilePath: "License", Types: []string{"MIT"}},
{FilePath: "internal/LICENSE", Types: []string{"Apache-2.0"}},
{FilePath: "License", Types: []string{"MIT"}},
}
got := strings.Join(pkgsiteLicenseFilePaths(licenses), ",")
if want := "License"; got != want {
t.Fatalf("pkgsiteLicenseFilePaths() = %q, want %q", got, want)
}
}
func TestCleanSpecAssetPath(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: "Readme", want: "Readme"},
{in: "/docs/readme.md", want: "docs/readme.md"},
{in: "../README.md", want: ""},
{in: "docs/../README.md", want: ""},
}
for _, tt := range tests {
if got := cleanSpecAssetPath(tt.in); got != tt.want {
t.Fatalf("cleanSpecAssetPath(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestSummaryFromReadme(t *testing.T) {
readme := `
# Project
[![Build Status](https://example.invalid/badge.svg)](https://example.invalid)
<p align="center">
This is the first useful line.
`
if got, want := summaryFromReadme(readme), "This is the first useful line"; got != want {
t.Fatalf("summaryFromReadme() = %q, want %q", got, want)
}
}
func TestUnusableSummary(t *testing.T) {
info := &pkgsiteInfo{
Package: pkgsitePackage{Name: "runewidth"},
Module: pkgsiteModule{Path: "github.com/mattn/go-runewidth"},
}
if !unusableSummary("go-runewidth", "github.com/mattn/go-runewidth", info) {
t.Fatalf("expected basename summary to be unusable")
}
if unusableSummary("Determines terminal display width", "github.com/mattn/go-runewidth", info) {
t.Fatalf("expected descriptive summary to be usable")
}
}
func TestCleanSummaryCandidate(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "strips html", in: `<p align="center">Package termenv styles terminals.</p>`, want: "Styles terminals"},
{name: "strips godoc package prefix", in: "Package isatty implements interface to isatty.", want: "Implements interface to isatty"},
{name: "strips markdown image", in: `![testing](https://example.invalid) Package css parses CSS.`, want: "Parses CSS"},
{name: "strips markdown link and code", in: "A [`Writer`](https://example.invalid) for ANSI output.", want: "A Writer for ANSI output"},
{name: "uses first sentence", in: "Package termenv styles terminals. It supports colors.", want: "Styles terminals"},
{name: "rejects html-only", in: `<p align="center">`, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cleanSummaryCandidate(tt.in); got != tt.want {
t.Fatalf("cleanSummaryCandidate() = %q, want %q", got, tt.want)
}
})
}
}
func TestTarballURLForRef(t *testing.T) {
tests := []struct {
name string
repoURL string
ref string
want string
}{
{
name: "github",
repoURL: "https://github.com/google/go-cmp",
ref: "v0.7.0",
want: "https://github.com/google/go-cmp/archive/v0.7.0.tar.gz",
},
{
name: "gitlab subgroup",
repoURL: "https://gitlab.com/group/subgroup/project",
ref: "v1.2.3",
want: "https://gitlab.com/group/subgroup/project/-/archive/v1.2.3/project-v1.2.3.tar.gz",
},
{
name: "sourcehut",
repoURL: "https://git.sr.ht/~user/project",
ref: "v1.0.0",
want: "https://git.sr.ht/~user/project/archive/v1.0.0.tar.gz",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := upstream{repoURL: tt.repoURL}
got, err := u.tarballURLForRef(tt.ref, "gz")
if err != nil {
t.Fatalf("tarballURLForRef() returned error: %v", err)
}
if got != tt.want {
t.Fatalf("tarballURLForRef() = %q, want %q", got, tt.want)
}
})
}
}
func TestGitCloneURLFromRepoURL(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "keeps github clone URL",
in: "https://github.com/google/go-cmp.git",
want: "https://github.com/google/go-cmp",
},
{
name: "converts Go source browser URL",
in: "https://cs.opensource.google/go/x/net",
want: "https://go.googlesource.com/net",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := gitCloneURLFromRepoURL(tt.in); got != tt.want {
t.Fatalf("gitCloneURLFromRepoURL(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestGuessedPackageTypeUsesRootPackageName(t *testing.T) {
tests := []struct {
name string
u upstream
want packageType
}{
{
name: "library root with command subpackage stays library",
u: upstream{packageName: "yaml", firstMain: "go.yaml.in/yaml/v4/cmd/go-yaml"},
want: typeLibrary,
},
{
name: "library root without command subpackage stays library",
u: upstream{packageName: "yaml"},
want: typeLibrary,
},
{
name: "root main is program",
u: upstream{packageName: "main", firstMain: "example.com/tool"},
want: typeProgram,
},
{
name: "fallback to old main detection when pkgsite name missing",
u: upstream{firstMain: "example.com/tool"},
want: typeProgram,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := guessedPackageType(&tt.u); got != tt.want {
t.Fatalf("guessedPackageType() = %v, want %v", got, tt.want)
}
})
}
}
func TestSourceImportPathForPackageUsesModuleRoot(t *testing.T) {
info := &pkgsiteInfo{Package: pkgsitePackage{ModulePath: "github.com/example/project"}}
if got, want := sourceImportPathForPackage("github.com/example/project/cmd/tool", info), "github.com/example/project"; got != want {
t.Fatalf("sourceImportPathForPackage() = %q, want %q", got, want)
}
info = &pkgsiteInfo{Module: pkgsiteModule{Path: "github.com/example/module"}}
if got, want := sourceImportPathForPackage("github.com/example/module/pkg", info), "github.com/example/module"; got != want {
t.Fatalf("sourceImportPathForPackage(module fallback) = %q, want %q", got, want)
}
if got, want := sourceImportPathForPackage("example.com/no-module", nil), "example.com/no-module"; got != want {
t.Fatalf("sourceImportPathForPackage(nil) = %q, want %q", got, want)
}
}
func TestSetRepoDependenciesSeparatesRuntimeAndTestOnly(t *testing.T) {
u := upstream{}
u.setRepoDependencies(
map[string]bool{
"github.com/runtime/dep": true,
"github.com/shared/dep": true,
},
map[string]bool{
"github.com/shared/dep": true,
"github.com/testonly/dep": true,
"github.com/testonly/dep2": true,
},
)
if got, want := strings.Join(u.repoRunDeps, ","), "github.com/runtime/dep,github.com/shared/dep"; got != want {
t.Fatalf("repoRunDeps = %q, want %q", got, want)
}
if got, want := strings.Join(u.repoTestDeps, ","), "github.com/testonly/dep,github.com/testonly/dep2"; got != want {
t.Fatalf("repoTestDeps = %q, want %q", got, want)
}
if got, want := strings.Join(u.repoDeps, ","), "github.com/runtime/dep,github.com/shared/dep,github.com/testonly/dep,github.com/testonly/dep2"; got != want {
t.Fatalf("repoDeps = %q, want %q", got, want)
}
}
func TestModuleProxyEscapedPath(t *testing.T) {
if got, want := moduleProxyEscapedPath("github.com/Azure/azure-sdk-for-go"), "github.com/!azure/azure-sdk-for-go"; got != want {
t.Fatalf("moduleProxyEscapedPath() = %q, want %q", got, want)
}
}
func TestModuleProxyVersionForSpec(t *testing.T) {
if got, want := moduleProxyVersionForSpec("v1.0.0-RC1"), "v1.0.0-!r!c1"; got != want {
t.Fatalf("moduleProxyVersionForSpec() = %q, want %q", got, want)
}
if got, want := moduleProxyVersionForSpec("v%{version}"), "v%{version}"; got != want {
t.Fatalf("moduleProxyVersionForSpec() = %q, want %q", got, want)
}
}
func TestModuleUsesRepoSubdir(t *testing.T) {
tests := []struct {
name string
modulePath string
repoURL string
want bool
}{
{
name: "real submodule",
modulePath: "github.com/charmbracelet/x/ansi",
repoURL: "https://github.com/charmbracelet/x",
want: true,
},
{
name: "semantic import version suffix is not a repo subdir",
modulePath: "github.com/aymanbagabas/go-osc52/v2",
repoURL: "https://github.com/aymanbagabas/go-osc52",
want: false,
},
{
name: "vanity module does not match repo host path",
modulePath: "go.yaml.in/yaml/v4",
repoURL: "https://github.com/yaml/go-yaml",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := moduleUsesRepoSubdir(tt.modulePath, tt.repoURL); got != tt.want {
t.Fatalf("moduleUsesRepoSubdir() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetPkgsitePackageRetriesAmbiguousPathWithLongestModule(t *testing.T) {
oldClient := pkgsiteHTTPClient
defer func() { pkgsiteHTTPClient = oldClient }()
@@ -105,3 +377,83 @@ func TestGetPkgsitePackageRetriesAmbiguousPathWithLongestModule(t *testing.T) {
t.Fatalf("requests = %#v, want retry with longest module path", requested)
}
}
func TestSourceURLForSpecUsesCommitIDMacro(t *testing.T) {
u := upstream{
repoURL: "https://github.com/example/project",
version: "0+git20260522.abcdef\n%define commit_id 0123456789abcdef",
}
got, err := u.sourceURLForSpec("github.com/example/project")
if err != nil {
t.Fatalf("sourceURLForSpec() returned error: %v", err)
}
want := "https://github.com/example/project/archive/%{commit_id}.tar.gz#/%{_name}-%{version}.tar.gz"
if got != want {
t.Fatalf("sourceURLForSpec() = %q, want %q", got, want)
}
}
func TestSourceURLForSpecKeepsVersionMacroForMatchingReleaseTag(t *testing.T) {
u := upstream{
repoURL: "https://github.com/example/project",
version: "1.2.3",
tag: "v1.2.3",
isRelease: true,
}
got, err := u.sourceURLForSpec("github.com/example/project")
if err != nil {
t.Fatalf("sourceURLForSpec() returned error: %v", err)
}
want := "https://github.com/example/project/archive/v%{version}.tar.gz#/%{_name}-%{version}.tar.gz"
if got != want {
t.Fatalf("sourceURLForSpec() = %q, want %q", got, want)
}
}
func TestSourceURLForSpecFallsBackToModuleProxy(t *testing.T) {
u := upstream{
repoURL: "https://go.googlesource.com/net",
version: "0.55.0",
tag: "v0.55.0",
isRelease: true,
}
got, err := u.sourceURLForSpec("golang.org/x/net")
if err != nil {
t.Fatalf("sourceURLForSpec() returned error: %v", err)
}
want := "https://proxy.golang.org/golang.org/x/net/@v/v%{version}.zip#/%{_name}-%{version}.zip"
if got != want {
t.Fatalf("sourceURLForSpec() = %q, want %q", got, want)
}
}
func TestSourceURLForSpecUsesModuleProxyForRepoSubmodule(t *testing.T) {
u := upstream{
repoURL: "https://github.com/charmbracelet/x",
version: "0.1.0",
tag: "v0.1.0",
isRelease: true,
}
got, err := u.sourceURLForSpec("github.com/charmbracelet/x/ansi")
if err != nil {
t.Fatalf("sourceURLForSpec() returned error: %v", err)
}
want := "https://proxy.golang.org/github.com/charmbracelet/x/ansi/@v/v%{version}.zip#/%{_name}-%{version}.zip"
if got != want {
t.Fatalf("sourceURLForSpec() = %q, want %q", got, want)
}
}
func TestSourceURLForSpecRejectsCommitIDForRepoSubmodule(t *testing.T) {
u := upstream{
repoURL: "https://github.com/charmbracelet/x",
version: "0+git20260522.abcdef\n%define commit_id abcdef1234567890",
}
_, err := u.sourceURLForSpec("github.com/charmbracelet/x/ansi")
if err == nil {
t.Fatalf("sourceURLForSpec() succeeded for commit-pinned submodule, want error")
}
if !strings.Contains(err.Error(), "canonical pseudo-version") {
t.Fatalf("sourceURLForSpec() error = %v, want canonical pseudo-version message", err)
}
}
+96 -24
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"log"
"os"
@@ -10,6 +11,65 @@ import (
"strings"
)
type specAssetFiles struct {
licenseFiles []string
readmeFile string
}
func cleanSpecAssetPath(path string) string {
path = filepath.ToSlash(strings.TrimSpace(path))
path = strings.TrimLeft(path, "/")
if path == "" || strings.HasPrefix(path, "../") || strings.Contains(path, "/../") || path == ".." {
return ""
}
return path
}
func pkgsiteLicenseFilePaths(licenses []pkgsiteLicense) []string {
topLevel := make([]pkgsiteLicense, 0, len(licenses))
for _, license := range licenses {
if !strings.Contains(strings.Trim(license.FilePath, "/"), "/") {
topLevel = append(topLevel, license)
}
}
if len(topLevel) > 0 {
licenses = topLevel
}
seen := make(map[string]bool)
paths := make([]string, 0, len(licenses))
for _, license := range licenses {
path := cleanSpecAssetPath(license.FilePath)
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func getSpecAssetFilesForGopkg(gopkg string) (specAssetFiles, error) {
info, err := getPkgsiteInfo(context.TODO(), gopkg)
if err != nil {
return specAssetFiles{}, err
}
licenses := info.Module.Licenses
if len(licenses) == 0 {
licenses = info.Package.Licenses
}
assets := specAssetFiles{
licenseFiles: pkgsiteLicenseFilePaths(licenses),
}
if info.Module.Readme != nil {
assets.readmeFile = cleanSpecAssetPath(info.Module.Readme.Filepath)
}
return assets, nil
}
func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version string,
pkgType packageType, u *upstream) error {
@@ -35,14 +95,18 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
log.Printf("Could not determine license for %q: %v\n", gopkg, err)
license = "TODO"
}
assetFiles, err := getSpecAssetFilesForGopkg(gopkg)
if err != nil {
log.Printf("Could not determine license/doc file paths for %q: %v\n", gopkg, err)
}
upstreamName := filepath.Base(gopkg)
if regexp.MustCompile(`^v\d+$`).MatchString(upstreamName) {
upstreamName = filepath.Base(filepath.Dir(gopkg))
}
owner, repo, err := findGitHubRepo(gopkg)
repoURL, err := getRepoURLForGopkg(gopkg)
if err != nil {
owner = "TODO"
repo = "TODO"
log.Printf("Could not determine repository URL for %q: %v\n", gopkg, err)
repoURL = "TODO"
}
// Write the spec file content
@@ -72,8 +136,8 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
// Header
fmt.Fprintf(f, "Name: %s\n", openRuyiSrc)
// Some times typeLibrary is treat as typeProgram,
// So we add an additional Name line, and keep one of those mannually
// Program packages may need manual naming cleanup; keep the library-form
// name visible as a second Name line until that workflow is redesigned.
switch pkgType {
case typeProgram:
fmt.Fprintf(f, "Name: %s\n", openRuyiLib)
@@ -83,15 +147,18 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
fmt.Fprintf(f, "Release: %%autorelease\n")
fmt.Fprintf(f, "Summary: %s\n", description)
fmt.Fprintf(f, "License: %s\n", license)
fmt.Fprintf(f, "URL: https://github.com/%s/%s\n", owner, repo)
fmt.Fprintf(f, "URL: %s\n", repoURL)
fmt.Fprintf(f, "#!RemoteAsset\n")
// If the computed version text contains a commit_id definition (see pkgVersionFromGit),
// use the commit_id tarball instead of v%{version}.tar.gz
if strings.Contains(u.version, "commit_id") {
fmt.Fprintf(f, "Source0: https://github.com/%s/%s/archive/%%{commit_id}.tar.gz#/%%{_name}-%%{version}.tar.gz\n", owner, repo)
} else {
fmt.Fprintf(f, "Source0: https://github.com/%s/%s/archive/v%%{version}.tar.gz#/%%{_name}-%%{version}.tar.gz\n", owner, repo)
sourceModulePath := u.modulePath
if sourceModulePath == "" {
sourceModulePath = gopkg
}
sourceURL, err := u.sourceURLForSpec(sourceModulePath)
if err != nil {
log.Printf("Could not determine remote source URL for %q: %v\n", gopkg, err)
sourceURL = "TODO"
}
fmt.Fprintf(f, "Source0: %s\n", sourceURL)
switch pkgType {
case typeLibrary:
@@ -137,7 +204,7 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
fmt.Fprintf(f, "\n")
// %files
writeRPMFilesSection(f, openRuyiSrc, openRuyiLib, openRuyiProgram, pkgType)
writeRPMFilesSection(f, openRuyiSrc, openRuyiLib, openRuyiProgram, pkgType, assetFiles)
// %changelog
fmt.Fprintf(f, "%%changelog\n")
@@ -192,45 +259,50 @@ func writeRPMProgramSubpackage(f *os.File, gopkg, openRuyiProgram, openRuyiSrc,
fmt.Fprintf(f, "This package contains the %s executable.\n", filepath.Base(gopkg))
}
func writeRPMFilesSection(f *os.File, openRuyiSrc, openRuyiLib, openRuyiProgram string, pkgType packageType) {
func writeLicenseAndDocFiles(f *os.File, assetFiles specAssetFiles, includeDoc bool) {
for _, licenseFile := range assetFiles.licenseFiles {
fmt.Fprintf(f, "%%license %s\n", licenseFile)
}
if includeDoc && assetFiles.readmeFile != "" {
fmt.Fprintf(f, "%%doc %s\n", assetFiles.readmeFile)
}
}
func writeRPMFilesSection(f *os.File, openRuyiSrc, openRuyiLib, openRuyiProgram string, pkgType packageType, assetFiles specAssetFiles) {
switch pkgType {
case typeLibrary:
fmt.Fprintf(f, "%%files\n")
fmt.Fprintf(f, "%%license LICENSE*\n")
fmt.Fprintf(f, "%%doc README*\n")
writeLicenseAndDocFiles(f, assetFiles, true)
fmt.Fprintf(f, "%%{go_sys_gopath}/%%{go_import_path}\n")
fmt.Fprintf(f, "\n")
case typeProgram:
fmt.Fprintf(f, "%%files\n")
fmt.Fprintf(f, "%%license LICENSE*\n")
fmt.Fprintf(f, "%%doc README*\n")
writeLicenseAndDocFiles(f, assetFiles, true)
fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n")
fmt.Fprintf(f, "\n")
case typeLibraryProgram:
// 库包文件(主包)
fmt.Fprintf(f, "%%files\n")
fmt.Fprintf(f, "%%license LICENSE*\n")
fmt.Fprintf(f, "%%doc README*\n")
writeLicenseAndDocFiles(f, assetFiles, true)
fmt.Fprintf(f, "%%{go_sys_gopath}/%%{go_import_path}\n")
fmt.Fprintf(f, "\n")
// 程序子包文件
fmt.Fprintf(f, "%%files -n %s\n", openRuyiProgram)
fmt.Fprintf(f, "%%license LICENSE*\n")
writeLicenseAndDocFiles(f, assetFiles, false)
fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n")
fmt.Fprintf(f, "\n")
case typeProgramLibrary:
// 程序主包文件
fmt.Fprintf(f, "%%files\n")
fmt.Fprintf(f, "%%license LICENSE*\n")
fmt.Fprintf(f, "%%doc README*\n")
writeLicenseAndDocFiles(f, assetFiles, true)
fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n")
fmt.Fprintf(f, "\n")
// 库子包文件
fmt.Fprintf(f, "%%files -n %s\n", openRuyiLib)
fmt.Fprintf(f, "%%license LICENSE*\n")
writeLicenseAndDocFiles(f, assetFiles, false)
fmt.Fprintf(f, "%%{go_sys_gopath}/%%{go_import_path}\n")
fmt.Fprintf(f, "\n")
}