From 27b86f755102a1318a9cd3385c357dd3ad3e03b0 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Mon, 27 Jul 2026 20:59:45 +0800 Subject: [PATCH] ci: close final release audit gaps --- .github/workflows/go.yml | 2 +- CHANGELOG.md | 5 + CONTRIBUTING.md | 9 +- internal/selfmanage/release_pipeline_test.go | 168 +++++++++++++++++-- internal/selfmanage/release_pubkey.hex | 5 +- 5 files changed, 167 insertions(+), 22 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e354780..7aa8b4b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -52,7 +52,7 @@ jobs: - name: Integration tests (root) # Root integration packages share the host account databases and must # not run useradd/userdel concurrently across package processes. - run: sudo -E env "PATH=$PATH" go test -race -p 1 -tags integration ./... + run: sudo -E env "PATH=$PATH" go test -count=1 -race -p 1 -tags integration ./... static-cross-build: name: Static cross-build (${{ matrix.goarch }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e2123..6e25ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project are documented here. and remains only as an audit record; no published v2.8.1 artifacts exist. - Record that the current v1 ed25519 release key has historical network-host exposure instead of claiming an air-gapped custody history it does not have. +- Keep documented root integration runs uncached and package-serialized, and + scan every workflow plus the contributor commands for unsafe parallel forms. +- Adopt the single-maintainer release model: protected `main` requires no + independent approval, CODEOWNERS remains metadata, last-push approval is not + required, and neither protected release environment has required reviewers. ## v2.8.1 - 2026-07-27 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c05fd97..7c90328 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,14 +23,17 @@ go vet -tags integration -printf.funcs=printf,errorf,warnf ./... test -z "$(gofmt -l .)" # gofmt must be clean go test -count=1 ./... go test -count=1 -race ./... -sudo -E env "PATH=$PATH" go test -count=1 -tags integration ./... -sudo -E env "PATH=$PATH" go test -count=1 -race -tags integration ./... +sudo -E env "PATH=$PATH" go test -count=1 -p 1 -tags integration ./... +sudo -E env "PATH=$PATH" go test -count=1 -race -p 1 -tags integration ./... staticcheck ./... staticcheck -tags integration ./... govulncheck ./... ``` -The integration suites use fixed disposable account names. Run the two integration commands **serially**, never concurrently, and only on a disposable host. +The integration suites use fixed disposable account names and shared host account +databases. Each command serializes package processes with `-p 1`; also run the +two commands themselves serially, never concurrently, and only on a disposable +host. **Release/install scripts**, if you touch `scripts/`: diff --git a/internal/selfmanage/release_pipeline_test.go b/internal/selfmanage/release_pipeline_test.go index e2e9c65..6ec0543 100644 --- a/internal/selfmanage/release_pipeline_test.go +++ b/internal/selfmanage/release_pipeline_test.go @@ -3339,25 +3339,161 @@ func TestVulnerabilityScannerIsPinnedAndRunsInReleaseGate(t *testing.T) { } func TestRootIntegrationPackagesRunSerially(t *testing.T) { - workflows := map[string]struct { - path string - want string - }{ - "Go": { - path: "../../.github/workflows/go.yml", - want: `go test -race -p 1 -tags integration ./...`, - }, - "Release": { - path: "../../.github/workflows/release.yml", - want: `go test -mod=readonly -count=1 -race -p 1 -tags integration ./...`, - }, + var paths []string + for _, pattern := range []string{"../../.github/workflows/*.yml", "../../.github/workflows/*.yaml"} { + matches, err := filepath.Glob(pattern) + if err != nil { + t.Fatal(err) + } + paths = append(paths, matches...) + } + paths = append(paths, "../../CONTRIBUTING.md") + commandCount := 0 + for _, path := range paths { + content := readReleaseFile(t, path) + count, unserialized := rootIntegrationPackageCommands(content) + commandCount += count + for _, line := range unserialized { + t.Errorf("%s:%d runs root integration packages without -p 1", path, line) + } + } + if commandCount != 4 { + t.Fatalf("found %d root integration package commands, want 4", commandCount) + } + + goWorkflow := readReleaseFile(t, "../../.github/workflows/go.yml") + if !strings.Contains(goWorkflow, `go test -count=1 -race -p 1 -tags integration ./...`) { + t.Error("Go workflow does not force an uncached serialized root integration run") } - for name, workflow := range workflows { - content := readReleaseFile(t, workflow.path) - if !strings.Contains(content, workflow.want) { - t.Errorf("%s workflow does not serialize root integration packages", name) + releaseWorkflow := readReleaseFile(t, "../../.github/workflows/release.yml") + if !strings.Contains(releaseWorkflow, `go test -mod=readonly -count=1 -race -p 1 -tags integration ./...`) { + t.Error("Release workflow does not force an uncached serialized root integration run") + } + + bait := "# go test -p 1 -tags integration ./...\n" + + "go test -tags integration ./... # -p 1\n" + + "go test -p 1 -tags integration ./... && go test -tags integration ./...\n" + + "go test -p 1 -tags integration ./...; go test -tags integration ./...\n" + count, unserialized := rootIntegrationPackageCommands(bait) + if count != 5 || len(unserialized) != 3 || unserialized[0] != 2 || unserialized[1] != 3 || unserialized[2] != 4 { + t.Fatalf("root command scan accepted a comment bait or missed a chained unserialized command: count=%d lines=%v", count, unserialized) + } +} + +func rootIntegrationPackageCommands(content string) (int, []int) { + content = strings.ReplaceAll(content, "\\\n", " ") + commandCount := 0 + var unserialized []int + for lineNumber, line := range strings.Split(content, "\n") { + for _, fields := range shellCommandFields(line) { + goTest := false + integration := false + allPackages := false + serialized := false + for i, field := range fields { + if field == "go" && i+1 < len(fields) && fields[i+1] == "test" { + goTest = true + } + if field == "-tags" && i+1 < len(fields) { + integration = integration || commaListContains(fields[i+1], "integration") + } else if strings.HasPrefix(field, "-tags=") { + integration = integration || commaListContains(strings.TrimPrefix(field, "-tags="), "integration") + } + if field == "./..." { + allPackages = true + } + if field == "-p=1" || (field == "-p" && i+1 < len(fields) && fields[i+1] == "1") { + serialized = true + } + } + if !goTest || !integration || !allPackages { + continue + } + commandCount++ + if !serialized { + unserialized = append(unserialized, lineNumber+1) + } + } + } + return commandCount, unserialized +} + +func shellCommandFields(line string) [][]string { + var commands [][]string + var fields []string + var field strings.Builder + var quote byte + escaped := false + + flushField := func() { + if field.Len() == 0 { + return + } + fields = append(fields, field.String()) + field.Reset() + } + flushCommand := func() { + flushField() + if len(fields) == 0 { + return + } + commands = append(commands, fields) + fields = nil + } + + for i := 0; i < len(line); i++ { + ch := line[i] + if escaped { + field.WriteByte(ch) + escaped = false + continue + } + if quote != '\'' && ch == '\\' { + escaped = true + continue + } + if quote != 0 { + if ch == quote { + quote = 0 + } else { + field.WriteByte(ch) + } + continue + } + if ch == '\'' || ch == '"' { + quote = ch + continue + } + if ch == '#' && field.Len() == 0 { + break + } + if ch == ';' || ch == '|' || (ch == '&' && i+1 < len(line) && line[i+1] == '&') { + flushCommand() + if i+1 < len(line) && line[i+1] == ch { + i++ + } + continue + } + if ch == ' ' || ch == '\t' || ch == '\r' { + flushField() + continue + } + field.WriteByte(ch) + } + if escaped { + field.WriteByte('\\') + } + flushCommand() + return commands +} + +func commaListContains(list, want string) bool { + for _, item := range strings.Split(list, ",") { + if item == want { + return true } } + return false } func TestReleaseKeyringValidationIsPortableAcrossAwkImplementations(t *testing.T) { diff --git a/internal/selfmanage/release_pubkey.hex b/internal/selfmanage/release_pubkey.hex index d25ca54..879c2e2 100644 --- a/internal/selfmanage/release_pubkey.hex +++ b/internal/selfmanage/release_pubkey.hex @@ -7,7 +7,8 @@ # # CONFIGURED: signed self-upgrade is ENABLED. `upgrade` verifies each downloaded # release binary against every key before installing it, failing closed on any -# mismatch. Private keys exist only on the air-gapped signing machine. Candidate -# source and online preparation/publication never receive a private-key path. +# mismatch. The current v1 key has historical network-host exposure; new keys +# must follow the air-gapped custody model in docs/releasing.md. Candidate source +# and online preparation/publication never receive a private-key path. # 982471fb0c9f82f76143c89d045f8a931180f8cca27dad5286bb2000614eaf1c