Author SHA1 Message Date
Jvle 0fb570e532 feat: update: add contributor author
Signed-off-by: Jvle <keke.oerv@isrc.iscas.ac.cn>
2026-07-19 15:45:45 +08:00
Jvle 386eb655e8 feat: pack: add contributor author
Signed-off-by: Jvle <keke.oerv@isrc.iscas.ac.cn>
2026-07-19 14:03:51 +08:00
17 changed files with 491 additions and 453 deletions
-28
View File
@@ -1,28 +0,0 @@
BSD 3-Clause License
Copyright (c) 2026, Institute of Software, Chinese Academy of Sciences (ISCAS)
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES, LOSS OF USE, DATA, OR PROFITS, OR BUSINESS INTERRUPTION HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT INCLUDING NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-75
View File
@@ -1,75 +0,0 @@
# go2spec
[![Status](https://img.shields.io/badge/status-active_development-orange)](#overview)
[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
[![Language](https://img.shields.io/badge/docs-English-blue)](README.md)
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-red)](README.zh.md)
## Overview
go2spec is a Go package helper for the openRuyi RPM packaging workflow. It collects upstream metadata from the Go ecosystem, generates source archives, and writes RPM spec files.
## Installation
### Build From Source
```bash
git clone <go2spec-repo-url>
cd go2spec
go build -o go2spec .
```
Run it with:
```bash
./go2spec help
```
## Quick Start
### Generate a spec
```bash
go2spec pack golang.org/x/net
```
The command creates a file layout similar to this in the current directory:
```text
go-golang-x-net/
go-golang-x-net/go-golang-x-net.spec
go-golang-x-net_<version>.orig.tar.<gz|xz>
```
### Check existing spec updates without writing files
```bash
go2spec update -n /path/to/SPECS
```
`update` scans `/path/to/SPECS/go-*/*.spec`, then checks the latest upstream version.
### Run updates concurrently
```bash
go2spec update -j 4 /path/to/SPECS
```
> Always run dry run mode first before modifying real spec files.
## Documentation
| Area | English | Chinese |
|---|---|---|
| Main README | [README.md](README.md) | [README.zh.md](docs/zh/README.zh.md) |
| `pack` command | [docs/en/pack.en.md](docs/en/pack.en.md) | [docs/zh/pack.zh.md](docs/zh/pack.zh.md) |
| `update` command | [docs/en/update.en.md](docs/en/update.en.md) | [docs/zh/update.zh.md](docs/zh/update.zh.md) |
## Contributing
- Read the [openRuyi Code of Conduct](https://openruyi.cn/governance/legal/code-of-conduct) and agree to follow it.
- Read the [AI-assisted Contribution Policy](https://openruyi.cn/governance/policy/ai-contribution-policy), and note that this Pull Request contains substantial AI-assisted content when applicable.
## License
go2spec uses the BSD-3-Clause license. See [LICENSE](LICENSE).
-75
View File
@@ -1,75 +0,0 @@
# go2spec
[![状态](https://img.shields.io/badge/status-active_development-orange)](#项目概览)
[![许可证](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
[![English](https://img.shields.io/badge/docs-English-blue)](README.md)
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-red)](README.zh.md)
## 项目概览
go2spec 是面向 openRuyi RPM 打包流程的 Go 包辅助工具。它从 Go 生态获取上游元数据,生成源码归档,并输出 RPM spec。
## 安装指南
### 从源码构建
```bash
git clone <go2spec-repo-url>
cd go2spec
go build -o go2spec .
```
运行:
```bash
./go2spec help
```
## 快速开始
### 生成 spec
```bash
go2spec pack golang.org/x/net
```
该命令会在当前目录生成类似下面的文件结构:
```text
go-golang-x-net/
go-golang-x-net/go-golang-x-net.spec
go-golang-x-net_<version>.orig.tar.<gz|xz>
```
### 不写入文件地检查现有 spec 更新
```bash
go2spec update -n /path/to/SPECS
```
`update` 会扫描 `/path/to/SPECS/go-*/*.spec`,然后检查上游最新版本。
### 并发执行更新
```bash
go2spec update -j 4 /path/to/SPECS
```
> 建议始终先执行 dry run,再修改真实 spec 文件。
## 文档导航
| 内容 | English | 中文 |
|---|---|---|
| 主 README | [README.md](README.md) | [README.zh.md](README.zh.md) |
| `pack` 命令 | [docs/en/pack.en.md](docs/en/pack.en.md) | [docs/zh/pack.zh.md](docs/zh/pack.zh.md) |
| `update` 命令 | [docs/en/update.en.md](docs/en/update.en.md) | [docs/zh/update.zh.md](docs/zh/update.zh.md) |
## 贡献
- 阅读 [openRuyi 行为准则](https://openruyi.cn/governance/legal/code-of-conduct) 并同意遵守。
- 阅读 [AI 辅助贡献政策](https://openruyi.cn/governance/policy/ai-contribution-policy),且此 Pull Request 包含实质性的 AI 辅助内容时需要说明。
## 许可证
go2spec 使用 BSD-3-Clause 许可证,见 [LICENSE](LICENSE)。
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"fmt"
"os"
"strings"
)
const specAuthorEnv = "GO2SPEC_AUTHOR"
type specAuthor struct {
Name string
Email string
}
func (a specAuthor) IsZero() bool {
return a.Name == "" && a.Email == ""
}
func (a specAuthor) String() string {
return fmt.Sprintf("%s <%s>", a.Name, a.Email)
}
func parseSpecAuthor(raw string) (specAuthor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return specAuthor{}, fmt.Errorf("author is empty")
}
if strings.ContainsAny(raw, "\r\n") {
return specAuthor{}, fmt.Errorf("author must be a single line")
}
if !strings.HasSuffix(raw, ">") {
return specAuthor{}, fmt.Errorf("author must use Name <email> format")
}
start := strings.LastIndex(raw, "<")
if start < 0 {
return specAuthor{}, fmt.Errorf("author must use Name <email> format")
}
name := strings.TrimSpace(raw[:start])
email := strings.TrimSpace(strings.TrimSuffix(raw[start+1:], ">"))
if name == "" || email == "" {
return specAuthor{}, fmt.Errorf("author must include both name and email")
}
if strings.ContainsAny(email, "<>") {
return specAuthor{}, fmt.Errorf("author email must not contain angle brackets")
}
return specAuthor{Name: name, Email: email}, nil
}
func resolveSpecAuthor(raw string) (specAuthor, error) {
if strings.TrimSpace(raw) != "" {
return parseSpecAuthor(raw)
}
if envAuthor, ok := os.LookupEnv(specAuthorEnv); ok && strings.TrimSpace(envAuthor) != "" {
return parseSpecAuthor(envAuthor)
}
return specAuthor{}, nil
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestParseSpecAuthor(t *testing.T) {
tests := []struct {
name string
raw string
want specAuthor
wantErr bool
}{
{
name: "valid author",
raw: "Test Author <author@example.invalid>",
want: specAuthor{Name: "Test Author", Email: "author@example.invalid"},
},
{
name: "missing email",
raw: "Test Author",
wantErr: true,
},
{
name: "missing name",
raw: "<author@example.invalid>",
wantErr: true,
},
{
name: "empty email",
raw: "Test Author <>",
wantErr: true,
},
{
name: "multiline author",
raw: "Test Author <author@example.invalid>\nOther <other@example.invalid>",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseSpecAuthor(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("parseSpecAuthor() succeeded, want error")
}
return
}
if err != nil {
t.Fatalf("parseSpecAuthor(): %v", err)
}
if got != tt.want {
t.Fatalf("parseSpecAuthor() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestResolveSpecAuthorUsesExplicitAuthor(t *testing.T) {
t.Setenv(specAuthorEnv, "Env Author <env@example.invalid>")
got, err := resolveSpecAuthor("Test Author <author@example.invalid>")
if err != nil {
t.Fatalf("resolveSpecAuthor(): %v", err)
}
want := specAuthor{Name: "Test Author", Email: "author@example.invalid"}
if got != want {
t.Fatalf("resolveSpecAuthor() = %#v, want %#v", got, want)
}
}
func TestResolveSpecAuthorUsesEnvAuthor(t *testing.T) {
t.Setenv(specAuthorEnv, "Test Author <author@example.invalid>")
got, err := resolveSpecAuthor("")
if err != nil {
t.Fatalf("resolveSpecAuthor(): %v", err)
}
want := specAuthor{Name: "Test Author", Email: "author@example.invalid"}
if got != want {
t.Fatalf("resolveSpecAuthor() = %#v, want %#v", got, want)
}
}
func TestResolveSpecAuthorAllowsMissingAuthor(t *testing.T) {
t.Setenv(specAuthorEnv, "")
got, err := resolveSpecAuthor("")
if err != nil {
t.Fatalf("resolveSpecAuthor(): %v", err)
}
if !got.IsZero() {
t.Fatalf("resolveSpecAuthor() = %#v, want zero author", got)
}
}
func TestResolveSpecAuthorRejectsInvalidEnvAuthor(t *testing.T) {
t.Setenv(specAuthorEnv, "Test Author")
if _, err := resolveSpecAuthor(""); err == nil {
t.Fatalf("resolveSpecAuthor() succeeded with invalid env author, want error")
}
}
func TestWriteSPDXHeaderIncludesContributor(t *testing.T) {
var buf bytes.Buffer
writeSPDXHeader(&buf, specAuthor{Name: "Test Author", Email: "author@example.invalid"})
want := "# SPDX-FileContributor: Test Author <author@example.invalid>\n"
if !strings.Contains(buf.String(), want) {
t.Fatalf("writeSPDXHeader() missing %q in:\n%s", want, buf.String())
}
}
func TestWriteSPDXHeaderOmitsEmptyContributor(t *testing.T) {
var buf bytes.Buffer
writeSPDXHeader(&buf, specAuthor{})
if strings.Contains(buf.String(), "SPDX-FileContributor") {
t.Fatalf("writeSPDXHeader() included empty contributor:\n%s", buf.String())
}
}
+4 -4
View File
@@ -38,8 +38,9 @@ func shortCommitHash(hash string) string {
// pkgVersionFromGit determines the actual version to be packaged
// from the git repository status and user preference.
// Besides returning the upstream version, the "upstream" struct fields
// u.version, u.commitID, u.commitIsh, u.hasRelease and u.isRelease are also set.
// Besides returning the upstream version, the "upstream" struct
// struct fields u.version, u.commitIsh, u.hasRelease and u.isRelease
// are also set.
// `preferredRev` should be empty if there are no user preferences.
// TODO: also support other VCS
func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePrerelease bool) (string, error) {
@@ -128,10 +129,9 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre
}
fullCommitHash := strings.TrimSpace(string(fullCommitHashBytes))
lastCommitHash := shortCommitHash(fullCommitHash)
u.commitID = fullCommitHash
u.commitIsh = lastCommitHash
// Snapshot versions are based on the packaging date, not the commit date.
u.version = fmt.Sprintf("0+git%s.%s", packagingDateString(), lastCommitHash)
u.version = fmt.Sprintf("0+git%s.%s\n%%define commit_id %s", packagingDateString(), lastCommitHash, fullCommitHash)
return u.version, nil
}
-58
View File
@@ -1,58 +0,0 @@
# pack Command
[![中文](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-red)](../zh/pack.zh.md)
[![update command](https://img.shields.io/badge/docs-update-blue)](update.en.md)
`pack` generates an RPM spec draft and a source archive from a Go import path.
**This command is intended to produce a maintainable starting point, not a final package that can be submitted directly. Always review the generated spec manually before submitting it to openRuyi or using it for builds.**
## Usage
```bash
go2spec pack [FLAG]... <go-package-importpath>
```
Example:
```bash
go2spec pack golang.org/x/net
```
## Flags
```text
-git_revision <rev>
Check out a specific git revision, such as a tag, branch, or commit
-force_prerelease
Package HEAD or tip instead of preferring the latest tagged version
-type <type>
Select the package type, supported values are library, program, library+program, and program+library
-program_package_name <name>
Override the program package name, and the source package name when appropriate
-allow_unknown_hoster
Allow package-name generation for unknown hosters, Review the generated name manually
```
## Output
A successful run creates these files in the current directory:
- `<package-name>/`, package working directory
- `<package-name>/<package-name>.spec`, generated RPM spec draft
- `<package-name>_<version>.orig.tar.<gz|xz>`, upstream source archive
If the target package directory already exists and is not empty, go2spec aborts to avoid overwriting existing work.
## Package Types
- `library`, for Go library modules
- `program`, for command packages whose root package is `main`
- `library+program`, for projects that ship both library files and command subpackages
- `program+library`, for program-first projects that also need a library subpackage
When `-type` is omitted, go2spec uses pkg.go.dev package metadata and local `go list` output to infer the type.
-46
View File
@@ -1,46 +0,0 @@
# update Command
[![中文](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-red)](../zh/update.zh.md)
[![pack command](https://img.shields.io/badge/docs-pack-blue)](pack.en.md)
`update` scans existing Go spec packages, checks the latest upstream version, and can update the spec version.
## Usage
```bash
go2spec update [options] <SPECS_DIR>
```
`<SPECS_DIR>` must contain `go-*` subdirectories with spec files.
```text
SPECS_DIR/
go-golang-x-net/
go-golang-x-net/go-golang-x-net.spec
```
## Options
```text
-n
Dry run, detect updates only, do not modify files
-j <num>
Number of concurrent checks, default is 3
```
## Recommended Workflow
Start with dry run mode:
```bash
go2spec update -n /path/to/SPECS
```
Apply updates after reviewing the output:
```bash
go2spec update -j 4 /path/to/SPECS
```
Review the generated diff before committing packaging changes.
-58
View File
@@ -1,58 +0,0 @@
# pack 命令
[![English](https://img.shields.io/badge/docs-English-blue)](../en/pack.en.md)
[![update 命令](https://img.shields.io/badge/docs-update-blue)](update.zh.md)
`pack` 根据 Go import path 生成 RPM spec 初稿和源码归档。
**该命令用于生成可继续维护的起点,不是最终可直接提交的包,提交到 openRuyi 或用于构建前必须人工 review 生成的 spec。**
## 用法
```bash
go2spec pack [FLAG]... <go-package-importpath>
```
示例:
```bash
go2spec pack golang.org/x/net
```
## 参数
```text
-git_revision <rev>
checkout 指定 git revision,例如 tag、branch 或 commit
-force_prerelease
打包 HEAD 或 tip,而不是优先选择最新 tag
-type <type>
选择包类型,支持 library、program、library+program 和 program+library
-program_package_name <name>
覆盖程序包名,适用时也覆盖源包名
-allow_unknown_hoster
允许未知 hoster 参与包名生成,生成结果必须人工检查
```
## 输出
执行成功后会在当前目录创建:
- `<package-name>/`package 工作目录
- `<package-name>/<package-name>.spec`,生成的 RPM spec 初稿
- `<package-name>_<version>.orig.tar.<gz|xz>`,上游源码归档
如果目标 package 目录已存在且非空,go2spec 会停止执行,以避免覆盖已有工作。
## 包类型
- `library`,适合 Go library module
- `program`,适合根 package 为 `main` 的命令包
- `library+program`,适合同时发布库文件和命令子包的项目
- `program+library`,适合以程序为主,同时需要库子包的项目
不传 `-type` 时,go2spec 会根据 pkg.go.dev package 元数据和本地 `go list` 输出推断类型。
-46
View File
@@ -1,46 +0,0 @@
# update 命令
[![English](https://img.shields.io/badge/docs-English-blue)](../en/update.en.md)
[![pack 命令](https://img.shields.io/badge/docs-pack-blue)](pack.zh.md)
`update` 扫描已有 Golang spec 包,检查上游最新版本,并可更新 spec 版本。
## 用法
```bash
go2spec update [options] <SPECS_DIR>
```
`<SPECS_DIR>` 必须包含带 spec 文件的 `go-*` 子目录。
```text
SPECS_DIR/
go-golang-x-net/
go-golang-x-net/go-golang-x-net.spec
```
## 参数
```text
-n
dry run,只检测更新,不修改文件
-j <num>
并发检查数量,默认 3
```
## 推荐流程
先执行 dry run
```bash
go2spec update -n /path/to/SPECS
```
确认输出后再执行更新:
```bash
go2spec update -j 4 /path/to/SPECS
```
提交 packaging 变更前必须 review 生成的 diff。
+12 -3
View File
@@ -38,7 +38,6 @@ type upstream struct {
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
commitID string // full commit hash for commit-pinned snapshot builds
tag string // Latest upstream tag, if any
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
@@ -328,7 +327,7 @@ func moduleUsesRepoSubdir(modulePath, repoURL string) bool {
// the upstream version maps cleanly to %{commit_id} or %{version}.
func (u *upstream) sourceRefForSpec() (string, error) {
ref := u.tag
if u.commitID != "" {
if strings.Contains(u.version, "commit_id") {
ref = "%{commit_id}"
} else if u.isRelease && u.tag == "v"+u.version {
ref = "v%{version}"
@@ -1001,6 +1000,12 @@ func mainPack(args []string, usage func()) {
"Override the program package name, and the source package name too\n"+
"when appropriate, e.g. to name github.com/cli/cli as \"gh\"")
var authorString string
flagSet.StringVar(&authorString,
"author",
"",
"Set optional SPDX-FileContributor author in Name <email> format, defaulting to GO2SPEC_AUTHOR when set")
var allowUnknownHoster bool
flagSet.BoolVar(&allowUnknownHoster,
"allow_unknown_hoster",
@@ -1027,6 +1032,10 @@ func mainPack(args []string, usage func()) {
}
gitRevision = strings.TrimSpace(gitRevision)
author, err := resolveSpecAuthor(authorString)
if err != nil {
log.Fatalf("resolve author: %v", err)
}
gopkg := flagSet.Arg(0)
// Remove URL scheme if present (https://, http://, git://, etc.)
@@ -1145,7 +1154,7 @@ func mainPack(args []string, usage func()) {
}
if err := writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, u.version,
pkgType, u); err != nil {
pkgType, u, author); err != nil {
log.Fatalf("Could not create spec file: %v\n", err)
}
+4 -8
View File
@@ -358,7 +358,10 @@ func pkgsiteLicenseExpression(licenses []pkgsiteLicense) string {
types = append(types, typ)
}
sort.Strings(types)
group := strings.Join(types, " OR ")
group := types[0]
if len(types) > 1 {
group = "(" + strings.Join(types, " OR ") + ")"
}
seenGroups[group] = true
}
if len(seenGroups) == 0 {
@@ -370,12 +373,5 @@ func pkgsiteLicenseExpression(licenses []pkgsiteLicense) string {
groups = append(groups, group)
}
sort.Strings(groups)
if len(groups) > 1 {
for i, group := range groups {
if strings.Contains(group, " OR ") {
groups[i] = "(" + group + ")"
}
}
}
return strings.Join(groups, " AND ")
}
+6 -22
View File
@@ -48,15 +48,7 @@ func TestPkgsiteLicenseExpression(t *testing.T) {
licenses: []pkgsiteLicense{
{FilePath: "LICENSE", Types: []string{"MIT", "Apache-2.0"}},
},
want: "Apache-2.0 OR MIT",
},
{
name: "parenthesizes OR group when joined with AND",
licenses: []pkgsiteLicense{
{FilePath: "LICENSE", Types: []string{"MIT", "Apache-2.0"}},
{FilePath: "COPYING", Types: []string{"BSD-3-Clause"}},
},
want: "(Apache-2.0 OR MIT) AND BSD-3-Clause",
want: "(Apache-2.0 OR MIT)",
},
{
name: "uses TODO when pkgsite has no SPDX type",
@@ -436,9 +428,8 @@ func TestGetPkgsitePackageRetriesAmbiguousPathWithLongestModule(t *testing.T) {
func TestSourceURLForSpecUsesCommitIDMacro(t *testing.T) {
u := upstream{
repoURL: "https://github.com/example/project",
version: "0+git20260522.abcdef",
commitID: "0123456789abcdef",
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 {
@@ -503,9 +494,8 @@ func TestSourceURLForSpecUsesModuleProxyForRepoSubmodule(t *testing.T) {
func TestSourceURLForSpecRejectsCommitIDForRepoSubmodule(t *testing.T) {
u := upstream{
repoURL: "https://github.com/charmbracelet/x",
version: "0+git20260522.abcdef",
commitID: "abcdef1234567890",
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 {
@@ -539,7 +529,7 @@ func TestPkgVersionFromGitUsesPackagingDateAndSevenCharHash(t *testing.T) {
}, "commit", "-m", "initial")
fullHash := strings.TrimSpace(runGit(t, dir, nil, "rev-parse", "HEAD"))
want := "0+git20250808." + fullHash[:7]
want := "0+git20250808." + fullHash[:7] + "\n%define commit_id " + fullHash
u := upstream{}
got, err := pkgVersionFromGit(dir, &u, "", false)
@@ -549,12 +539,6 @@ func TestPkgVersionFromGitUsesPackagingDateAndSevenCharHash(t *testing.T) {
if got != want {
t.Fatalf("pkgVersionFromGit() = %q, want %q", got, want)
}
if strings.Contains(got, "\n") || strings.Contains(got, "commit_id") {
t.Fatalf("pkgVersionFromGit() returned metadata in version: %q", got)
}
if u.commitID != fullHash {
t.Fatalf("commitID = %q, want %q", u.commitID, fullHash)
}
if strings.Contains(got, "19990102") {
t.Fatalf("pkgVersionFromGit() used commit date: %q", got)
}
+25 -14
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -71,7 +72,7 @@ func getSpecAssetFilesForGopkg(gopkg string) (specAssetFiles, error) {
}
func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version string,
pkgType packageType, u *upstream) error {
pkgType packageType, u *upstream, author specAuthor) error {
f, err := os.Create(filepath.Join(dir, "", openRuyiSrc+".spec"))
if err != nil {
@@ -111,19 +112,19 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
// Write the spec file content
// SPDX header
fmt.Fprintf(f, "# SPDX-FileCopyrightText: (C) 2026 Institute of Software, Chinese Academy of Sciences (ISCAS)\n")
fmt.Fprintf(f, "# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors\n")
fmt.Fprintf(f, "#\n")
fmt.Fprintf(f, "# SPDX-License-Identifier: MulanPSL-2.0\n")
fmt.Fprintf(f, "\n")
writeSPDXHeader(f, author)
// Macros
fmt.Fprintf(f, "%%define _name %s\n", upstreamName)
fmt.Fprintf(f, "%%define go_import_path %s\n", gopkg)
if u.commitID != "" {
fmt.Fprintf(f, "%%define commit_id %s\n", u.commitID)
// If pkgVersionFromGit embedded a commit_id define in u.version, extract and write it here
commitRe := regexp.MustCompile(`%define\s+commit_id\s+([0-9a-fA-F]+)`)
if m := commitRe.FindStringSubmatch(u.version); m != nil {
fmt.Fprintf(f, "%%define commit_id %s\n", m[1])
// Remove the embedded %define line from version so it doesn't get written to Version: field
version = commitRe.ReplaceAllString(version, "")
version = strings.TrimSpace(version)
}
fmt.Fprintf(f, "\n")
@@ -196,18 +197,17 @@ func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version st
log.Fatalf("Invalid pkgType %d in writeRPMSpec(), aborting", pkgType)
}
if pkgType != typeLibrary {
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "\n")
fmt.Fprintf(f, "%%description\n")
fmt.Fprintf(f, "%s\n\n", strings.TrimRight(longdescription, "\n"))
fmt.Fprintf(f, "%s\n", longdescription)
fmt.Fprintf(f, "\n")
// %files
writeRPMFilesSection(f, openRuyiSrc, openRuyiLib, openRuyiProgram, pkgType, assetFiles)
// %changelog
fmt.Fprintf(f, "%%changelog\n")
fmt.Fprintf(f, "%%autochangelog\n\n")
fmt.Fprintf(f, "%%{?autochangelog}\n\n")
return nil
}
@@ -360,3 +360,14 @@ func convertDependenciesToRPM(goPkgs []string) []string {
return rpmDeps
}
func writeSPDXHeader(w io.Writer, author specAuthor) {
fmt.Fprintf(w, "# SPDX-FileCopyrightText: (C) 2026 Institute of Software, Chinese Academy of Sciences (ISCAS)\n")
fmt.Fprintf(w, "# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors\n")
if !author.IsZero() {
fmt.Fprintf(w, "# SPDX-FileContributor: %s\n", author.String())
}
fmt.Fprintf(w, "#\n")
fmt.Fprintf(w, "# SPDX-License-Identifier: MulanPSL-2.0\n")
fmt.Fprintf(w, "\n")
}
+49 -5
View File
@@ -295,7 +295,42 @@ func writeSpecLines(path string, lines []string) error {
return w.Flush()
}
func updateSpecVersion(spec *specInfo, newVersion, remoteSHA256 string, updateRemoteAsset bool) error {
func ensureSpecContributor(lines []string, author specAuthor) ([]string, error) {
if author.IsZero() {
return lines, nil
}
contributor := "# SPDX-FileContributor: " + author.String()
lastContributorIndex := -1
licenseIndex := -1
for i, line := range lines {
if line == contributor {
return lines, nil
}
if strings.HasPrefix(line, "# SPDX-FileContributor:") {
lastContributorIndex = i
}
if licenseIndex < 0 && strings.HasPrefix(line, "# SPDX-License-Identifier:") {
licenseIndex = i
}
}
insertIndex := 0
if lastContributorIndex >= 0 {
insertIndex = lastContributorIndex + 1
} else if licenseIndex > 0 && strings.TrimSpace(lines[licenseIndex-1]) == "#" {
insertIndex = licenseIndex - 1
} else if licenseIndex >= 0 {
insertIndex = licenseIndex
}
lines = append(lines, "")
copy(lines[insertIndex+1:], lines[insertIndex:])
lines[insertIndex] = contributor
return lines, nil
}
func updateSpecVersion(spec *specInfo, newVersion, remoteSHA256 string, updateRemoteAsset bool, author specAuthor) error {
lines, err := readSpecLines(spec.FilePath)
if err != nil {
return err
@@ -329,6 +364,10 @@ func updateSpecVersion(spec *specInfo, newVersion, remoteSHA256 string, updateRe
return fmt.Errorf("Version line not found in %s", spec.FilePath)
}
if !updateRemoteAsset {
lines, err = ensureSpecContributor(lines, author)
if err != nil {
return err
}
return writeSpecLines(spec.FilePath, lines)
}
if source0RemoteAssetIndex >= 0 {
@@ -346,6 +385,11 @@ func updateSpecVersion(spec *specInfo, newVersion, remoteSHA256 string, updateRe
lines = append(lines[:source0Index], append([]string{insert}, lines[source0Index:]...)...)
}
lines, err = ensureSpecContributor(lines, author)
if err != nil {
return err
}
return writeSpecLines(spec.FilePath, lines)
}
@@ -354,7 +398,7 @@ type downloadResult struct {
err error
}
func verifyAndUpdateSpec(ctx context.Context, spec *specInfo, dryRun bool) error {
func verifyAndUpdateSpec(ctx context.Context, spec *specInfo, dryRun bool, author specAuthor) error {
err := verifyDownload(ctx, spec)
if err != nil {
return fmt.Errorf("download verification failed: %w", err)
@@ -372,7 +416,7 @@ func verifyAndUpdateSpec(ctx context.Context, spec *specInfo, dryRun bool) error
return fmt.Errorf("compute remote asset sha256: %w", err)
}
if err := updateSpecVersion(spec, newVer, remoteSHA256, updateRemoteAsset); err != nil {
if err := updateSpecVersion(spec, newVer, remoteSHA256, updateRemoteAsset, author); err != nil {
return fmt.Errorf("update spec file: %w", err)
}
}
@@ -380,7 +424,7 @@ func verifyAndUpdateSpec(ctx context.Context, spec *specInfo, dryRun bool) error
return nil
}
func processUpdate(ctx context.Context, spec *specInfo, dryRun bool) error {
func processUpdate(ctx context.Context, spec *specInfo, dryRun bool, author specAuthor) error {
err := verifyDownload(ctx, spec)
if err != nil {
return err
@@ -398,7 +442,7 @@ func processUpdate(ctx context.Context, spec *specInfo, dryRun bool) error {
return fmt.Errorf("compute remote asset sha256: %w", err)
}
if err := updateSpecVersion(spec, newVer, remoteSHA256, updateRemoteAsset); err != nil {
if err := updateSpecVersion(spec, newVer, remoteSHA256, updateRemoteAsset, author); err != nil {
return fmt.Errorf("update spec file: %w", err)
}
}
+14 -6
View File
@@ -312,6 +312,7 @@ func mainUpdate(args []string) int {
fs.SetOutput(os.Stderr)
concurrency := fs.Int("j", 3, "number of concurrent checks")
dryRun := fs.Bool("n", false, "dry run: detect updates only, do not modify files")
authorString := fs.String("author", "", "Set optional SPDX-FileContributor author in Name <email> format, defaulting to GO2SPEC_AUTHOR when set")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: %s update [options] <SPECS_DIR>\n\n", os.Args[0])
fmt.Fprintf(fs.Output(), "Check Go module spec files for updates.\n\n")
@@ -389,13 +390,20 @@ func mainUpdate(args []string) int {
sp.Error = err.Error()
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
atomic.AddInt32(&c.upErrors, 1)
} else if err := updateSpecVersion(sp, newVer, remoteSHA256, updateRemoteAsset); err != nil {
sp.Error = err.Error()
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
atomic.AddInt32(&c.upErrors, 1)
} else {
atomic.AddInt32(&c.modified, 1)
out.print(fmt.Sprintf(" OK: %s updated to %s", sp.GoImportPath, newVer))
author, err := resolveSpecAuthor(*authorString)
if err != nil {
sp.Error = err.Error()
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
atomic.AddInt32(&c.upErrors, 1)
} else if err := updateSpecVersion(sp, newVer, remoteSHA256, updateRemoteAsset, author); err != nil {
sp.Error = err.Error()
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
atomic.AddInt32(&c.upErrors, 1)
} else {
atomic.AddInt32(&c.modified, 1)
out.print(fmt.Sprintf(" OK: %s updated to %s", sp.GoImportPath, newVer))
}
}
}
atomic.AddInt32(&c.processed, 1)
+189 -5
View File
@@ -15,6 +15,8 @@ import (
"testing"
)
var testSpecAuthor = specAuthor{Name: "Test Author", Email: "author@example.invalid"}
func TestUpdateSpecVersionPreservesFormatting(t *testing.T) {
t.Parallel()
@@ -32,7 +34,7 @@ func TestUpdateSpecVersionPreservesFormatting(t *testing.T) {
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", strings.Repeat("a", 64), true); err != nil {
if err := updateSpecVersion(spec, "2.0.0", strings.Repeat("a", 64), true, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
@@ -66,7 +68,7 @@ func TestUpdateSpecVersionReturnsErrorWhenVersionLineMissing(t *testing.T) {
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", strings.Repeat("a", 64), true); err == nil {
if err := updateSpecVersion(spec, "2.0.0", strings.Repeat("a", 64), true, testSpecAuthor); err == nil {
t.Fatal("expected updateSpecVersion to fail when Version line is missing")
}
}
@@ -223,7 +225,7 @@ func TestUpdateSpecVersionOnlyUpdatesSource0RemoteAsset(t *testing.T) {
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", source0Hash, true); err != nil {
if err := updateSpecVersion(spec, "2.0.0", source0Hash, true, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
@@ -258,7 +260,7 @@ func TestUpdateSpecVersionClearsRemoteAssetWhenHashEmpty(t *testing.T) {
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", true); err != nil {
if err := updateSpecVersion(spec, "2.0.0", "", true, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
@@ -296,7 +298,7 @@ func TestUpdateSpecVersionKeepsRemoteAssetWhenUpdateDisabled(t *testing.T) {
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false); err != nil {
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
@@ -314,6 +316,188 @@ func TestUpdateSpecVersionKeepsRemoteAssetWhenUpdateDisabled(t *testing.T) {
}
}
func TestUpdateSpecVersionDoesNotDuplicateExistingContributor(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
contributor := "# SPDX-FileContributor: " + testSpecAuthor.String()
original := strings.Join([]string{
"# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors",
contributor,
"# SPDX-License-Identifier: MulanPSL-2.0",
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if count := strings.Count(string(updated), contributor); count != 1 {
t.Fatalf("contributor count = %d, want 1:\n%s", count, string(updated))
}
}
func TestUpdateSpecVersionAppendsDifferentContributor(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
original := strings.Join([]string{
"# SPDX-FileContributor: Existing Author <existing@example.invalid>",
"# SPDX-License-Identifier: MulanPSL-2.0",
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
want := "# SPDX-FileContributor: Existing Author <existing@example.invalid>\n# SPDX-FileContributor: " + testSpecAuthor.String() + "\n# SPDX-License-Identifier:"
if !strings.Contains(string(updated), want) {
t.Fatalf("contributor was not appended after existing contributor:\n%s", string(updated))
}
}
func TestUpdateSpecVersionInsertsContributorBeforeSeparator(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
original := strings.Join([]string{
"# SPDX-FileCopyrightText: (C) 2026 Institute of Software, Chinese Academy of Sciences (ISCAS)",
"# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors",
"#",
"# SPDX-License-Identifier: MulanPSL-2.0",
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
want := "# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors\n# SPDX-FileContributor: " + testSpecAuthor.String() + "\n#\n# SPDX-License-Identifier:"
if !strings.Contains(string(updated), want) {
t.Fatalf("contributor was not inserted before separator:\n%s", string(updated))
}
}
func TestUpdateSpecVersionInsertsContributorBeforeLicense(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
original := strings.Join([]string{
"# SPDX-FileCopyrightText: (C) 2026 openRuyi Project Contributors",
"# SPDX-License-Identifier: MulanPSL-2.0",
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
want := "# SPDX-FileContributor: " + testSpecAuthor.String() + "\n# SPDX-License-Identifier:"
if !strings.Contains(string(updated), want) {
t.Fatalf("contributor was not inserted before license:\n%s", string(updated))
}
}
func TestUpdateSpecVersionInsertsContributorAtStartWithoutHeader(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
original := strings.Join([]string{
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, testSpecAuthor); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
wantPrefix := "# SPDX-FileContributor: " + testSpecAuthor.String() + "\nName:"
if !strings.HasPrefix(string(updated), wantPrefix) {
t.Fatalf("contributor was not inserted at start:\n%s", string(updated))
}
}
func TestUpdateSpecVersionOmitsMissingContributorAuthor(t *testing.T) {
t.Parallel()
dir := t.TempDir()
specPath := filepath.Join(dir, "test.spec")
original := strings.Join([]string{
"Name: golang-test",
"Version: 1.0.0",
}, "\n")
if err := os.WriteFile(specPath, []byte(original), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
spec := &specInfo{FilePath: specPath}
if err := updateSpecVersion(spec, "2.0.0", "", false, specAuthor{}); err != nil {
t.Fatalf("updateSpecVersion: %v", err)
}
updated, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read spec: %v", err)
}
got := string(updated)
if !strings.Contains(got, "Version: 2.0.0") {
t.Fatalf("version was not updated:\n%s", got)
}
if strings.Contains(got, "SPDX-FileContributor") {
t.Fatalf("empty author should not add contributor:\n%s", got)
}
}
func TestResolvedSource0URLSkipsPinnedCommit(t *testing.T) {
t.Parallel()