feat: add contributor author #14

Open
Jvlegod wants to merge 2 commits from Jvlegod/go2spec:feat/spdx-file-contributor-author into master
7 changed files with 465 additions and 24 deletions
+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())
}
}
+11 -1
View File
@@ -1000,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",
@@ -1026,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.)
@@ -1144,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)
}
+14 -7
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,12 +112,7 @@ 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)
@@ -364,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()