Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e43df4416e | |||
| 9b51447acd | |||
| 80ec9e3fb4 | |||
| 86b73db71f | |||
| 52e3a389bb | |||
| cf9e451ecd | |||
| 6376ef23f1 | |||
| 7d92c477c2 | |||
| 6eec022bb0 | |||
| 15dd58f5ac | |||
| df064bd1d3 | |||
| 42569b4af2 | |||
| 50d3573257 | |||
| 39196a525c | |||
| cfd8ba7cb2 | |||
| 182da397e3 | |||
| a3e993d95a | |||
| 33e10635b6 | |||
| 618a7c9dd3 | |||
| 41e58ef894 | |||
| 29e1bab228 | |||
| ca571aca4f | |||
| ff90510c38 | |||
| db9e24f7dc | |||
| 4bb480f990 | |||
| d51a0ada5e | |||
| f2124b3b7b | |||
| ae0e4a0263 | |||
| c7942210d5 | |||
| c339872bfe |
@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
Third-party code and assets are subject to their respective license terms.
|
||||
|
||||
Policy documents adapted from Standard Repository Policies, the root AGENTS.md
|
||||
orientation, and the Policy Reading Guide in docs/development.md remain licensed
|
||||
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
|
||||
upstream credit, and license links.
|
||||
|
||||
@@ -8,8 +8,9 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
||||
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||
delivery are documented. ReleaseKit does not declare binary deliverables.
|
||||
|
||||
See the [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline
|
||||
and installation commands.
|
||||
See the [consumer adoption guide](docs/consumers/adoption.md) to add ReleaseKit
|
||||
as a versioned Go tool or package dependency, and the
|
||||
[v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline.
|
||||
|
||||
Run the offline Go test suite from the repository root with:
|
||||
|
||||
@@ -42,6 +43,8 @@ failure recovery.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Consumer adoption](docs/consumers/adoption.md): versioned CLI tool and Go API
|
||||
dependencies, upgrades, and CI adoption.
|
||||
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
||||
policy reading.
|
||||
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`,
|
||||
|
||||
@@ -516,7 +516,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
writeBuildFixture(t, repository, "go.mod", module)
|
||||
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
|
||||
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
||||
@@ -534,7 +534,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
writeFile(t, repository, "go.mod", module)
|
||||
writeFile(t, repository, ".gitignore", "dist/\n")
|
||||
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestVersionInConsumerExecutable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
module := fmt.Sprintf(`module example.com/versionfixture
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0
|
||||
|
||||
|
||||
@@ -1155,7 +1155,7 @@ func newCLIBuildRepository(t *testing.T, mainSource string) string {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
||||
write := func(name, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||
|
||||
@@ -150,6 +150,19 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
||||
// Synthetic downloads must leave the caller's module cache untouched.
|
||||
callerCache := t.TempDir()
|
||||
t.Setenv("GOMODCACHE", callerCache)
|
||||
t.Cleanup(func() {
|
||||
entries, err := os.ReadDir(callerCache)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("synthetic module downloads modified the caller's cache: %v", entries)
|
||||
}
|
||||
})
|
||||
|
||||
root := cliRepositoryRoot(t)
|
||||
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml"))
|
||||
if err != nil {
|
||||
@@ -185,7 +198,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
||||
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||
commit := oldCommit
|
||||
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
|
||||
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
|
||||
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
|
||||
env["RELEASEKIT_SUM_LOG"] = sumLog
|
||||
output := runWoodpeckerBlock(t, command, repository, env)
|
||||
@@ -207,7 +220,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
|
||||
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
|
||||
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
|
||||
env["RELEASEKIT_SUM_LOG"] = sumLog
|
||||
output := runWoodpeckerBlock(t, command, repository, env)
|
||||
@@ -245,21 +258,15 @@ func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
|
||||
defer proxy.Close()
|
||||
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+dependency+" v1.0.0\n")
|
||||
writeRepositoryFile(t, repository, "cmd/releasekit/main.go", "package main\n\nimport (\"fmt\"; \"os\"; helper \""+dependency+"\")\n\nfunc main() {\n\tfmt.Println(\"self-bootstrap \" + helper.Message)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n")
|
||||
preparationEnv := map[string]string{
|
||||
"GOPROXY": proxy.URL, "GOSUMDB": "off", "GONOPROXY": "none", "GOPRIVATE": "",
|
||||
"GOWORK": "off", "GOTOOLCHAIN": "local", "GOENV": "off", "GOFLAGS": "-modcacherw",
|
||||
"GOMODCACHE": t.TempDir(),
|
||||
}
|
||||
preparationEnv := map[string]string{}
|
||||
useSyntheticModuleProxy(t, preparationEnv, proxy.URL)
|
||||
runWoodpeckerBlock(t, "go mod download "+dependency, repository, preparationEnv)
|
||||
cliGitRun(t, repository, "add", "cmd/releasekit/main.go", "go.mod", "go.sum")
|
||||
cliGitRun(t, repository, "commit", "-m", "add the self-bootstrapped command")
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
goDir, goLog := installGoBootstrapShim(t)
|
||||
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||
for key, value := range preparationEnv {
|
||||
env[key] = value
|
||||
}
|
||||
env["GOMODCACHE"] = t.TempDir()
|
||||
useSyntheticModuleProxy(t, env, proxy.URL)
|
||||
proxyRequests.Store(0)
|
||||
env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit))
|
||||
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||
@@ -426,6 +433,19 @@ esac
|
||||
return shimDir, goLog
|
||||
}
|
||||
|
||||
func useSyntheticModuleProxy(t *testing.T, environment map[string]string, proxyURL string) {
|
||||
t.Helper()
|
||||
environment["GOMODCACHE"] = t.TempDir()
|
||||
environment["GOPROXY"] = proxyURL
|
||||
environment["GOSUMDB"] = "off"
|
||||
environment["GONOPROXY"] = "none"
|
||||
environment["GOPRIVATE"] = ""
|
||||
environment["GOWORK"] = "off"
|
||||
environment["GOTOOLCHAIN"] = "local"
|
||||
environment["GOENV"] = "off"
|
||||
environment["GOFLAGS"] = "-modcacherw"
|
||||
}
|
||||
|
||||
func templateEnvironment(repository, commit, shimDir, goLog string) map[string]string {
|
||||
realGo, _ := exec.LookPath("go")
|
||||
return map[string]string{
|
||||
@@ -467,7 +487,7 @@ func executeWoodpeckerBlock(block, repository string, environment map[string]str
|
||||
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
|
||||
t.Helper()
|
||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.25.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
|
||||
goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.26.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
|
||||
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||
cliGitRun(t, repository, "add", "go.mod")
|
||||
if includeSum {
|
||||
|
||||
@@ -134,6 +134,50 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
OutputDir: filepath.Join(t.TempDir(), "caller-owned-assets"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
t.Fatal("built artifact is empty")
|
||||
}
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none before local verification", remote.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
53
docs/consumers/adoption.md
Normal file
53
docs/consumers/adoption.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Adopt ReleaseKit
|
||||
|
||||
Use a versioned Go tool dependency when ReleaseKit provides the repository's
|
||||
release commands. This records the CLI in `go.mod` and keeps it available to
|
||||
maintainers without adding imports to application or library code.
|
||||
|
||||
## CLI tool dependency
|
||||
|
||||
From the consumer module's root, select the ReleaseKit version to adopt:
|
||||
|
||||
```sh
|
||||
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.2.0
|
||||
go mod tidy
|
||||
go tool releasekit version
|
||||
```
|
||||
|
||||
Replace `v0.2.0` with the intended release. Go records the executable package in
|
||||
a `tool` directive and the module version in a `require` directive. The tool
|
||||
declaration retains the dependency through `go mod tidy`. Commit the resulting
|
||||
`go.mod` and `go.sum` changes. To upgrade, repeat the command with the chosen
|
||||
version and review the module changes.
|
||||
|
||||
Go tool dependencies require Go 1.24 or later, together with a toolchain that
|
||||
satisfies the selected ReleaseKit version and its dependencies. Tools share the
|
||||
consumer's module graph: their requirements participate in dependency version
|
||||
selection and can raise the consumer module's Go minimum. See the
|
||||
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
|
||||
|
||||
Run `go tool releasekit` from within the consumer module to use its selected
|
||||
version. Follow the [CLI contract](../cli.md) for commands and exit statuses,
|
||||
and the [configuration reference](../config.md) for the release profile.
|
||||
|
||||
## Go API consumers
|
||||
|
||||
Consumers importing the root `releasekit` API or
|
||||
[`buildinfo`](runtime-version.md) use ordinary package dependencies. Add the
|
||||
required imports and select the module version:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
API consumers can also register the CLI with `go get -tool` as above. Both uses
|
||||
share the module-selected ReleaseKit version.
|
||||
|
||||
## CI adoption
|
||||
|
||||
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
||||
supports both tool and ordinary package dependencies. It builds ReleaseKit from
|
||||
the tagged consumer's module selection, so the committed module files also
|
||||
govern CI delivery and retries. Registering the CLI as a tool is sufficient for
|
||||
consumers that use ReleaseKit only for maintainer commands.
|
||||
@@ -86,6 +86,9 @@ settings are preserved, including values from Go's persistent environment
|
||||
file. The check revalidates the branch, commit, and cleanliness after
|
||||
validation.
|
||||
|
||||
For checksum failures during dependency preparation, use the
|
||||
[module checksum investigation guide](delivery.md#investigating-module-checksum-mismatches).
|
||||
|
||||
A successful check is evidence about the local candidate only. It does not
|
||||
check whether a remote branch or tag is available and does not create a tag.
|
||||
The [`check` command](../cli.md) reads the product and optional additional
|
||||
|
||||
@@ -150,9 +150,13 @@ exit codes, and transport behavior.
|
||||
|
||||
Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml)
|
||||
in the consumer repository's `.woodpecker/` directory. Set `GO_IMAGE` to an
|
||||
image with Go 1.25 or newer, Git, and a local Go version compatible with the
|
||||
consumer module. It uses `GOTOOLCHAIN=local`; Go does not download a different
|
||||
toolchain. The tag's `go.mod` must select ReleaseKit as a module dependency.
|
||||
image with Git and a Go toolchain compatible with the consumer module and its
|
||||
selected ReleaseKit version. Choose the patch used for the consumer's release
|
||||
build and validation. The template uses `GOTOOLCHAIN=local`; Go does not download
|
||||
a different toolchain. The tag's `go.mod` must select ReleaseKit as a module
|
||||
dependency. For CLI-only consumers, use the versioned Go tool dependency
|
||||
described in the [adoption guide](adoption.md). Ordinary package dependencies
|
||||
also work with this template.
|
||||
Configure the Woodpecker repository secret `releasekit_gitea_token`; the
|
||||
template maps it to `RELEASEKIT_GITEA_TOKEN` for delivery. The repository needs
|
||||
a `delivery` profile. Page-only and binary behavior both use this template; the
|
||||
@@ -214,3 +218,48 @@ move the tag to make a retry pass. See
|
||||
for runner-provided tag and commit variables and
|
||||
[workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) for tag
|
||||
events, sequential steps, and the shared workspace.
|
||||
|
||||
## Investigating module checksum mismatches
|
||||
|
||||
During tool installation or dependency preparation, a checksum mismatch means
|
||||
the bytes Go read differ from a recorded checksum. The message alone does not
|
||||
identify whether those bytes came from the local cache or a remote source.
|
||||
Keep checksum verification enabled and retain the error, selected version,
|
||||
and relevant `go.sum` entries. Go's [module authentication reference](https://go.dev/ref/mod#authenticating)
|
||||
describes these checks.
|
||||
|
||||
To isolate a public ReleaseKit download from application settings and the
|
||||
existing module cache, run the following with a compatible local Go toolchain.
|
||||
Replace `v0.2.0` with the affected version. This diagnostic accesses the public
|
||||
Go proxy and checksum database and retains its temporary directory for inspection.
|
||||
|
||||
```sh
|
||||
(
|
||||
checksum_dir=$(mktemp -d) || exit 1
|
||||
printf 'Checksum investigation directory: %s\n' "$checksum_dir"
|
||||
cd "$checksum_dir" || exit 1
|
||||
GOENV=off GOFLAGS= GOWORK=off GOTOOLCHAIN=local \
|
||||
GOPRIVATE= GONOPROXY= GONOSUMDB= \
|
||||
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
|
||||
GOMODCACHE="$checksum_dir/modules" \
|
||||
go mod download -json gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
|
||||
)
|
||||
```
|
||||
|
||||
A successful isolated download establishes that this version verifies through
|
||||
the selected proxy. Compare its `GoMod`, `GoModSum`, and `Sum` with the affected
|
||||
cache and the application's recorded checksums. Locate the original cache with
|
||||
`go env GOMODCACHE`; Go stores downloaded module metadata separately from the
|
||||
extracted source. See the [module cache reference](https://go.dev/ref/mod#module-cache).
|
||||
|
||||
Once a stale cache entry is confirmed, preserve a copy, quarantine that entry
|
||||
while no Go process is using it, and retry normal dependency preparation. For
|
||||
a confirmed stale `.mod`, quarantine that file alone. Preserve application
|
||||
checksums and published tags. Routine downloads can continue to use the normal
|
||||
module cache.
|
||||
|
||||
If the isolated download also fails, investigate the selected proxy, tagged
|
||||
source, and recorded checksums with the maintainer before changing local state.
|
||||
Record any difference between proxy and direct fetching. A confirmed cache
|
||||
problem can be repaired locally; changes to published source follow the
|
||||
maintainer's immutable-release procedure.
|
||||
|
||||
@@ -75,3 +75,17 @@ validation. The [maintainer release procedure](release.md) owns preparation,
|
||||
publication, and recovery. Hosted note-page delivery is a separate prepared
|
||||
workflow and does not change the successful-tag-push completion point. ReleaseKit
|
||||
does not declare binary deliverables.
|
||||
|
||||
## Testing synthetic modules
|
||||
|
||||
Tests that serve synthetic module versions through local proxies should pass a
|
||||
test-owned temporary `GOMODCACHE` to every Go subprocess that consumes those
|
||||
versions. Scope fixture-specific proxy and checksum settings to those processes.
|
||||
This applies the [testing policy's isolation requirements](policy/testing.md#test-execution-requirements)
|
||||
to module downloads and keeps fixture metadata out of developer and CI caches.
|
||||
|
||||
Distinct fixture version numbers still occupy the real module's cache namespace.
|
||||
Use temporary caches even when the selected versions have never been published.
|
||||
Verify isolation at the shared fixture boundary; ordinary builds can continue
|
||||
to use their prepared dependency cache. Consumer recovery guidance lives in the
|
||||
[module checksum investigation guide](consumers/delivery.md#investigating-module-checksum-mismatches).
|
||||
|
||||
@@ -5,6 +5,34 @@ operation and applies the shared environment to its Go commands. It uses
|
||||
`internal/process` for cancellation, the working directory, output, and wrapped
|
||||
errors.
|
||||
|
||||
## Command output ownership
|
||||
|
||||
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
||||
stderr to the optional writer while the child runs, then releases its capture
|
||||
on success. Failures include the captured diagnostic text and preserve the
|
||||
underlying process or forwarding error.
|
||||
|
||||
`Tool.Output` returns combined stdout and stderr for commands whose output is
|
||||
needed by the caller, such as compiler identification. After the child stops,
|
||||
the capture's backing bytes transfer to the caller without a second copy. A
|
||||
failed output command returns both those bytes and an error containing the
|
||||
diagnostic text. The capture remains in memory for the duration of the child;
|
||||
the component benchmark compares one 8 MiB write with the same output in
|
||||
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth
|
||||
allocations. The [output benchmark](../../internal/gotool/performance_test.go)
|
||||
measures total allocated bytes per operation, excluding subprocess overhead and
|
||||
diagnostic strings. These totals count all allocations, including superseded
|
||||
buffers. The returned slice retains the final buffer holding the complete output.
|
||||
Peak live memory depends on buffer growth, garbage collection, and other command
|
||||
work and is not measured by allocation totals. Failed output also formats
|
||||
diagnostics into the error, so the returned bytes and diagnostic string can
|
||||
coexist. Disk-backed diagnostics could reduce large failure peaks, but add file
|
||||
lifecycle and cleanup work and remain a follow-up option.
|
||||
|
||||
Dependency preparation uses a separate command environment so proxy, private
|
||||
module, checksum, and cache settings remain available. Its completed capture
|
||||
uses the same ownership transfer when formatting a failure.
|
||||
|
||||
## Configuration discovery
|
||||
|
||||
The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then
|
||||
|
||||
@@ -39,16 +39,16 @@ reports these implemented packages:
|
||||
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
|
||||
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
|
||||
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
|
||||
| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. |
|
||||
| `internal/git` | Git command boundary, committed-tree and blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||
| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
|
||||
| `internal/git` | Git command boundary, committed-tree reads, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||
| `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
|
||||
| `internal/gotool` | Effective Go configuration discovery, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). |
|
||||
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
|
||||
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. |
|
||||
| `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
|
||||
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
||||
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
|
||||
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
|
||||
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
|
||||
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
||||
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
||||
|
||||
The `buildinfo` facade depends only on its small runtime implementation. The
|
||||
@@ -82,10 +82,12 @@ not a separate release matrix:
|
||||
| Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
|
||||
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
|
||||
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
|
||||
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
|
||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
||||
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
||||
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||
| Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
||||
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
|
||||
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |
|
||||
|
||||
@@ -36,7 +36,10 @@ It follows committed symlink declarations through the captured tree and fails
|
||||
with an inspection cause when their selected contents are unavailable. A
|
||||
replacement with no parsed version is reported with its go.mod path, line,
|
||||
directive, and target; versioned replacements are allowed. It runs after the
|
||||
workspace-file check using the same candidate inventory.
|
||||
workspace-file check using the same candidate inventory. Resolved module bodies
|
||||
are parsed once per blob object ID; only owned filesystem-replacement targets
|
||||
and line numbers remain cached. Each declaration path receives findings from
|
||||
that summary in its own path context.
|
||||
The optional file-size check uses each blob's captured Git size, including
|
||||
symlink link-text bytes, and skips entries without a blob size such as gitlinks.
|
||||
It applies normalized literal file/directory exclusions with path-boundary
|
||||
@@ -47,20 +50,33 @@ and checks supported anchors; outside-checkout and submodule observations are
|
||||
written to the shared output writer. A nil writer keeps library use quiet.
|
||||
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
|
||||
lazily, and resolves destinations without consulting the worktree or
|
||||
neighboring files.
|
||||
neighboring files. Each tree owns one lazy Git blob reader; direct tree users
|
||||
must close it when finished. The shared validation executor closes its tree on
|
||||
every return before product checks or build callbacks run. A metadata-only
|
||||
selection never starts the reader. Per-read contexts cancel an in-flight blob
|
||||
exchange. The context supplied when creating the tree governs the reader
|
||||
lifetime, including shutdown and child reaping.
|
||||
Regular-file reads return independent caller-owned byte slices and are not
|
||||
retained by the tree. Symlink targets are retained as immutable owned strings
|
||||
by blob object ID; explicitly reading a symlink still returns independent
|
||||
bytes. The Go-format check caches only whether each successfully inspected
|
||||
object is formatted, then reports every unformatted path that refers to it.
|
||||
|
||||
The internal Markdown parser uses Goldmark to record inline, reference, and
|
||||
autolinks, images, titles, and their source use sites, and to build heading
|
||||
anchors from visible AST text. It supports ATX and Setext headings, decodes
|
||||
Markdown text through the parser, and assigns collision-free numeric suffixes
|
||||
to repeated anchors. Link locations use one-based line and byte-column values.
|
||||
A per-path cache retains parsed records and anchors for candidate documents.
|
||||
The Markdown cache shares parsed records and anchors by regular blob object ID,
|
||||
with a path lookup for callers. Retained destinations and titles own their
|
||||
string storage, and parsed records contain content-relative locations only.
|
||||
|
||||
`checkMarkdownLinks` examines regular `.md` blobs from the captured tree. It
|
||||
skips schemed and protocol-relative URIs, strips query components, and decodes
|
||||
percent escapes once in local paths and fragments. It resolves destinations
|
||||
through `CandidateTree.ResolveDestination`; decoded fragments are compared in
|
||||
lowercase against anchors from the resolved regular Markdown document. Missing
|
||||
through `CandidateTree.ResolveDestination` relative to the current source path;
|
||||
decoded fragments are compared in lowercase against anchors from the resolved
|
||||
regular Markdown document. Missing
|
||||
paths, symlink cycles, and missing anchors produce `markdown-links` findings at
|
||||
the source use site. Outside-checkout links produce out-of-scope observations by
|
||||
default and findings when boundary enforcement is selected. Submodule-content
|
||||
|
||||
@@ -29,6 +29,9 @@ License and notices: [MIT](licenses/MIT.txt) |
|
||||
|
||||
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,
|
||||
and the Policy Reading Guide in `docs/development.md`, adapted for this repository.
|
||||
The release policy incorporates revision `e833faddf120c789a9b45348edaf75720de16348`;
|
||||
the remaining material incorporates revision
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
@@ -38,4 +41,9 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
Release-policy notices:
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
|
||||
[Release-policy source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md) |
|
||||
[Other policy material source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.8
|
||||
|
||||
**Last Updated**: 2026-10-07
|
||||
**Last Updated**: 2026-10-08
|
||||
|
||||
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||
|
||||
@@ -55,7 +55,7 @@ provides texts to copy for applicable licenses and their companions.
|
||||
ReleaseKit's selected grant is LGPL version 3 or later, recorded in
|
||||
[COPYRIGHT](../../COPYRIGHT). It applies to the library and release
|
||||
executable. Adapted policy documents, agent orientation, and the policy reading
|
||||
guide retain their CC-BY-SA-4.0 scope as identified there and in the
|
||||
guide retain their CC-BY-SA-4.0 scope as identified in the
|
||||
[attribution document](../oss/attribution.md).
|
||||
|
||||
Unless the author specifies otherwise, use **GPL version 3 or later**
|
||||
@@ -90,9 +90,9 @@ fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
|
||||
grant. This convention introduces no blanket requirement for per-file headers;
|
||||
preserve notices or add them where the applicable terms require them.
|
||||
|
||||
Identify separately licensed documentation or assets in the project grant, with
|
||||
links to their attribution and license information. An application's default
|
||||
code license does not override the terms of incorporated documentation.
|
||||
Identify separately licensed documentation or assets in the attribution
|
||||
document, with links to their license information. The project grant uses the
|
||||
upstream template's third-party statement.
|
||||
|
||||
## Upstream Attribution House Format
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 1.4
|
||||
**Base Policy Version:** 1.6
|
||||
|
||||
**Last Updated**: 2026-10-07
|
||||
**Last Updated**: 2026-10-08
|
||||
|
||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -12,6 +12,9 @@ This policy defines a proportionate workflow for validating and publishing an
|
||||
intended source revision under a fresh version. Releases should require little
|
||||
routine intervention, avoid repeated work, and preserve published contents.
|
||||
|
||||
Adopt stronger verification or reproducibility requirements through an explicit
|
||||
project-specific decision justified by the intended audience and support needs.
|
||||
|
||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||
policy and link to it rather than introduce additional release requirements.
|
||||
@@ -69,7 +72,8 @@ requires a deliberate update to the policy and procedure where needed.
|
||||
For releases at or after `v1.0.0`:
|
||||
|
||||
- a major release may make intentional incompatible changes;
|
||||
- a minor release may add backward-compatible behavior; and
|
||||
- a minor release may add backward-compatible behavior or raise the Go consumer
|
||||
minimum as described below; and
|
||||
- a patch release may make backward-compatible fixes.
|
||||
|
||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||
@@ -81,6 +85,43 @@ Additional compatibility promises and deliberate exceptions belong in the
|
||||
adopted policy or canonical contract documentation. Release notes explain
|
||||
changes; they do not redefine current compatibility contracts.
|
||||
|
||||
## Consumer Minimum And Release Toolchain
|
||||
|
||||
ReleaseKit provides reasonable support for routine build and release needs,
|
||||
following [ADR 0002](../adr/0002-routine-releases.md). Distinguish the minimum Go
|
||||
toolchain supported for consumers from the toolchain maintainers use to build
|
||||
and validate a release. The consumer minimum belongs in [go.mod](../../go.mod);
|
||||
the [maintainer runbook](../release.md) defines the selected release toolchain
|
||||
and its setup commands.
|
||||
|
||||
Select the Go series during release preparation. When advancing the consumer
|
||||
minimum, use the latest locally available stable series and record its initial
|
||||
stable version in `go.mod`, such as `go 1.26.0`. Use a higher patch minimum when
|
||||
a dependency requirement or identified correctness fix requires it, and document
|
||||
the reason.
|
||||
|
||||
Build the maintainer release executable and validate the candidate with the
|
||||
same latest locally available patch of the selected series. Keep that toolchain
|
||||
active throughout preparation, tool bootstrap, and validation, using local
|
||||
toolchain selection. CI jobs performing release builds and validation use the
|
||||
selected toolchain; maintained CI images follow that choice. A newer validation
|
||||
patch version alone leaves the consumer minimum unchanged.
|
||||
|
||||
For release-policy purposes, successful build and release validation on that
|
||||
patch are sufficient to presume support across the selected Go series, subject
|
||||
to the declared consumer minimum and concrete evidence of incompatibility.
|
||||
This relies on Go's [release policy](https://go.dev/doc/devel/release#policy) and
|
||||
[compatibility commitments](https://go.dev/doc/go1compat). Investigate specific
|
||||
compatibility concerns as they arise. Routine releases require no patch-version
|
||||
matrix. Record the actual validation toolchain in the release evidence.
|
||||
|
||||
A minor release may raise the Go consumer minimum, including from one Go series
|
||||
to the next, both before and after `v1.0.0`. This is an explicit exception to the
|
||||
backward-compatibility requirement for minor releases. Patch releases preserve
|
||||
the consumer minimum. Explain an increase's impact and required consumer action
|
||||
in the release note. Other incompatible changes remain subject to the versioning
|
||||
rules above.
|
||||
|
||||
## Release Documentation And Preparation
|
||||
|
||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||
@@ -132,8 +173,8 @@ checks when their distinct protection justifies their cost. A one-time migration
|
||||
check must not silently become a permanent release requirement.
|
||||
|
||||
Release tests must not require real credentials, mutable application services,
|
||||
paid APIs, or live service probes. The procedure may describe dependency
|
||||
preparation separately from offline validation.
|
||||
paid APIs, live service probes, or inference calls. The procedure may describe
|
||||
dependency preparation separately from offline validation.
|
||||
|
||||
Avoid duplicate validation across release phases. Successful checks for the
|
||||
same final candidate need not be repeated solely to satisfy another phase;
|
||||
@@ -200,7 +241,8 @@ release without replacing its contents or reusing its version.
|
||||
Create and maintain `docs/release.md` with the first working publication command
|
||||
as a short, executable maintainer runbook. It must identify:
|
||||
|
||||
1. prerequisites, primary branch, and authoritative remote;
|
||||
1. prerequisites, selected release toolchain, primary branch, and authoritative
|
||||
remote;
|
||||
2. version and release-note preparation;
|
||||
3. one publication command with its selected local checks and automated guards;
|
||||
4. declared deliverables, required build targets, and automation responsibilities;
|
||||
|
||||
@@ -12,8 +12,36 @@ succeeded. CI and consumer installation are not completion checks.
|
||||
|
||||
## Prepare a candidate
|
||||
|
||||
Use a local Go 1.25 toolchain and a clean clone. Start on `main`, choose an
|
||||
unused stable `vMAJOR.MINOR.PATCH` version, and add
|
||||
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.8. Make that
|
||||
local installation the active `go` command for preparation, building the release
|
||||
executable, and candidate validation:
|
||||
|
||||
```sh
|
||||
export GOTOOLCHAIN=local
|
||||
go version
|
||||
```
|
||||
|
||||
Record the reported version with the validation results. The
|
||||
[Go support policy](policy/release.md#consumer-minimum-and-release-toolchain) owns
|
||||
series selection, consumer minima, patch exceptions, and validation scope.
|
||||
The maintained [source-delivery image](../examples/woodpecker/releasekit-source-delivery.yml)
|
||||
and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the
|
||||
chosen build toolchain; update them together when that selection changes.
|
||||
|
||||
The current consumer minimum is declared in [go.mod](../go.mod). When preparing
|
||||
a release that raises it to Go 1.26, the normal declaration is:
|
||||
|
||||
```sh
|
||||
go mod edit -go=1.26.0
|
||||
```
|
||||
|
||||
Retain any higher patch minimum required by dependencies or a specific fix.
|
||||
Make module changes before committing the candidate. A release preserving the
|
||||
existing minimum keeps its current declaration. Minimum increases must fit the
|
||||
[compatibility policy](policy/release.md#versioning-and-compatibility); before
|
||||
v1.0.0, an increase belongs in a minor release.
|
||||
|
||||
Choose an unused stable `vMAJOR.MINOR.PATCH` version, and add
|
||||
`docs/releases/<version>.md` with a first heading containing `ReleaseKit` and
|
||||
the exact version. Summarize the change and any compatibility or upgrade impact.
|
||||
End the note with the three license links at the immutable source tag:
|
||||
|
||||
117
docs/releases/v0.3.0.md
Normal file
117
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# ReleaseKit v0.3.0
|
||||
|
||||
## Summary
|
||||
|
||||
ReleaseKit reduces repeated Git processes, validation memory retention, artifact
|
||||
hashing, output copying, and runtime metadata parsing. It also improves
|
||||
cancellation during validation shutdown and documents module-selected CLI
|
||||
adoption and checksum recovery.
|
||||
|
||||
## Changes
|
||||
|
||||
- Committed-file checks share one lazy Git batch reader per validation operation.
|
||||
Metadata-only checks start no content reader. Cancellation remains active
|
||||
through reader shutdown, and cleanup reaps the child before validation returns.
|
||||
- Validation releases regular-file source bytes after inspection and reuses
|
||||
compact or parsed results by blob identity for Go formatting, module
|
||||
replacements, Markdown documents, and symlink targets. Findings retain each
|
||||
source path's locations and destination context.
|
||||
- Managed and prebuilt delivery use one local asset verification pass before
|
||||
hosted requests, removing the adjacent declaration-time hashing pass. Changed
|
||||
assets still fail verification; exact remote comparisons, additive retries,
|
||||
and partial-delivery reporting retain their behavior.
|
||||
- Go commands transfer completed output buffers without an additional full
|
||||
copy. Status-only successes release their captures, while failures retain
|
||||
complete diagnostics and underlying causes. Capture remains in memory;
|
||||
allocation costs depend on output size and write chunking.
|
||||
- The runtime version accessor caches its build-metadata fallback safely across
|
||||
calls. Linker-injected versions retain precedence, and the development fallback
|
||||
remains unchanged.
|
||||
- Shutdown errors are attributed to the last validation check that actually read
|
||||
content. Selecting a later check with no applicable files preserves that
|
||||
attribution, and an earlier check failure remains the primary error.
|
||||
- CLI-only consumers have documented adoption through versioned Go tool
|
||||
dependencies. The Woodpecker template supports both tool dependencies and
|
||||
ordinary package dependencies selected by the tagged consumer module.
|
||||
- Synthetic-module workflow tests use isolated module caches. New checksum
|
||||
investigation guidance explains how to distinguish stale local metadata from
|
||||
a download failure while preserving checksum verification and published tags.
|
||||
|
||||
Performance gains depend on the consumer's workload. The changes preserve
|
||||
complete in-memory diagnostics and existing validation selections.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The declared minimum Go version increases from 1.25.0 to 1.26.0. Consumers
|
||||
require Go 1.26.0 or newer; dependency versions are unchanged from v0.2.0.
|
||||
ReleaseKit's maintainer build toolchain and maintained Woodpecker template images
|
||||
use Go 1.26.8; consumers select a local toolchain compatible with their module
|
||||
and the selected ReleaseKit version.
|
||||
|
||||
The root orchestration facade, separate lightweight `buildinfo` facade, public
|
||||
signatures, CLI commands, and profile schema are unchanged. Existing v0.2.0
|
||||
callers and profiles require no source or configuration migration.
|
||||
|
||||
Validation cleanup may now return the caller's cancellation cause where shutdown
|
||||
previously remained blocked. Cleanup failures identify the check that used the
|
||||
reader. Inspect structured validation errors with `errors.As` and underlying
|
||||
causes with `errors.Is`.
|
||||
|
||||
Source publication and hosted delivery retain their separate completion states.
|
||||
ReleaseKit's deliverables remain the source tag and hosted Gitea note page.
|
||||
|
||||
## Upgrade guidance
|
||||
|
||||
1. Use Go 1.26.0 or newer for local tooling and CI before selecting v0.3.0.
|
||||
Updating ReleaseKit in a shared module graph can raise the consumer's declared
|
||||
Go minimum. Select the dependency form appropriate to the consumer.
|
||||
|
||||
Go API consumers:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.3.0
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
Consumers using ReleaseKit for repository release commands can register or
|
||||
upgrade the CLI as a Go tool dependency:
|
||||
|
||||
```sh
|
||||
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
|
||||
go mod tidy
|
||||
go tool releasekit version
|
||||
```
|
||||
|
||||
Review and commit the resulting module changes. API and tool dependencies
|
||||
share the consumer's module graph and selected ReleaseKit version.
|
||||
|
||||
A separately installed executable can be updated with:
|
||||
|
||||
```sh
|
||||
go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
|
||||
```
|
||||
|
||||
2. Retain existing validation and delivery selections. The performance changes
|
||||
apply automatically. Prepare dependencies before offline checks or builds,
|
||||
and run the consumer's ordinary tests after upgrading.
|
||||
|
||||
3. For module-selected Woodpecker delivery, commit the dependency upgrade before
|
||||
tagging the consumer. Choose a compatible local Go image; delivery and retries
|
||||
continue to use the triggering tag's module selection. CLI-only consumers can
|
||||
use the tool declaration to retain ReleaseKit through `go mod tidy`.
|
||||
|
||||
4. If dependency preparation reports a checksum mismatch, follow the
|
||||
[checksum investigation guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md#investigating-module-checksum-mismatches).
|
||||
Repair a confirmed stale cache entry while retaining checksum verification
|
||||
and the application's recorded checksums.
|
||||
|
||||
See the
|
||||
[adoption guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/adoption.md),
|
||||
[candidate check guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/check.md),
|
||||
[delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md),
|
||||
and [runtime version guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/runtime-version.md)
|
||||
for supported behavior and usage.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/oss/attribution.md)
|
||||
@@ -4,7 +4,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: deliver
|
||||
image: golang:1.25.0-bookworm
|
||||
image: golang:1.26.8-bookworm
|
||||
environment:
|
||||
RELEASEKIT_GITEA_TOKEN:
|
||||
from_secret: releasekit_gitea_token
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
matrix:
|
||||
include:
|
||||
- GO_IMAGE: golang:1.25.0-bookworm
|
||||
- GO_IMAGE: golang:1.26.8-bookworm
|
||||
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module gitea.maximumdirect.net/eric/ReleaseKit
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/yuin/goldmark/v2 v2.1.6
|
||||
|
||||
@@ -118,7 +118,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("prepare Go toolchain: %w", err)
|
||||
}
|
||||
compiler, err := goTool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||
compiler, err := goTool.Output(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("identify Go compiler: %w", err)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
||||
|
||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
|
||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||
_, err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||
err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -53,22 +50,6 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
|
||||
}
|
||||
file, err := os.Open(artifact.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open build artifact %q: %w", artifact.Filename, err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(hash, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("hash build artifact %q: %w", artifact.Filename, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close build artifact %q: %w", artifact.Filename, closeErr)
|
||||
}
|
||||
if artifact.SHA256 != hex.EncodeToString(hash.Sum(nil)) {
|
||||
return nil, fmt.Errorf("build artifact %q does not match its declared SHA-256 digest", artifact.Filename)
|
||||
}
|
||||
assets = append(assets, gitea.ExpectedAsset{
|
||||
Filename: artifact.Filename,
|
||||
SHA256: artifact.SHA256,
|
||||
|
||||
@@ -47,20 +47,6 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-amd64")
|
||||
if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := declaredBuildAssets(internalbuild.Result{
|
||||
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
|
||||
Artifacts: []internalbuild.Artifact{{Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "SHA-256") {
|
||||
t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64")
|
||||
contents := []byte("built application")
|
||||
|
||||
85
internal/delivery/performance_test.go
Normal file
85
internal/delivery/performance_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
|
||||
const performanceArtifactSize = 16 << 20
|
||||
|
||||
func BenchmarkPerformanceAssetPreflight(b *testing.B) {
|
||||
result, totalBytes := performanceBuildResult(b)
|
||||
declarations, err := declaredBuildAssets(result)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.Run("Declarations", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(totalBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(totalBytes), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
assets, err := declaredBuildAssets(result)
|
||||
if err != nil || len(assets) != len(declarations) {
|
||||
b.Fatalf("declaredBuildAssets() returned %d assets, err %v", len(assets), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("DeclarationsAndLocalVerification", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(totalBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(totalBytes), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
assets, err := declaredBuildAssets(result)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func performanceBuildResult(b *testing.B) (internalbuild.Result, int64) {
|
||||
b.Helper()
|
||||
outputDir := b.TempDir()
|
||||
data := make([]byte, performanceArtifactSize)
|
||||
for i := range data {
|
||||
data[i] = byte((i*31 + i/251 + 17) % 251)
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
result := internalbuild.Result{
|
||||
Version: "v1.2.3",
|
||||
BinaryName: "releasekit",
|
||||
Targets: []internalbuild.Target{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
},
|
||||
}
|
||||
for _, target := range result.Targets {
|
||||
name := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
|
||||
if target.OS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
path := filepath.Join(outputDir, name)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
result.Artifacts = append(result.Artifacts, internalbuild.Artifact{
|
||||
Path: path, Filename: name, Target: target,
|
||||
SHA256: hex.EncodeToString(digest[:]),
|
||||
})
|
||||
}
|
||||
return result, 2 * performanceArtifactSize
|
||||
}
|
||||
222
internal/git/blob_reader.go
Normal file
222
internal/git/blob_reader.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
)
|
||||
|
||||
var errBlobObjectMissing = errors.New("Git object is missing")
|
||||
|
||||
// BlobReader reads requested blobs through one lazy git cat-file --batch
|
||||
// process. Reads are serialized; callers must close the reader when finished.
|
||||
type BlobReader struct {
|
||||
repositoryDir string
|
||||
parent context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
child *process.PipedProcess
|
||||
stdout *bufio.Reader
|
||||
stderr bytes.Buffer
|
||||
closed bool
|
||||
aborted bool
|
||||
terminal error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewBlobReader creates a lazy reader for one repository. It starts no child
|
||||
// process until the first valid blob is requested.
|
||||
func NewBlobReader(ctx context.Context, repositoryDir string) *BlobReader {
|
||||
return &BlobReader{repositoryDir: repositoryDir, parent: ctx}
|
||||
}
|
||||
|
||||
// Read returns one blob body from the candidate repository. The returned bytes
|
||||
// are newly allocated and owned by the caller.
|
||||
func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, error) {
|
||||
if !validObjectID(objectID) {
|
||||
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||
}
|
||||
|
||||
reader.mu.Lock()
|
||||
defer reader.mu.Unlock()
|
||||
if reader.terminal != nil {
|
||||
return nil, reader.terminal
|
||||
}
|
||||
if reader.closed {
|
||||
return nil, fmt.Errorf("read blob %s: Git blob reader is closed", objectID)
|
||||
}
|
||||
if err := reader.startLocked(); err != nil {
|
||||
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||
}
|
||||
|
||||
child := reader.child
|
||||
callbackDone := make(chan struct{})
|
||||
stopCancellation := context.AfterFunc(ctx, func() {
|
||||
defer close(callbackDone)
|
||||
child.Cancel()
|
||||
_ = child.Stdin().Close()
|
||||
_ = child.Stdout().Close()
|
||||
})
|
||||
body, err := reader.exchange(objectID)
|
||||
if !stopCancellation() {
|
||||
<-callbackDone
|
||||
}
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
err = contextErr
|
||||
} else if contextErr := reader.parent.Err(); contextErr != nil {
|
||||
err = contextErr
|
||||
}
|
||||
if errors.Is(err, errBlobObjectMissing) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, reader.abortLocked(objectID, err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Close closes a healthy session cleanly or reaps an aborted session. It is
|
||||
// safe to call more than once.
|
||||
func (reader *BlobReader) Close() error {
|
||||
reader.mu.Lock()
|
||||
defer reader.mu.Unlock()
|
||||
if reader.closed {
|
||||
return reader.closeErr
|
||||
}
|
||||
reader.closed = true
|
||||
if reader.aborted {
|
||||
reader.closeErr = reader.terminal
|
||||
return reader.closeErr
|
||||
}
|
||||
if reader.child == nil {
|
||||
return nil
|
||||
}
|
||||
if err := reader.child.Stdin().Close(); err != nil {
|
||||
reader.closeErr = reader.abortLocked("", fmt.Errorf("close Git cat-file input: %w", err))
|
||||
return reader.closeErr
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, reader.child.Stdout()); err != nil {
|
||||
reader.closeErr = reader.abortLocked("", fmt.Errorf("finish Git cat-file output: %w", err))
|
||||
return reader.closeErr
|
||||
}
|
||||
if err := reader.child.Wait(); err != nil {
|
||||
reader.closeErr = reader.processError("close Git cat-file batch", err)
|
||||
}
|
||||
return reader.closeErr
|
||||
}
|
||||
|
||||
func (reader *BlobReader) startLocked() error {
|
||||
if reader.child != nil {
|
||||
return nil
|
||||
}
|
||||
if err := reader.parent.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
child, err := process.StartPiped(reader.parent, process.Invocation{
|
||||
Args: []string{"git", "cat-file", "--batch"},
|
||||
Dir: reader.repositoryDir,
|
||||
Env: map[string]string{
|
||||
"GIT_NO_LAZY_FETCH": "1",
|
||||
"GIT_ALLOW_PROTOCOL": "",
|
||||
},
|
||||
Stderr: &reader.stderr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader.child = child
|
||||
reader.stdout = bufio.NewReader(child.Stdout())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (reader *BlobReader) exchange(objectID string) ([]byte, error) {
|
||||
if _, err := io.WriteString(reader.child.Stdin(), objectID+"\n"); err != nil {
|
||||
return nil, fmt.Errorf("write object request: %w", err)
|
||||
}
|
||||
header, err := reader.stdout.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read object header: %w", err)
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSuffix(header, "\n"))
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], objectID) && fields[1] == "missing" {
|
||||
return nil, fmt.Errorf("read blob %s: %w", objectID, errBlobObjectMissing)
|
||||
}
|
||||
if len(fields) != 3 {
|
||||
return nil, fmt.Errorf("invalid Git blob header %q", strings.TrimSuffix(header, "\n"))
|
||||
}
|
||||
if !strings.EqualFold(fields[0], objectID) {
|
||||
return nil, fmt.Errorf("Git blob response object ID %q does not match request %q", fields[0], objectID)
|
||||
}
|
||||
if fields[1] != "blob" {
|
||||
return nil, fmt.Errorf("Git object %s has type %q, want blob", objectID, fields[1])
|
||||
}
|
||||
size, err := strconv.ParseInt(fields[2], 10, strconv.IntSize)
|
||||
if err != nil || size < 0 {
|
||||
if err == nil {
|
||||
err = errors.New("negative object size")
|
||||
}
|
||||
return nil, fmt.Errorf("invalid Git blob size %q: %w", fields[2], err)
|
||||
}
|
||||
body := make([]byte, int(size))
|
||||
if _, err := io.ReadFull(reader.stdout, body); err != nil {
|
||||
return nil, fmt.Errorf("read %d-byte Git blob body: %w", size, err)
|
||||
}
|
||||
framing, err := reader.stdout.ReadByte()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Git blob framing: %w", err)
|
||||
}
|
||||
if framing != '\n' {
|
||||
return nil, fmt.Errorf("invalid Git blob framing byte %q", framing)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (reader *BlobReader) abortLocked(objectID string, cause error) error {
|
||||
if reader.aborted {
|
||||
return reader.terminal
|
||||
}
|
||||
reader.aborted = true
|
||||
var waitErr error
|
||||
if reader.child != nil {
|
||||
reader.child.Cancel()
|
||||
_ = reader.child.Stdin().Close()
|
||||
_ = reader.child.Stdout().Close()
|
||||
waitErr = reader.child.Wait()
|
||||
}
|
||||
if contextErr := reader.parent.Err(); contextErr != nil {
|
||||
cause = contextErr
|
||||
}
|
||||
reader.terminal = reader.processError("read blob "+objectID, errors.Join(cause, relevantProcessError(waitErr, cause)))
|
||||
return reader.terminal
|
||||
}
|
||||
|
||||
func (reader *BlobReader) processError(operation string, cause error) error {
|
||||
message := strings.TrimSpace(reader.stderr.String())
|
||||
if message != "" {
|
||||
return fmt.Errorf("%s: %s: %w", operation, message, cause)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, cause)
|
||||
}
|
||||
|
||||
func relevantProcessError(waitErr, cause error) error {
|
||||
if waitErr == nil || errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == -1 {
|
||||
return nil
|
||||
}
|
||||
return waitErr
|
||||
}
|
||||
195
internal/git/blob_reader_test.go
Normal file
195
internal/git/blob_reader_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBlobReaderReadsLooseAndPackedObjects(t *testing.T) {
|
||||
for _, packed := range []bool{false, true} {
|
||||
name := "loose"
|
||||
if packed {
|
||||
name = "packed"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
binary := []byte{0, '\n', 0xff, 'b', '\n', 0}
|
||||
writeRepoFile(t, fixture.work, "empty.dat", "")
|
||||
writeRepoFile(t, fixture.work, "binary.dat", string(binary))
|
||||
gitRun(t, fixture.work, "add", ".")
|
||||
gitRun(t, fixture.work, "commit", "-m", "add binary fixtures")
|
||||
want := map[string][]byte{
|
||||
gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"): []byte("candidate"),
|
||||
gitText(t, fixture.work, "rev-parse", "HEAD:empty.dat"): {},
|
||||
gitText(t, fixture.work, "rev-parse", "HEAD:binary.dat"): binary,
|
||||
}
|
||||
if packed {
|
||||
gitRun(t, fixture.work, "gc", "--aggressive", "--prune=now")
|
||||
}
|
||||
reader := NewBlobReader(t.Context(), fixture.work)
|
||||
defer reader.Close()
|
||||
for repeat := 0; repeat < 2; repeat++ {
|
||||
for objectID, expected := range want {
|
||||
got, err := reader.Read(t.Context(), objectID)
|
||||
if err != nil {
|
||||
t.Fatalf("Read(%s) error = %v", objectID, err)
|
||||
}
|
||||
if !bytes.Equal(got, expected) {
|
||||
t.Errorf("Read(%s) = %v, want %v", objectID, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderMissingObjectKeepsBatchSessionUsable(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
objectID := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||
reader := NewBlobReader(t.Context(), fixture.work)
|
||||
defer reader.Close()
|
||||
missing := strings.Repeat("0", 40)
|
||||
if _, err := reader.Read(t.Context(), missing); err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("Read(missing) error = %v, want contextual missing-object error", err)
|
||||
}
|
||||
contents, err := reader.Read(t.Context(), objectID)
|
||||
if err != nil || string(contents) != "candidate" {
|
||||
t.Fatalf("Read(existing) = %q, %v after missing request", contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderPreservesHexCaseAndArbitraryBodyBytes(t *testing.T) {
|
||||
installFakeGit(t, `while IFS= read -r object; do
|
||||
upper=$(printf '%s' "$object" | tr 'a-f' 'A-F')
|
||||
printf '%s blob 4\n' "$upper"
|
||||
printf 'a\000b\n\n'
|
||||
done`)
|
||||
objectID := strings.Repeat("abcdef0123", 4)
|
||||
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||
contents, err := reader.Read(t.Context(), objectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []byte{'a', 0, 'b', '\n'}; !bytes.Equal(contents, want) {
|
||||
t.Fatalf("Read() = %v, want %v", contents, want)
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderRejectsInvalidHeadersAndFraming(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{name: "wrong object", response: `printf '%040d blob 0\n\n' 0`, want: "does not match request"},
|
||||
{name: "wrong type", response: `printf '%s tree 0\n\n' "$object"`, want: `has type "tree"`},
|
||||
{name: "invalid size", response: `printf '%s blob -1\n' "$object"`, want: "invalid Git blob size"},
|
||||
{name: "invalid framing", response: `printf '%s blob 0\nx' "$object"`, want: "invalid Git blob framing"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
installFakeGit(t, "IFS= read -r object\n"+testCase.response)
|
||||
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||
_, err := reader.Read(t.Context(), strings.Repeat("d", 40))
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("Read() error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
if closeErr := reader.Close(); !strings.Contains(closeErr.Error(), testCase.want) {
|
||||
t.Fatalf("Close() error = %v, want original protocol error", closeErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderTruncatedBodyAbortsAndKeepsOriginalCause(t *testing.T) {
|
||||
installFakeGit(t, `IFS= read -r object
|
||||
printf '%s blob 8\n' "$object"
|
||||
printf 'short'
|
||||
exit 0`)
|
||||
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||
_, err := reader.Read(t.Context(), strings.Repeat("a", 40))
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("Read() error = %v, want truncated-body cause", err)
|
||||
}
|
||||
if closeErr := reader.Close(); !errors.Is(closeErr, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("Close() error = %v, want original read cause", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderCancellationDuringBodyReadReapsChild(t *testing.T) {
|
||||
ready := filepath.Join(t.TempDir(), "ready")
|
||||
installFakeGit(t, `IFS= read -r object
|
||||
printf '%s blob 1048576\npartial' "$object"
|
||||
: > "$RELEASEKIT_GIT_READER_READY"
|
||||
exec sleep 60`)
|
||||
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
reader := NewBlobReader(ctx, t.TempDir())
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reader.Read(ctx, strings.Repeat("b", 40))
|
||||
result <- err
|
||||
}()
|
||||
waitForFile(t, ready)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Read() error = %v, want context cancellation", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Read() did not return after cancellation")
|
||||
}
|
||||
if err := reader.Close(); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Close() error = %v, want original cancellation cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
||||
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
||||
exit 23`)
|
||||
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||
_, err := reader.Read(t.Context(), strings.Repeat("c", 40))
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
|
||||
t.Fatalf("Read() error = %v, want Git exit code 23", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fixture Git failure") {
|
||||
t.Fatalf("Read() error = %v, want captured Git stderr", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installFakeGit(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "git")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal(fmt.Errorf("inspect readiness signal: %w", err))
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("child did not create readiness signal")
|
||||
}
|
||||
59
internal/git/performance_test.go
Normal file
59
internal/git/performance_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkPerformanceBlobReader(b *testing.B) {
|
||||
fixture := newRemoteFixture(b)
|
||||
objectID := gitText(b, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||
const expectedBytes = int64(len("candidate"))
|
||||
|
||||
b.Run("new-session-per-read", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(expectedBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(expectedBytes), "bytes/op")
|
||||
b.ReportMetric(1, "blobs/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
reader := NewBlobReader(context.Background(), fixture.work)
|
||||
contents, err := reader.Read(context.Background(), objectID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if int64(len(contents)) != expectedBytes {
|
||||
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(contents)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("shared-session", func(b *testing.B) {
|
||||
reader := NewBlobReader(context.Background(), fixture.work)
|
||||
b.Cleanup(func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
})
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(expectedBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(expectedBytes), "bytes/op")
|
||||
b.ReportMetric(1, "blobs/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
contents, err := reader.Read(context.Background(), objectID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if int64(len(contents)) != expectedBytes {
|
||||
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
|
||||
}
|
||||
runtime.KeepAlive(contents)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -164,7 +164,7 @@ type remoteFixture struct {
|
||||
commit string
|
||||
}
|
||||
|
||||
func newRemoteFixture(t *testing.T) remoteFixture {
|
||||
func newRemoteFixture(t testing.TB) remoteFixture {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
work := filepath.Join(base, "work")
|
||||
@@ -194,7 +194,7 @@ func resolveFixture(t *testing.T, repo remoteFixture) Endpoint {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func gitRun(t *testing.T, dir string, args ...string) {
|
||||
func gitRun(t testing.TB, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
@@ -203,7 +203,7 @@ func gitRun(t *testing.T, dir string, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func gitText(t *testing.T, dir string, args ...string) string {
|
||||
func gitText(t testing.TB, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
@@ -214,7 +214,7 @@ func gitText(t *testing.T, dir string, args ...string) string {
|
||||
return strings.TrimSuffix(string(outputBytes), "\n")
|
||||
}
|
||||
|
||||
func writeRepoFile(t *testing.T, repoDir, name, contents string) {
|
||||
func writeRepoFile(t testing.TB, repoDir, name, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repoDir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
|
||||
@@ -62,6 +62,13 @@ func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
|
||||
if !errors.As(err, &exitError) {
|
||||
t.Fatalf("blob error %v lost its Git process cause", err)
|
||||
}
|
||||
reader := NewBlobReader(t.Context(), repository)
|
||||
if _, err := reader.Read(t.Context(), blob); err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("batch blob read error = %v, want missing-object inspection error", err)
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatalf("close missing-object batch reader: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(attempts); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("object inspection invoked remote transport: %q, %v", data, err)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||
Stderr: captured,
|
||||
})
|
||||
if err != nil {
|
||||
if output := strings.TrimSpace(string(captured.Bytes())); output != "" {
|
||||
if output := strings.TrimSpace(string(captured.take())); output != "" {
|
||||
return fmt.Errorf("go mod download: %w: %s", err, output)
|
||||
}
|
||||
return fmt.Errorf("go mod download: %w", err)
|
||||
@@ -121,8 +121,19 @@ func (tool *Tool) HostTarget() Target {
|
||||
// Run executes a Go command with ReleaseKit's shared offline baseline. Native
|
||||
// commands use the host target and effective cgo configuration. Static commands
|
||||
// use target and disable cgo. Output, when supplied, receives stdout and stderr
|
||||
// as the command runs; the returned bytes retain both streams for diagnostics.
|
||||
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
|
||||
// as the command runs.
|
||||
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) error {
|
||||
_, err := tool.execute(ctx, directory, mode, target, output, false, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Output executes a Go command and returns its combined stdout and stderr.
|
||||
// Output, when supplied, also receives the bytes as the command runs.
|
||||
func (tool *Tool) Output(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
|
||||
return tool.execute(ctx, directory, mode, target, output, true, args...)
|
||||
}
|
||||
|
||||
func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, keepOutput bool, args ...string) ([]byte, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("Go command requires arguments")
|
||||
}
|
||||
@@ -136,14 +147,24 @@ func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target T
|
||||
Stdout: captured,
|
||||
Stderr: captured,
|
||||
})
|
||||
contents := captured.Bytes()
|
||||
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
||||
}
|
||||
contents := captured.take()
|
||||
if err != nil {
|
||||
if len(contents) != 0 {
|
||||
return contents, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
}
|
||||
return contents, fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return contents, nil
|
||||
return outputBytes(contents, keepOutput), nil
|
||||
}
|
||||
|
||||
func outputBytes(contents []byte, keep bool) []byte {
|
||||
if keep {
|
||||
return contents
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tool *Tool) environment(mode Mode, target Target) map[string]string {
|
||||
@@ -206,9 +227,10 @@ func discover(ctx context.Context, directory string, environment map[string]stri
|
||||
}
|
||||
|
||||
type combinedWriter struct {
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
buffer bytes.Buffer
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
buffer bytes.Buffer
|
||||
forwardErr error
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
@@ -217,11 +239,29 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
if writer.output == nil {
|
||||
return writer.buffer.Write(contents)
|
||||
}
|
||||
return io.MultiWriter(&writer.buffer, writer.output).Write(contents)
|
||||
if _, err := writer.buffer.Write(contents); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := writer.output.Write(contents)
|
||||
if err == nil && written != len(contents) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil && writer.forwardErr == nil {
|
||||
writer.forwardErr = err
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Bytes() []byte {
|
||||
func (writer *combinedWriter) forwardingError() error {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return append([]byte(nil), writer.buffer.Bytes()...)
|
||||
return writer.forwardErr
|
||||
}
|
||||
|
||||
// take transfers the completed capture to the caller. It must only be used
|
||||
// after the child process has stopped writing.
|
||||
func (writer *combinedWriter) take() []byte {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.buffer.Bytes()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gotool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -62,6 +63,14 @@ func runGoPreparationFixture() int {
|
||||
case "fail":
|
||||
fmt.Fprintln(os.Stderr, "fixture dependency failure")
|
||||
return 23
|
||||
case "output":
|
||||
fmt.Fprintln(os.Stdout, "fixture stdout")
|
||||
fmt.Fprintln(os.Stderr, "fixture stderr")
|
||||
return 0
|
||||
case "large-fail":
|
||||
_, _ = fmt.Fprint(os.Stdout, strings.Repeat("x", 8<<20))
|
||||
fmt.Fprintln(os.Stderr, "fixture large failure")
|
||||
return 23
|
||||
case "wait":
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
@@ -295,7 +304,7 @@ func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
|
||||
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
|
||||
t.Helper()
|
||||
args := append([]string{"env", "-json"}, names...)
|
||||
output, err := tool.Run(context.Background(), t.TempDir(), mode, target, nil, args...)
|
||||
output, err := tool.Output(context.Background(), t.TempDir(), mode, target, nil, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -306,6 +315,122 @@ func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...s
|
||||
return settings
|
||||
}
|
||||
|
||||
func TestOutputReturnsAndForwardsCombinedOutput(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||
tool := fixtureTool()
|
||||
var forwarded bytes.Buffer
|
||||
|
||||
output, err := tool.Output(context.Background(), repository, Native, Target{}, &forwarded, "version")
|
||||
if err != nil {
|
||||
t.Fatalf("Output() error = %v", err)
|
||||
}
|
||||
if !bytes.Contains(output, []byte("fixture stdout")) || !bytes.Contains(output, []byte("fixture stderr")) {
|
||||
t.Fatalf("combined output = %q", output)
|
||||
}
|
||||
if !bytes.Equal(forwarded.Bytes(), output) {
|
||||
t.Fatalf("forwarded output differs from capture: forwarded %d bytes, captured %d", forwarded.Len(), len(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunForwardsOutputWithoutReturningCapture(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||
tool := fixtureTool()
|
||||
var forwarded bytes.Buffer
|
||||
|
||||
if err := tool.Run(context.Background(), repository, Native, Target{}, &forwarded, "version"); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !bytes.Contains(forwarded.Bytes(), []byte("fixture stdout")) || !bytes.Contains(forwarded.Bytes(), []byte("fixture stderr")) {
|
||||
t.Fatalf("forwarded output = %q", forwarded.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesFailureDiagnosticsAndCause(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail")
|
||||
tool := fixtureTool()
|
||||
|
||||
err := tool.Run(context.Background(), repository, Native, Target{}, nil, "version")
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
|
||||
tool := fixtureTool()
|
||||
|
||||
output, err := tool.Output(context.Background(), repository, Native, Target{}, nil, "version")
|
||||
var exitError *exec.ExitError
|
||||
wantSize := (8 << 20) + len("fixture large failure\n")
|
||||
if len(output) != wantSize {
|
||||
t.Fatalf("captured output = %d bytes, want %d", len(output), wantSize)
|
||||
}
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture large failure") {
|
||||
t.Fatalf("Output() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputReturnsCancellation(t *testing.T) {
|
||||
repository, recordPath := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
||||
tool := fixtureTool()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := tool.Output(ctx, repository, Native, Target{}, nil, "version")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(recordPath); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("Go fixture did not start")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Output() error = %v; want cancellation", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Go fixture did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputPreservesForwardingWriterFailure(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||
tool := fixtureTool()
|
||||
writerErr := errors.New("fixture output writer failed")
|
||||
|
||||
output, err := tool.Output(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
|
||||
if !errors.Is(err, writerErr) || !strings.Contains(err.Error(), "go version") {
|
||||
t.Fatalf("Output() error = %v; want wrapped writer error and command", err)
|
||||
}
|
||||
if len(output) == 0 {
|
||||
t.Fatal("Output() returned no captured bytes after forwarding failed")
|
||||
}
|
||||
}
|
||||
|
||||
type failingOutputWriter struct{ err error }
|
||||
|
||||
func (writer failingOutputWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
|
||||
func fixtureTool() *Tool {
|
||||
return &Tool{host: Target{OS: runtime.GOOS, Arch: runtime.GOARCH}, settings: map[string]string{}}
|
||||
}
|
||||
|
||||
func clearGoSettings(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{
|
||||
|
||||
55
internal/gotool/performance_test.go
Normal file
55
internal/gotool/performance_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package gotool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const performanceDiagnosticSize = 8 << 20
|
||||
|
||||
func BenchmarkPerformanceCombinedOutput(b *testing.B) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
size int
|
||||
chunkSize int
|
||||
}{
|
||||
{name: "modest", size: 4 << 10, chunkSize: 4 << 10},
|
||||
{name: "diagnostic-8MiB", size: performanceDiagnosticSize, chunkSize: performanceDiagnosticSize},
|
||||
{name: "diagnostic-8MiB-32KiB-chunks", size: performanceDiagnosticSize, chunkSize: 32 << 10},
|
||||
} {
|
||||
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
|
||||
input = input[:testCase.size]
|
||||
b.Run(testCase.name+"/discard-forwarding", func(b *testing.B) {
|
||||
// Verify capture and forwarding for this workload outside the measurement.
|
||||
var forwarded bytes.Buffer
|
||||
verification := &combinedWriter{output: &forwarded}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
b.Fatal("captured and forwarded output must equal the complete input")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := &combinedWriter{output: io.Discard}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
captured := writer.take()
|
||||
if len(captured) != len(input) {
|
||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Invocation describes one child process. UnsetEnv removes inherited values
|
||||
@@ -27,16 +28,11 @@ type Invocation struct {
|
||||
// Run starts the command in the requested directory and waits for it to exit.
|
||||
// Errors preserve the underlying exec error, including *exec.ExitError.
|
||||
func Run(ctx context.Context, invocation Invocation) error {
|
||||
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
|
||||
return errors.New("process invocation requires a command")
|
||||
command, err := newCommand(ctx, invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||
command.Dir = invocation.Dir
|
||||
command.Stdin = invocation.Stdin
|
||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
||||
}
|
||||
command.Stdout = invocation.Stdout
|
||||
command.Stderr = invocation.Stderr
|
||||
|
||||
@@ -49,6 +45,102 @@ func Run(ctx context.Context, invocation Invocation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PipedProcess owns the command started by StartPiped. Callers close Stdin,
|
||||
// read Stdout to EOF, and call Wait once after a normal exchange. On an abort,
|
||||
// close active pipes, call Cancel, and then Wait to reap the child.
|
||||
type PipedProcess struct {
|
||||
command *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
parent context.Context
|
||||
cancel context.CancelFunc
|
||||
waitOnce sync.Once
|
||||
waitErr error
|
||||
}
|
||||
|
||||
// Stdin returns the child process input pipe. The caller owns its closure.
|
||||
func (child *PipedProcess) Stdin() io.WriteCloser {
|
||||
return child.stdin
|
||||
}
|
||||
|
||||
// Stdout returns the child process output pipe. The caller must consume it
|
||||
// before Wait on a normal exit and owns its closure on an abort.
|
||||
func (child *PipedProcess) Stdout() io.ReadCloser {
|
||||
return child.stdout
|
||||
}
|
||||
|
||||
// Cancel interrupts the child without cancelling its parent context.
|
||||
func (child *PipedProcess) Cancel() {
|
||||
child.cancel()
|
||||
}
|
||||
|
||||
// Wait waits for and reaps the child. It preserves the parent's cancellation
|
||||
// cause and otherwise returns the command's wrapped exit error.
|
||||
func (child *PipedProcess) Wait() error {
|
||||
child.waitOnce.Do(func() {
|
||||
err := child.command.Wait()
|
||||
child.cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if contextErr := child.parent.Err(); contextErr != nil {
|
||||
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], contextErr)
|
||||
return
|
||||
}
|
||||
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], err)
|
||||
})
|
||||
return child.waitErr
|
||||
}
|
||||
|
||||
// StartPiped starts a child with stdin and stdout connected to pipes. Command
|
||||
// settings come from invocation; its Stdin and Stdout fields are ignored, while
|
||||
// Stderr remains routed as requested.
|
||||
func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, error) {
|
||||
childContext, cancel := context.WithCancel(ctx)
|
||||
command, err := newCommand(childContext, invocation)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
command.Stderr = invocation.Stderr
|
||||
stdin, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("pipe stdin for %q: %w", invocation.Args[0], err)
|
||||
}
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("pipe stdout for %q: %w", invocation.Args[0], err)
|
||||
}
|
||||
if err := command.Start(); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
||||
}
|
||||
return &PipedProcess{
|
||||
command: command,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
parent: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
|
||||
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
|
||||
return nil, errors.New("process invocation requires a command")
|
||||
}
|
||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||
command.Dir = invocation.Dir
|
||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func environmentEntries(overlay map[string]string, unset []string) []string {
|
||||
environment := make(map[string]string, len(os.Environ())+len(overlay))
|
||||
for _, entry := range os.Environ() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -117,6 +119,146 @@ func TestRunReturnsCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
||||
workdir := filepath.Join(t.TempDir(), "piped directory")
|
||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
|
||||
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
|
||||
var stderr bytes.Buffer
|
||||
child, err := StartPiped(context.Background(), Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "exchange"},
|
||||
Dir: workdir,
|
||||
Env: map[string]string{
|
||||
"RELEASEKIT_PROCESS_HELPER": "1",
|
||||
"RELEASEKIT_PROCESS_OVERLAY": "child",
|
||||
},
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(child.Stdin(), "request\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read response before closing input: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("request|%s|argument with spaces|child|inherited\n", workdir)
|
||||
if response != want {
|
||||
t.Fatalf("piped response = %q, want %q", response, want)
|
||||
}
|
||||
if err := child.Stdin().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := child.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stderr.String(); got != "child stderr\n" {
|
||||
t.Fatalf("child stderr = %q, want %q", got, "child stderr\n")
|
||||
}
|
||||
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
|
||||
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
child, err := StartPiped(ctx, Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
||||
if err != nil || line != "started\n" {
|
||||
cancel()
|
||||
_ = child.Stdin().Close()
|
||||
_ = child.Stdout().Close()
|
||||
_ = child.Wait()
|
||||
t.Fatalf("readiness = %q, %v; want started signal", line, err)
|
||||
}
|
||||
cancel()
|
||||
if err := child.Stdin().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := child.Stdout().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := child.Wait(); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Wait() error = %v, want context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
child, err := StartPiped(ctx, Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
||||
if err != nil || line != "started\n" {
|
||||
child.Cancel()
|
||||
_ = child.Stdin().Close()
|
||||
_ = child.Stdout().Close()
|
||||
_ = child.Wait()
|
||||
t.Fatalf("readiness = %q, %v; want started signal", line, err)
|
||||
}
|
||||
child.Cancel()
|
||||
if err := child.Stdin().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := child.Stdout().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = child.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("Wait() error = %v, want interrupted child exit", err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("child cancellation changed parent context: %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedReportsStartFailure(t *testing.T) {
|
||||
child, err := StartPiped(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
||||
if err == nil || child != nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
||||
t.Fatalf("StartPiped() = %#v, %v; want a missing executable error", child, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedPreservesNonzeroExit(t *testing.T) {
|
||||
child, err := StartPiped(context.Background(), Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := child.Stdin().Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = child.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
|
||||
t.Fatalf("Wait() error = %v, want exit code 7", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHelper(t *testing.T) {
|
||||
if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" {
|
||||
return
|
||||
@@ -142,6 +284,21 @@ func TestProcessHelper(t *testing.T) {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
case "exchange":
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
request, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
os.Exit(11)
|
||||
}
|
||||
workdir, err := os.Getwd()
|
||||
if err != nil {
|
||||
os.Exit(12)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s\n", strings.TrimSuffix(request, "\n"), workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
|
||||
fmt.Fprintln(os.Stderr, "child stderr")
|
||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
||||
os.Exit(13)
|
||||
}
|
||||
default:
|
||||
os.Exit(10)
|
||||
}
|
||||
|
||||
43
internal/runtimeinfo/performance_test.go
Normal file
43
internal/runtimeinfo/performance_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package runtimeinfo
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var performanceVersionSink string
|
||||
|
||||
func BenchmarkPerformanceVersion(b *testing.B) {
|
||||
b.Run("Injected", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
performanceVersionSink = version("v1.2.3", debug.ReadBuildInfo)
|
||||
if performanceVersionSink != "v1.2.3" {
|
||||
b.Fatalf("version() = %q, want injected version", performanceVersionSink)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("FallbackInitialization", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
fallback := newFallbackVersion(debug.ReadBuildInfo)
|
||||
performanceVersionSink = fallback()
|
||||
if performanceVersionSink == "" {
|
||||
b.Fatal("fallback returned an empty version")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("RealMetadataFallback", func(b *testing.B) {
|
||||
_ = Version()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
performanceVersionSink = Version()
|
||||
if performanceVersionSink == "" {
|
||||
b.Fatal("Version() returned an empty version")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,26 @@
|
||||
// public buildinfo facade.
|
||||
package runtimeinfo
|
||||
|
||||
import "runtime/debug"
|
||||
import (
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var injectedVersion string
|
||||
|
||||
// Version returns the injected version or the running executable's main module
|
||||
// version, falling back to "dev" when neither provides a release identifier.
|
||||
func Version() string {
|
||||
return version(injectedVersion, debug.ReadBuildInfo)
|
||||
if injectedVersion != "" {
|
||||
return injectedVersion
|
||||
}
|
||||
return fallbackVersion()
|
||||
}
|
||||
|
||||
var fallbackVersion = newFallbackVersion(debug.ReadBuildInfo)
|
||||
|
||||
func newFallbackVersion(readBuildInfo func() (*debug.BuildInfo, bool)) func() string {
|
||||
return sync.OnceValue(func() string { return version("", readBuildInfo) })
|
||||
}
|
||||
|
||||
func version(injected string, readBuildInfo func() (*debug.BuildInfo, bool)) string {
|
||||
|
||||
@@ -2,6 +2,8 @@ package runtimeinfo
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -55,3 +57,54 @@ func TestVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackVersionReadsMetadataOnceConcurrently(t *testing.T) {
|
||||
var reads atomic.Int32
|
||||
fallback := newFallbackVersion(func() (*debug.BuildInfo, bool) {
|
||||
reads.Add(1)
|
||||
return &debug.BuildInfo{Main: debug.Module{Version: "v4.5.6"}}, true
|
||||
})
|
||||
|
||||
const callers = 32
|
||||
results := make(chan string, callers)
|
||||
var workers sync.WaitGroup
|
||||
workers.Add(callers)
|
||||
for i := 0; i < callers; i++ {
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
results <- fallback()
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
for result := range results {
|
||||
if result != "v4.5.6" {
|
||||
t.Errorf("fallback() = %q, want stable version", result)
|
||||
}
|
||||
}
|
||||
if got := reads.Load(); got != 1 {
|
||||
t.Fatalf("metadata reader called %d times, want once", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackVersionCachesUnavailableAndDevelopmentMetadata(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
read func() (*debug.BuildInfo, bool)
|
||||
}{
|
||||
{name: "unavailable", read: func() (*debug.BuildInfo, bool) { return nil, false }},
|
||||
{name: "development", read: func() (*debug.BuildInfo, bool) {
|
||||
return &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, true
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fallback := newFallbackVersion(test.read)
|
||||
if got := fallback(); got != "dev" {
|
||||
t.Fatalf("fallback() = %q, want dev", got)
|
||||
}
|
||||
if got := fallback(); got != "dev" {
|
||||
t.Fatalf("repeated fallback() = %q, want dev", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
if err := os.Remove(filepath.Join(repository, "docs", "inside")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,6 +103,7 @@ func TestResolveDestinationHandlesExpandingCyclesAndRepeatedLinks(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
for _, test := range []struct {
|
||||
destination string
|
||||
kind DestinationKind
|
||||
@@ -155,6 +157,7 @@ func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
gotWithoutNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -195,6 +198,7 @@ func TestResolveDestinationPreservesParentTraversalOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
for _, test := range []struct {
|
||||
destination string
|
||||
kind DestinationKind
|
||||
@@ -253,6 +257,7 @@ func TestResolveDestinationMapsAbsolutePathsIntoCandidate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
if err := os.Remove(filepath.Join(repository, "README.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}})
|
||||
if err != nil {
|
||||
@@ -170,6 +171,7 @@ func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
|
||||
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err = checkGoFormat(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat {
|
||||
@@ -70,6 +71,7 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err = checkGoFormat(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
|
||||
@@ -88,8 +90,8 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
|
||||
repositoryDir: t.TempDir(),
|
||||
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
|
||||
byPath: map[string]int{"missing.go": 0},
|
||||
blobs: make(map[string][]byte),
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err := checkGoFormat(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
|
||||
@@ -101,6 +103,33 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckGoFormatReportsEveryPathForRepeatedUnformattedContent(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
contents := []byte("package app\nfunc value(){return}\n")
|
||||
writeTreeFile(t, repository, "first.go", contents)
|
||||
writeTreeFile(t, repository, "nested/second.go", contents)
|
||||
commit := commitCandidateTree(t, repository)
|
||||
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
err = checkGoFormat(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat {
|
||||
t.Fatalf("checkGoFormat() error = %v, want go-format findings", err)
|
||||
}
|
||||
if len(failure.Findings) != 2 || failure.Findings[0].Path != "first.go" || failure.Findings[1].Path != "nested/second.go" {
|
||||
t.Fatalf("findings = %#v, want both paths in tree order", failure.Findings)
|
||||
}
|
||||
first, _ := tree.Entry("first.go")
|
||||
second, _ := tree.Entry("nested/second.go")
|
||||
if first.ObjectID != second.ObjectID {
|
||||
t.Fatalf("fixture object IDs = %q and %q, want repeated content", first.ObjectID, second.ObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGoFormatIsOptionalAndStopsAfterFinding(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "unformatted.go", []byte("package fixture\nfunc Value( ){return 1}\n"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"golang.org/x/mod/modfile"
|
||||
@@ -11,12 +12,16 @@ import (
|
||||
|
||||
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
replacementsByObjectID := make(map[string][]moduleReplacementSummary)
|
||||
for _, entry := range tree.Entries() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
|
||||
}
|
||||
if path.Base(entry.Path) != "go.mod" {
|
||||
continue
|
||||
}
|
||||
|
||||
contents, err := readModuleDeclaration(ctx, tree, entry)
|
||||
declaration, err := resolveModuleDeclaration(ctx, tree, entry)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
@@ -24,26 +29,49 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
Cause: fmt.Errorf("inspect committed %s: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
|
||||
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
|
||||
replacements, ok := replacementsByObjectID[declaration.ObjectID]
|
||||
if !ok {
|
||||
contents, err := tree.ReadFile(ctx, declaration.Path)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not inspect committed module declaration"}},
|
||||
Cause: fmt.Errorf("inspect committed %s: read module declaration: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
|
||||
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
replacements = make([]moduleReplacementSummary, 0, len(moduleFile.Replace))
|
||||
for _, replacement := range moduleFile.Replace {
|
||||
if replacement.New.Version != "" {
|
||||
continue
|
||||
}
|
||||
line := 0
|
||||
if replacement.Syntax != nil {
|
||||
line = replacement.Syntax.Start.Line
|
||||
}
|
||||
replacements = append(replacements, moduleReplacementSummary{
|
||||
target: strings.Clone(replacement.New.Path),
|
||||
line: line,
|
||||
})
|
||||
}
|
||||
contents = nil
|
||||
moduleFile = nil
|
||||
replacementsByObjectID[declaration.ObjectID] = replacements
|
||||
}
|
||||
for _, replacement := range moduleFile.Replace {
|
||||
if replacement.New.Version != "" {
|
||||
continue
|
||||
}
|
||||
for _, replacement := range replacements {
|
||||
finding := Finding{
|
||||
Path: entry.Path,
|
||||
Message: "filesystem module replacement is committed",
|
||||
Target: replacement.New.Path,
|
||||
Target: replacement.target,
|
||||
Directive: "replace",
|
||||
}
|
||||
if replacement.Syntax != nil {
|
||||
finding.Line = replacement.Syntax.Start.Line
|
||||
Line: replacement.line,
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
@@ -54,39 +82,36 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) ([]byte, error) {
|
||||
type moduleReplacementSummary struct {
|
||||
target string
|
||||
line int
|
||||
}
|
||||
|
||||
func resolveModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) (git.TreeEntry, error) {
|
||||
if entry.Type != "blob" {
|
||||
return nil, fmt.Errorf("entry has type %q, not a blob", entry.Type)
|
||||
return git.TreeEntry{}, fmt.Errorf("entry has type %q, not a blob", entry.Type)
|
||||
}
|
||||
if entry.Mode != "120000" {
|
||||
if entry.Mode != "100644" && entry.Mode != "100755" {
|
||||
return nil, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
|
||||
return git.TreeEntry{}, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
|
||||
}
|
||||
contents, err := tree.ReadFile(ctx, entry.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read module declaration: %w", err)
|
||||
}
|
||||
return contents, nil
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
target, err := tree.ReadFile(ctx, entry.Path)
|
||||
target, err := tree.readSymlinkTarget(ctx, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read symlink declaration: %w", err)
|
||||
return git.TreeEntry{}, fmt.Errorf("read symlink declaration: %w", err)
|
||||
}
|
||||
destination, err := tree.ResolveDestination(ctx, entry.Path, string(target))
|
||||
destination, err := tree.ResolveDestination(ctx, entry.Path, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
|
||||
return git.TreeEntry{}, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
|
||||
}
|
||||
if destination.Kind != DestinationEntry {
|
||||
return nil, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
|
||||
return git.TreeEntry{}, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
|
||||
}
|
||||
resolved, ok := tree.Entry(destination.Path)
|
||||
if !ok || resolved.Type != "blob" || (resolved.Mode != "100644" && resolved.Mode != "100755") {
|
||||
return nil, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
|
||||
return git.TreeEntry{}, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
|
||||
}
|
||||
contents, err := tree.ReadFile(ctx, destination.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read resolved module declaration %q: %w", destination.Path, err)
|
||||
}
|
||||
return contents, nil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
failure, ok := err.(*Failure)
|
||||
@@ -61,6 +62,7 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
failure, ok := err.(*Failure)
|
||||
if !ok || failure.Check != LocalReplacements || len(failure.Findings) != 1 {
|
||||
@@ -72,6 +74,57 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "shared/module.mod", []byte(
|
||||
"module shared.example/module\n"+
|
||||
"go 1.25.0\n"+
|
||||
"replace example.org/one => ../one\n"+
|
||||
"replace example.org/two => ../two\n",
|
||||
))
|
||||
for _, directory := range []string{"first", "second"} {
|
||||
if err := os.MkdirAll(filepath.Join(repository, directory), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../shared/module.mod", filepath.Join(repository, directory, "go.mod")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
commit := commitCandidateTree(t, repository)
|
||||
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != LocalReplacements || len(failure.Findings) != 4 {
|
||||
t.Fatalf("checkLocalReplacements() error = %v, want four reused findings", err)
|
||||
}
|
||||
want := []struct {
|
||||
path string
|
||||
line int
|
||||
target string
|
||||
}{
|
||||
{"first/go.mod", 3, "../one"},
|
||||
{"first/go.mod", 4, "../two"},
|
||||
{"second/go.mod", 3, "../one"},
|
||||
{"second/go.mod", 4, "../two"},
|
||||
}
|
||||
for index, finding := range failure.Findings {
|
||||
if finding.Path != want[index].path || finding.Line != want[index].line || finding.Target != want[index].target {
|
||||
t.Errorf("finding[%d] = %#v, want %#v", index, finding, want[index])
|
||||
}
|
||||
}
|
||||
first, _ := tree.Entry("first/go.mod")
|
||||
second, _ := tree.Entry("second/go.mod")
|
||||
shared, _ := tree.Entry("shared/module.mod")
|
||||
if first.ObjectID != second.ObjectID || first.ObjectID == shared.ObjectID {
|
||||
t.Fatalf("declaration object IDs = %q, %q; module body = %q", first.ObjectID, second.ObjectID, shared.ObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
if err := os.Symlink("missing.mod", filepath.Join(repository, "go.mod")); err != nil {
|
||||
@@ -82,6 +135,7 @@ func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
failure, ok := err.(*Failure)
|
||||
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
|
||||
@@ -100,6 +154,7 @@ func TestCheckLocalReplacementsPreservesParseCause(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
failure, ok := err.(*Failure)
|
||||
if !ok || failure.Check != LocalReplacements || failure.Cause == nil || !strings.Contains(failure.Cause.Error(), "parse committed go.mod") {
|
||||
@@ -167,7 +222,7 @@ func TestRunAttributesCandidateTreeFailureToLocalReplacements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func commitCandidateTree(t *testing.T, repository string) string {
|
||||
func commitCandidateTree(t testing.TB, repository string) string {
|
||||
t.Helper()
|
||||
gitTree(t, repository, "add", ".")
|
||||
gitTree(t, repository, "commit", "-m", "capture module declarations")
|
||||
|
||||
@@ -2,6 +2,7 @@ package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,19 +31,29 @@ type markdownDocument struct {
|
||||
}
|
||||
|
||||
type markdownCache struct {
|
||||
tree *CandidateTree
|
||||
documents map[string]*markdownDocument
|
||||
tree *CandidateTree
|
||||
byPath map[string]*markdownDocument
|
||||
byObjectID map[string]*markdownDocument
|
||||
}
|
||||
|
||||
func newMarkdownCache(tree *CandidateTree) *markdownCache {
|
||||
return &markdownCache{
|
||||
tree: tree,
|
||||
documents: make(map[string]*markdownDocument),
|
||||
tree: tree,
|
||||
byPath: make(map[string]*markdownDocument),
|
||||
byObjectID: make(map[string]*markdownDocument),
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
|
||||
if document, ok := cache.documents[path]; ok {
|
||||
if document, ok := cache.byPath[path]; ok {
|
||||
return document, nil
|
||||
}
|
||||
entry, ok := cache.tree.Entry(path)
|
||||
if !ok || !isRegularFile(entry) {
|
||||
return nil, fmt.Errorf("committed path %q is not a regular file", path)
|
||||
}
|
||||
if document, ok := cache.byObjectID[entry.ObjectID]; ok {
|
||||
cache.byPath[path] = document
|
||||
return document, nil
|
||||
}
|
||||
source, err := cache.tree.ReadFile(ctx, path)
|
||||
@@ -50,7 +61,9 @@ func (cache *markdownCache) document(ctx context.Context, path string) (*markdow
|
||||
return nil, err
|
||||
}
|
||||
document := parseMarkdown(source)
|
||||
cache.documents[path] = document
|
||||
source = nil
|
||||
cache.byObjectID[entry.ObjectID] = document
|
||||
cache.byPath[path] = document
|
||||
return document, nil
|
||||
}
|
||||
|
||||
@@ -69,15 +82,15 @@ func parseMarkdown(source []byte) *markdownDocument {
|
||||
switch node := node.(type) {
|
||||
case *ast.Link:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Title: node.Title.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Title: strings.Clone(string(node.Title.Value(source))),
|
||||
Reference: node.Reference != nil,
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
})
|
||||
case *ast.Image:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Title: node.Title.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Title: strings.Clone(string(node.Title.Value(source))),
|
||||
Image: true,
|
||||
Reference: node.Reference != nil,
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
@@ -86,7 +99,7 @@ func parseMarkdown(source []byte) *markdownDocument {
|
||||
return ast.WalkSkipChildren, nil
|
||||
case *ast.AutoLink:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
})
|
||||
case *ast.Heading:
|
||||
|
||||
@@ -150,6 +150,24 @@ func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMarkdownLinksUsesCurrentPathForSharedDocuments(t *testing.T) {
|
||||
shared := []byte("[target](target.md)\n")
|
||||
_, tree := committedMarkdownTree(t, map[string][]byte{
|
||||
"one/guide.md": shared,
|
||||
"one/target.md": []byte("# Target\n"),
|
||||
"two/guide.md": shared,
|
||||
})
|
||||
|
||||
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
||||
t.Fatalf("checkMarkdownLinks() error = %v, want one path-specific finding", err)
|
||||
}
|
||||
if finding := failure.Findings[0]; finding.Path != "two/guide.md" || finding.Target != "target.md" {
|
||||
t.Fatalf("shared-document finding = %#v, want missing target relative to two/guide.md", finding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations(t *testing.T) {
|
||||
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
||||
"guide.md": []byte("[outside](../../outside.md)\n[submodule](deps/module/README.md)\n"),
|
||||
@@ -289,6 +307,7 @@ func candidateTreeAtHead(t *testing.T, repository string) *CandidateTree {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
|
||||
@@ -87,11 +87,13 @@ func TestParseMarkdownHeadingAnchors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
|
||||
func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "guide.md", []byte("# Guide\n[other](other.md)\n"))
|
||||
writeTreeFile(t, repository, "other.md", []byte("# Other\n"))
|
||||
gitTree(t, repository, "add", "guide.md", "other.md")
|
||||
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
||||
writeTreeFile(t, repository, "one/guide.md", shared)
|
||||
writeTreeFile(t, repository, "two/guide.md", shared)
|
||||
writeTreeFile(t, repository, "one/other.md", []byte("# Other\n"))
|
||||
gitTree(t, repository, "add", "one/guide.md", "two/guide.md", "one/other.md")
|
||||
gitTree(t, repository, "commit", "-m", "add markdown files")
|
||||
|
||||
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||
@@ -99,26 +101,30 @@ func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
cache := newMarkdownCache(tree)
|
||||
first, err := cache.document(context.Background(), "guide.md")
|
||||
first, err := cache.document(context.Background(), "one/guide.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := cache.document(context.Background(), "guide.md")
|
||||
second, err := cache.document(context.Background(), "two/guide.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("repeated document lookup returned a different parsed document")
|
||||
t.Fatal("identical Markdown blobs returned different parsed documents")
|
||||
}
|
||||
other, err := cache.document(context.Background(), "other.md")
|
||||
other, err := cache.document(context.Background(), "one/other.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == first {
|
||||
t.Fatal("different candidate paths shared a parsed document")
|
||||
t.Fatal("different Markdown blobs shared a parsed document")
|
||||
}
|
||||
if _, ok := other.anchors["other"]; !ok {
|
||||
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||
}
|
||||
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||
}
|
||||
}
|
||||
|
||||
350
internal/validation/performance_test.go
Normal file
350
internal/validation/performance_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const performanceGoFileCount = 1000
|
||||
const performanceMarkdownSize = 1 << 20
|
||||
const performanceMarkdownLinkCount = 500
|
||||
|
||||
type performanceTree struct {
|
||||
repository string
|
||||
commit string
|
||||
paths []string
|
||||
expected map[string][]byte
|
||||
totalBytes int64
|
||||
}
|
||||
|
||||
func BenchmarkPerformanceValidation(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
repeated bool
|
||||
packed bool
|
||||
}{
|
||||
{name: "unique-loose"},
|
||||
{name: "unique-packed", packed: true},
|
||||
{name: "repeated-loose", repeated: true},
|
||||
{name: "repeated-packed", repeated: true, packed: true},
|
||||
}
|
||||
fixtures := make(map[string]performanceTree, len(cases))
|
||||
for _, testCase := range cases {
|
||||
fixtures[testCase.name] = newPerformanceTree(b, performanceGoFileCount, testCase.repeated, testCase.packed, false)
|
||||
}
|
||||
markdown := newPerformanceTree(b, 0, false, false, true)
|
||||
|
||||
for _, testCase := range cases {
|
||||
fixture := fixtures[testCase.name]
|
||||
b.Run("TreeMetadata/"+testCase.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(fixture.paths)), "files/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(tree.Entries()) != len(fixture.paths) {
|
||||
b.Fatalf("tree entries = %d, want %d", len(tree.Entries()), len(fixture.paths))
|
||||
}
|
||||
if err := tree.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(tree)
|
||||
}
|
||||
})
|
||||
b.Run("ContentAccess/"+testCase.name, func(b *testing.B) {
|
||||
trees := freshPerformanceTrees(b, fixture, b.N)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(fixture.totalBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(fixture.totalBytes), "bytes/op")
|
||||
b.ReportMetric(float64(len(fixture.paths)), "files/op")
|
||||
b.ReportMetric(float64(performanceGoFileCount), "go-files/op")
|
||||
for _, tree := range trees {
|
||||
var bytesRead int64
|
||||
for _, path := range fixture.paths {
|
||||
contents, err := tree.ReadFile(context.Background(), path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
bytesRead += int64(len(contents))
|
||||
}
|
||||
if bytesRead != fixture.totalBytes {
|
||||
b.Fatalf("read %d bytes, want %d", bytesRead, fixture.totalBytes)
|
||||
}
|
||||
if err := tree.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(tree)
|
||||
}
|
||||
runtime.KeepAlive(trees)
|
||||
})
|
||||
b.Run("GoFormat/"+testCase.name, func(b *testing.B) {
|
||||
trees := freshPerformanceTrees(b, fixture, b.N)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(fixture.totalBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(fixture.totalBytes), "bytes/op")
|
||||
b.ReportMetric(float64(performanceGoFileCount), "go-files/op")
|
||||
for _, tree := range trees {
|
||||
if err := checkGoFormat(context.Background(), tree); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := tree.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(tree)
|
||||
}
|
||||
runtime.KeepAlive(trees)
|
||||
})
|
||||
}
|
||||
|
||||
b.Run("Markdown/one-megabyte-repeated-links-and-identical-content", func(b *testing.B) {
|
||||
trees := freshPerformanceTrees(b, markdown, b.N)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(markdown.totalBytes)
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(markdown.totalBytes), "bytes/op")
|
||||
b.ReportMetric(float64(performanceMarkdownLinkCount*2), "repeated-links/op")
|
||||
for _, tree := range trees {
|
||||
if err := checkMarkdownLinks(context.Background(), tree, false, io.Discard); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := tree.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(tree)
|
||||
}
|
||||
runtime.KeepAlive(trees)
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkPerformanceRetainedContent(b *testing.B) {
|
||||
fixture := newPerformanceTree(b, performanceGoFileCount, false, false, true)
|
||||
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Cleanup(func() { _ = tree.Close() })
|
||||
cache := newMarkdownCache(tree)
|
||||
var markdownPaths []string
|
||||
for _, entry := range tree.Entries() {
|
||||
if strings.HasSuffix(strings.ToLower(entry.Path), ".md") {
|
||||
markdownPaths = append(markdownPaths, entry.Path)
|
||||
}
|
||||
}
|
||||
if len(markdownPaths) != 3 {
|
||||
b.Fatalf("Markdown paths = %v, want README and two identical documents", markdownPaths)
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
var before runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
b.ReportAllocs()
|
||||
b.ReportMetric(float64(len(fixture.paths)), "go-files/op")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := checkGoFormat(context.Background(), tree); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, path := range markdownPaths {
|
||||
if _, err := cache.document(context.Background(), path); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
if err := tree.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
runtime.GC()
|
||||
var after runtime.MemStats
|
||||
runtime.ReadMemStats(&after)
|
||||
retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
|
||||
if retained < 0 {
|
||||
retained = 0
|
||||
}
|
||||
b.ReportMetric(float64(retained), "retained-B")
|
||||
runtime.KeepAlive(tree)
|
||||
runtime.KeepAlive(cache)
|
||||
runtime.KeepAlive(fixture)
|
||||
}
|
||||
|
||||
func newPerformanceTree(tb testing.TB, goFileCount int, repeated, packed, includeMarkdown bool) performanceTree {
|
||||
tb.Helper()
|
||||
repository := newTreeRepository(tb)
|
||||
fixture := performanceTree{
|
||||
repository: repository,
|
||||
paths: make([]string, 0, goFileCount),
|
||||
expected: make(map[string][]byte, goFileCount+3),
|
||||
}
|
||||
for i := 0; i < goFileCount; i++ {
|
||||
name := fmt.Sprintf("source/file-%04d.go", i)
|
||||
contents := performanceGoSource(i)
|
||||
if repeated {
|
||||
contents = []byte("package source\n")
|
||||
}
|
||||
writeTreeFile(tb, repository, name, contents)
|
||||
fixture.paths = append(fixture.paths, name)
|
||||
fixture.expected[name] = contents
|
||||
fixture.totalBytes += int64(len(contents))
|
||||
}
|
||||
if includeMarkdown {
|
||||
markdown := performanceMarkdownDocument()
|
||||
readme := performanceMarkdownIndex()
|
||||
for name, contents := range map[string][]byte{
|
||||
"README.md": readme,
|
||||
"archive/payload.md": markdown,
|
||||
"docs/payload.md": markdown,
|
||||
} {
|
||||
writeTreeFile(tb, repository, name, contents)
|
||||
fixture.expected[name] = contents
|
||||
fixture.totalBytes += int64(len(contents))
|
||||
}
|
||||
}
|
||||
|
||||
fixture.commit = commitCandidateTree(tb, repository)
|
||||
if packed {
|
||||
gitTree(tb, repository, "gc", "--aggressive", "--prune=now")
|
||||
}
|
||||
verifyPerformanceTree(tb, fixture, repeated, packed, includeMarkdown)
|
||||
return fixture
|
||||
}
|
||||
|
||||
func freshPerformanceTrees(tb testing.TB, fixture performanceTree, count int) []*CandidateTree {
|
||||
tb.Helper()
|
||||
base, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(tb, base)
|
||||
trees := make([]*CandidateTree, count)
|
||||
for index := range trees {
|
||||
tree := *base
|
||||
tree.symlinkTargets = make(map[string]string)
|
||||
trees[index] = &tree
|
||||
}
|
||||
if err := base.Close(); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
runtime.KeepAlive(base)
|
||||
return trees
|
||||
}
|
||||
|
||||
func verifyPerformanceTree(tb testing.TB, fixture performanceTree, repeated, packed, includeMarkdown bool) {
|
||||
tb.Helper()
|
||||
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
if len(tree.Entries()) != len(fixture.expected) {
|
||||
tb.Fatalf("tree entries = %d, want %d", len(tree.Entries()), len(fixture.expected))
|
||||
}
|
||||
uniqueObjects := make(map[string]struct{}, len(fixture.paths))
|
||||
for _, path := range fixture.paths {
|
||||
contents, err := tree.ReadFile(context.Background(), path)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(contents, fixture.expected[path]) {
|
||||
tb.Fatalf("committed content at %q differs from fixture bytes", path)
|
||||
}
|
||||
uniqueObjects[tree.entries[tree.byPath[path]].ObjectID] = struct{}{}
|
||||
}
|
||||
wantObjects := len(fixture.paths)
|
||||
if repeated {
|
||||
wantObjects = 1
|
||||
}
|
||||
if len(uniqueObjects) != wantObjects {
|
||||
tb.Fatalf("unique Go object IDs = %d, want %d", len(uniqueObjects), wantObjects)
|
||||
}
|
||||
if includeMarkdown {
|
||||
for _, path := range []string{"README.md", "docs/payload.md", "archive/payload.md"} {
|
||||
contents, err := tree.ReadFile(context.Background(), path)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(contents, fixture.expected[path]) {
|
||||
tb.Fatalf("committed Markdown content at %q differs from fixture bytes", path)
|
||||
}
|
||||
}
|
||||
if len(fixture.expected["docs/payload.md"]) != performanceMarkdownSize {
|
||||
tb.Fatalf("large Markdown fixture size = %d, want %d", len(fixture.expected["docs/payload.md"]), performanceMarkdownSize)
|
||||
}
|
||||
if !bytes.Equal(fixture.expected["docs/payload.md"], fixture.expected["archive/payload.md"]) {
|
||||
tb.Fatal("Markdown copies differ")
|
||||
}
|
||||
if tree.entries[tree.byPath["docs/payload.md"]].ObjectID != tree.entries[tree.byPath["archive/payload.md"]].ObjectID {
|
||||
tb.Fatal("identical Markdown copies do not share an object ID")
|
||||
}
|
||||
index := string(fixture.expected["README.md"])
|
||||
if strings.Count(index, "docs/payload.md#shared-payload") != performanceMarkdownLinkCount || strings.Count(index, "archive/payload.md#shared-payload") != performanceMarkdownLinkCount {
|
||||
tb.Fatal("Markdown link fixture does not contain the expected repeated targets")
|
||||
}
|
||||
}
|
||||
if err := tree.Close(); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
|
||||
objects := gitTree(tb, fixture.repository, "count-objects", "-v")
|
||||
loose, packedCount := performanceObjectCounts(tb, objects)
|
||||
if packed {
|
||||
if loose != 0 || packedCount == 0 {
|
||||
tb.Fatalf("packed fixture object counts = loose %d, in-pack %d", loose, packedCount)
|
||||
}
|
||||
} else if loose == 0 || packedCount != 0 {
|
||||
tb.Fatalf("loose fixture object counts = loose %d, in-pack %d", loose, packedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func performanceObjectCounts(tb testing.TB, output string) (loose, packed int) {
|
||||
tb.Helper()
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, value, ok := strings.Cut(line, ": ")
|
||||
if !ok || (key != "count" && key != "in-pack") {
|
||||
continue
|
||||
}
|
||||
count, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
tb.Fatalf("parse git count-objects output %q: %v", line, err)
|
||||
}
|
||||
if key == "count" {
|
||||
loose = count
|
||||
} else {
|
||||
packed = count
|
||||
}
|
||||
}
|
||||
return loose, packed
|
||||
}
|
||||
|
||||
func performanceGoSource(index int) []byte {
|
||||
return []byte(fmt.Sprintf("package source\n\nconst value%04d = %q\n", index, fmt.Sprintf("payload-%04d", index)))
|
||||
}
|
||||
|
||||
func performanceMarkdownDocument() []byte {
|
||||
contents := make([]byte, performanceMarkdownSize)
|
||||
prefix := []byte("# Shared Payload\n\n")
|
||||
copy(contents, prefix)
|
||||
for index := len(prefix); index < len(contents)-1; index++ {
|
||||
contents[index] = 'x'
|
||||
}
|
||||
contents[len(contents)-1] = '\n'
|
||||
return contents
|
||||
}
|
||||
|
||||
func performanceMarkdownIndex() []byte {
|
||||
var contents strings.Builder
|
||||
for index := 0; index < performanceMarkdownLinkCount; index++ {
|
||||
fmt.Fprintf(&contents, "- [payload %d](docs/payload.md#shared-payload)\n", index)
|
||||
fmt.Fprintf(&contents, "- [copy %d](archive/payload.md#shared-payload)\n", index)
|
||||
}
|
||||
return []byte(contents.String())
|
||||
}
|
||||
@@ -13,26 +13,32 @@ import (
|
||||
// CandidateTree is an inventory of one captured commit with blob contents
|
||||
// loaded lazily by object ID.
|
||||
type CandidateTree struct {
|
||||
repositoryDir string
|
||||
entries []git.TreeEntry
|
||||
byPath map[string]int
|
||||
directories map[string]struct{}
|
||||
blobs map[string][]byte
|
||||
context context.Context
|
||||
repositoryDir string
|
||||
entries []git.TreeEntry
|
||||
byPath map[string]int
|
||||
directories map[string]struct{}
|
||||
blobReader *git.BlobReader
|
||||
blobReads uint64
|
||||
symlinkTargets map[string]string
|
||||
closed bool
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// ReadCandidateTree enumerates the captured commit without reading from the
|
||||
// worktree.
|
||||
// worktree. The supplied context also governs the scoped reader through closure.
|
||||
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
|
||||
entries, err := git.ReadTree(ctx, repositoryDir, commit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tree := &CandidateTree{
|
||||
repositoryDir: repositoryDir,
|
||||
entries: entries,
|
||||
byPath: make(map[string]int, len(entries)),
|
||||
directories: make(map[string]struct{}),
|
||||
blobs: make(map[string][]byte),
|
||||
context: ctx,
|
||||
repositoryDir: repositoryDir,
|
||||
entries: entries,
|
||||
byPath: make(map[string]int, len(entries)),
|
||||
directories: make(map[string]struct{}),
|
||||
symlinkTargets: make(map[string]string),
|
||||
}
|
||||
tree.directories["."] = struct{}{}
|
||||
for index, entry := range entries {
|
||||
@@ -49,9 +55,13 @@ func (tree *CandidateTree) Entries() []git.TreeEntry {
|
||||
return append([]git.TreeEntry(nil), tree.entries...)
|
||||
}
|
||||
|
||||
// ReadFile returns the committed blob bytes for path. Contents are cached by
|
||||
// object ID for later reads from this tree.
|
||||
// ReadFile returns newly allocated, caller-owned bytes for a committed blob.
|
||||
// Regular-file content is not cached, so repeated reads request the object
|
||||
// again through the tree's scoped blob reader.
|
||||
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
if tree.closed {
|
||||
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", path)
|
||||
}
|
||||
index, ok := tree.byPath[path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("committed path %q does not exist", path)
|
||||
@@ -60,17 +70,71 @@ func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, e
|
||||
if entry.Type != "blob" {
|
||||
return nil, fmt.Errorf("committed path %q is a %s entry, not a blob", path, entry.Type)
|
||||
}
|
||||
if contents, ok := tree.blobs[entry.ObjectID]; ok {
|
||||
return append([]byte(nil), contents...), nil
|
||||
if entry.Mode == "120000" {
|
||||
target, err := tree.readSymlinkTarget(ctx, entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(target), nil
|
||||
}
|
||||
contents, err := git.ReadBlob(ctx, tree.repositoryDir, entry.ObjectID)
|
||||
return tree.readBlob(ctx, entry)
|
||||
}
|
||||
|
||||
func (tree *CandidateTree) readSymlinkTarget(ctx context.Context, entry git.TreeEntry) (string, error) {
|
||||
if tree.closed {
|
||||
return "", fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
|
||||
}
|
||||
if entry.Type != "blob" || entry.Mode != "120000" {
|
||||
return "", fmt.Errorf("committed path %q is not a symlink blob", entry.Path)
|
||||
}
|
||||
if target, ok := tree.symlinkTargets[entry.ObjectID]; ok {
|
||||
return target, nil
|
||||
}
|
||||
contents, err := tree.readBlob(ctx, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read committed path %q: %w", path, err)
|
||||
return "", err
|
||||
}
|
||||
target := string(contents)
|
||||
if tree.symlinkTargets == nil {
|
||||
tree.symlinkTargets = make(map[string]string)
|
||||
}
|
||||
tree.symlinkTargets[entry.ObjectID] = target
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (tree *CandidateTree) readBlob(ctx context.Context, entry git.TreeEntry) ([]byte, error) {
|
||||
if tree.closed {
|
||||
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
|
||||
}
|
||||
if tree.blobReader == nil {
|
||||
// The operation context governs shutdown independently of each read.
|
||||
operationContext := tree.context
|
||||
if operationContext == nil {
|
||||
operationContext = context.Background()
|
||||
}
|
||||
tree.blobReader = git.NewBlobReader(operationContext, tree.repositoryDir)
|
||||
}
|
||||
tree.blobReads++
|
||||
contents, err := tree.blobReader.Read(ctx, entry.ObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read committed path %q: %w", entry.Path, err)
|
||||
}
|
||||
tree.blobs[entry.ObjectID] = append([]byte(nil), contents...)
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// Close closes the scoped local Git blob reader, if content was requested.
|
||||
// Metadata-only trees have no child process to close.
|
||||
func (tree *CandidateTree) Close() error {
|
||||
if tree.closed {
|
||||
return tree.closeErr
|
||||
}
|
||||
tree.closed = true
|
||||
if tree.blobReader != nil {
|
||||
tree.closeErr = tree.blobReader.Close()
|
||||
}
|
||||
return tree.closeErr
|
||||
}
|
||||
|
||||
// Entry returns the committed metadata for path when present.
|
||||
func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
|
||||
index, ok := tree.byPath[path]
|
||||
@@ -151,11 +215,12 @@ func (tree *CandidateTree) resolveDestination(ctx context.Context, directory str
|
||||
if active[partial] {
|
||||
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
||||
}
|
||||
linkText, err := tree.ReadFile(ctx, partial)
|
||||
entry, _ := tree.Entry(partial)
|
||||
linkText, err := tree.readSymlinkTarget(ctx, entry)
|
||||
if err != nil {
|
||||
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
||||
}
|
||||
target := string(linkText)
|
||||
target := linkText
|
||||
targetDirectory := directory
|
||||
if path.IsAbs(target) {
|
||||
relative, inside, err := tree.relativeAbsoluteDestination(target)
|
||||
|
||||
196
internal/validation/tree_lifecycle_test.go
Normal file
196
internal/validation/tree_lifecycle_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunAttributesUnexpectedReaderCloseFailureToLastContentCheck(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
|
||||
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
|
||||
commit := commitCandidateTree(t, repository)
|
||||
installLifecycleTools(t, `while IFS= read -r object; do
|
||||
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
|
||||
printf '%s blob %s\n' "$object" "$size"
|
||||
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
|
||||
printf '\n'
|
||||
done
|
||||
printf 'batch process failed during cleanup\n' >&2
|
||||
exit 23`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
markdown *MarkdownSettings
|
||||
}{
|
||||
{name: "formatting only"},
|
||||
{name: "later Markdown check without files", markdown: &MarkdownSettings{}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
request := lifecycleRequest(repository, commit)
|
||||
request.Settings.Markdown = testCase.markdown
|
||||
err := Run(context.Background(), request)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat {
|
||||
t.Fatalf("Run() error = %v, want cleanup failure attributed to GoFormat", err)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(failure.Cause, &exitErr) || exitErr.ExitCode() != 23 {
|
||||
t.Fatalf("cleanup cause = %v, want Git child exit code 23", failure.Cause)
|
||||
}
|
||||
if !strings.Contains(failure.Cause.Error(), "batch process failed during cleanup") {
|
||||
t.Fatalf("cleanup cause = %v, want captured Git stderr", failure.Cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesCheckFailureDuringReaderCleanup(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
|
||||
writeTreeFile(t, repository, "broken.go", []byte("package app\nfunc ( {\n"))
|
||||
commit := commitCandidateTree(t, repository)
|
||||
installLifecycleTools(t, `while IFS= read -r object; do
|
||||
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
|
||||
printf '%s blob %s\n' "$object" "$size"
|
||||
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
|
||||
printf '\n'
|
||||
done
|
||||
printf 'secondary cleanup failure\n' >&2
|
||||
exit 24`)
|
||||
|
||||
err := Run(context.Background(), lifecycleRequest(repository, commit))
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
|
||||
t.Fatalf("Run() error = %v, want original GoFormat check failure", err)
|
||||
}
|
||||
if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" {
|
||||
t.Fatalf("Run() findings = %#v, want original broken.go finding", failure.Findings)
|
||||
}
|
||||
if strings.Contains(failure.Cause.Error(), "secondary cleanup failure") {
|
||||
t.Fatalf("Run() replaced the check cause with cleanup output: %v", failure.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesCancellationDuringReaderCleanup(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
|
||||
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
|
||||
commit := commitCandidateTree(t, repository)
|
||||
ready := filepath.Join(t.TempDir(), "reader-ready")
|
||||
installLifecycleTools(t, `IFS= read -r object
|
||||
: > "$RELEASEKIT_GIT_READER_READY"
|
||||
exec sleep 60`)
|
||||
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- Run(ctx, lifecycleRequest(repository, commit)) }()
|
||||
waitForLifecycleFile(t, ready)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat || !errors.Is(failure.Cause, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want GoFormat cancellation cause", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not return after candidate reader cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesCancellationDuringReaderShutdown(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
|
||||
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
|
||||
commit := commitCandidateTree(t, repository)
|
||||
ready := filepath.Join(t.TempDir(), "shutdown-ready")
|
||||
installLifecycleTools(t, `while IFS= read -r object; do
|
||||
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
|
||||
printf '%s blob %s\n' "$object" "$size"
|
||||
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
|
||||
printf '\n'
|
||||
done
|
||||
printf '%s\n' "$$" > "$RELEASEKIT_GIT_READER_READY"
|
||||
exec sleep 60`)
|
||||
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- Run(ctx, lifecycleRequest(repository, commit)) }()
|
||||
waitForLifecycleFile(t, ready)
|
||||
pidBytes, err := os.ReadFile(ready)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != GoFormat || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want GoFormat cancellation cause", err)
|
||||
}
|
||||
if err := child.Signal(syscall.Signal(0)); !errors.Is(err, syscall.ESRCH) && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("child signal after Run() = %v, want reaped child", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = child.Kill()
|
||||
<-result
|
||||
t.Fatal("Run() did not return after cancellation during reader shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleRequest(repository, commit string) Request {
|
||||
return Request{
|
||||
RepositoryDir: repository,
|
||||
Commit: commit,
|
||||
Settings: Settings{GoFormat: true},
|
||||
}
|
||||
}
|
||||
|
||||
func installLifecycleTools(t *testing.T, batchScript string) {
|
||||
t.Helper()
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installGoShim(t, "", false)
|
||||
t.Setenv("RELEASEKIT_REAL_GIT", realGit)
|
||||
directory := t.TempDir()
|
||||
gitWrapper := filepath.Join(directory, "git")
|
||||
script := "#!/bin/sh\nset -eu\nif [ \"${1-}\" = cat-file ] && [ \"${2-}\" = --batch ]; then\n" + batchScript + "\nfi\nexec \"$RELEASEKIT_REAL_GIT\" \"$@\"\n"
|
||||
if err := os.WriteFile(gitWrapper, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
func waitForLifecycleFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal(fmt.Errorf("inspect reader readiness signal: %w", err))
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("candidate blob reader did not reach its blocking exchange")
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatalf("ReadCandidateTree() error = %v", err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
entries := make(map[string]gitTreeEntry, len(tree.Entries()))
|
||||
for _, entry := range tree.Entries() {
|
||||
entries[entry.Path] = gitTreeEntry{mode: entry.Mode, kind: entry.Type, size: entry.Size, hasSize: entry.HasSize}
|
||||
@@ -77,6 +78,61 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
|
||||
if err := os.Symlink("source.txt", filepath.Join(repository, "alias.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commit := commitCandidateTree(t, repository)
|
||||
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
first, err := tree.ReadFile(context.Background(), "source.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first[0] = 'X'
|
||||
second, err := tree.ReadFile(context.Background(), "source.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(second) != "committed content" {
|
||||
t.Fatalf("second regular-file read = %q, want committed content", second)
|
||||
}
|
||||
if &first[0] == &second[0] {
|
||||
t.Fatal("regular-file reads share a backing byte slice")
|
||||
}
|
||||
|
||||
linkFirst, err := tree.ReadFile(context.Background(), "alias.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkFirst[0] = 'X'
|
||||
linkSecond, err := tree.ReadFile(context.Background(), "alias.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(linkSecond) != "source.txt" {
|
||||
t.Fatalf("second symlink read = %q, want source.txt", linkSecond)
|
||||
}
|
||||
if len(tree.symlinkTargets) != 1 {
|
||||
t.Fatalf("cached symlink target count = %d, want 1", len(tree.symlinkTargets))
|
||||
}
|
||||
destination, err := tree.ResolveDestination(context.Background(), "README.md", "alias.txt")
|
||||
if err != nil || destination.Kind != DestinationEntry || destination.Path != "source.txt" {
|
||||
t.Fatalf("ResolveDestination() = %#v, %v; want source.txt entry", destination, err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupCandidateTree(tb testing.TB, tree *CandidateTree) {
|
||||
tb.Helper()
|
||||
tb.Cleanup(func() { _ = tree.Close() })
|
||||
}
|
||||
|
||||
func TestReadCandidateTreePreservesGitProcessError(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
_, err := ReadCandidateTree(context.Background(), repository, strings.Repeat("f", 40))
|
||||
@@ -93,7 +149,7 @@ type gitTreeEntry struct {
|
||||
hasSize bool
|
||||
}
|
||||
|
||||
func newTreeRepository(t *testing.T) string {
|
||||
func newTreeRepository(t testing.TB) string {
|
||||
t.Helper()
|
||||
repository := t.TempDir()
|
||||
gitTree(t, repository, "init", "-b", "main")
|
||||
@@ -102,7 +158,7 @@ func newTreeRepository(t *testing.T) string {
|
||||
return repository
|
||||
}
|
||||
|
||||
func writeTreeFile(t *testing.T, repository, name string, content []byte) {
|
||||
func writeTreeFile(t testing.TB, repository, name string, content []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
@@ -113,7 +169,7 @@ func writeTreeFile(t *testing.T, repository, name string, content []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func gitTree(t *testing.T, repository string, args ...string) string {
|
||||
func gitTree(t testing.TB, repository string, args ...string) string {
|
||||
t.Helper()
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = repository
|
||||
|
||||
@@ -63,7 +63,7 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
|
||||
}
|
||||
|
||||
// Run executes shared validation for the captured candidate.
|
||||
func Run(ctx context.Context, request Request) error {
|
||||
func Run(ctx context.Context, request Request) (result error) {
|
||||
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
||||
return &Failure{
|
||||
Check: FileSize,
|
||||
@@ -93,19 +93,19 @@ func Run(ctx context.Context, request Request) error {
|
||||
args = append(args, "-vet=off")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...)
|
||||
err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...)
|
||||
if err != nil {
|
||||
return &Failure{Check: GoTest, Cause: err}
|
||||
}
|
||||
if request.Settings.FullVet {
|
||||
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...")
|
||||
err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...")
|
||||
if err != nil {
|
||||
return &Failure{Check: GoVet, Cause: err}
|
||||
}
|
||||
}
|
||||
for _, target := range request.Settings.CompileTargets {
|
||||
selected := gotool.Target{OS: target.OS, Arch: target.Arch}
|
||||
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output,
|
||||
err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output,
|
||||
"build", "-mod=readonly", "-o", os.DevNull, "./...")
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
@@ -136,10 +136,23 @@ func Run(ctx context.Context, request Request) error {
|
||||
}
|
||||
return &Failure{Check: check, Cause: err}
|
||||
}
|
||||
lastContentCheck := Check("")
|
||||
defer func() {
|
||||
if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" {
|
||||
result = &Failure{
|
||||
Check: lastContentCheck,
|
||||
Cause: fmt.Errorf("close candidate Git blob reader: %w", closeErr),
|
||||
}
|
||||
}
|
||||
}()
|
||||
if request.Settings.GoFormat {
|
||||
readsBefore := tree.blobReads
|
||||
if err := checkGoFormat(ctx, tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if tree.blobReads != readsBefore {
|
||||
lastContentCheck = GoFormat
|
||||
}
|
||||
}
|
||||
if request.Settings.ModuleDistribution.WorkspaceFiles {
|
||||
if err := checkWorkspaceFiles(tree); err != nil {
|
||||
@@ -147,9 +160,13 @@ func Run(ctx context.Context, request Request) error {
|
||||
}
|
||||
}
|
||||
if request.Settings.ModuleDistribution.LocalReplacements {
|
||||
readsBefore := tree.blobReads
|
||||
if err := checkLocalReplacements(ctx, tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if tree.blobReads != readsBefore {
|
||||
lastContentCheck = LocalReplacements
|
||||
}
|
||||
}
|
||||
if request.Settings.FileSize != nil {
|
||||
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
|
||||
@@ -157,9 +174,13 @@ func Run(ctx context.Context, request Request) error {
|
||||
}
|
||||
}
|
||||
if request.Settings.Markdown != nil {
|
||||
readsBefore := tree.blobReads
|
||||
if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil {
|
||||
return err
|
||||
}
|
||||
if tree.blobReads != readsBefore {
|
||||
lastContentCheck = MarkdownLinks
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -187,6 +208,7 @@ func checkWorkspaceFiles(tree *CandidateTree) error {
|
||||
|
||||
func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
formattedByObjectID := make(map[string]bool)
|
||||
for _, entry := range tree.Entries() {
|
||||
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
|
||||
continue
|
||||
@@ -194,6 +216,12 @@ func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: GoFormat, Cause: err}
|
||||
}
|
||||
if formatted, ok := formattedByObjectID[entry.ObjectID]; ok {
|
||||
if !formatted {
|
||||
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
|
||||
}
|
||||
continue
|
||||
}
|
||||
contents, err := tree.ReadFile(ctx, entry.Path)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
@@ -216,7 +244,11 @@ func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
|
||||
Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(contents, formatted) {
|
||||
isFormatted := bytes.Equal(contents, formatted)
|
||||
formattedByObjectID[entry.ObjectID] = isFormatted
|
||||
contents = nil
|
||||
formatted = nil
|
||||
if !isFormatted {
|
||||
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
|
||||
}
|
||||
}
|
||||
|
||||
77
performance_benchmark_test.go
Normal file
77
performance_benchmark_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
)
|
||||
|
||||
func BenchmarkPerformanceFacadeCheck(b *testing.B) {
|
||||
repository, moduleBytes := performanceCheckFixture(b)
|
||||
warmRequest := releasekit.ReleaseRequest{
|
||||
RepositoryDir: repository.work,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
}
|
||||
if _, err := releasekit.Check(context.Background(), warmRequest); err != nil {
|
||||
b.Fatalf("warm benchmark Go build cache: %v", err)
|
||||
}
|
||||
b.Run("GoSuiteOnly", func(b *testing.B) {
|
||||
benchmarkFacadeCheck(b, repository, moduleBytes, releasekit.ValidationSpec{})
|
||||
})
|
||||
b.Run("GoSuiteAndSelectedChecks", func(b *testing.B) {
|
||||
benchmarkFacadeCheck(b, repository, moduleBytes, releasekit.ValidationSpec{
|
||||
GoFormat: true,
|
||||
Markdown: &releasekit.MarkdownValidationSpec{},
|
||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||
LocalReplacements: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func benchmarkFacadeCheck(b *testing.B, repository publishFixture, moduleBytes int64, validation releasekit.ValidationSpec) {
|
||||
b.Helper()
|
||||
request := releasekit.ReleaseRequest{
|
||||
RepositoryDir: repository.work,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
Validation: validation,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(moduleBytes), "module-bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := releasekit.Check(context.Background(), request); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performanceCheckFixture(b *testing.B) (publishFixture, int64) {
|
||||
b.Helper()
|
||||
repository := newPublishFixture(b)
|
||||
writeFile(b, repository.work, "source.go", "package fixture\n\nconst name = \"candidate\"\n")
|
||||
gitRun(b, repository.work, "add", "source.go")
|
||||
gitRun(b, repository.work, "commit", "-m", "Add check benchmark source")
|
||||
if status := gitText(b, repository.work, "status", "--porcelain"); status != "" {
|
||||
b.Fatalf("benchmark candidate is not clean:\n%s", status)
|
||||
}
|
||||
b.Setenv("GOMODCACHE", b.TempDir())
|
||||
b.Setenv("GOCACHE", b.TempDir())
|
||||
b.Setenv("GOWORK", "off")
|
||||
b.Setenv("GOPROXY", "off")
|
||||
b.Setenv("GOSUMDB", "off")
|
||||
var moduleBytes int64
|
||||
for _, name := range []string{"go.mod", "source.go", "fixture_test.go", "race_fixture_test.go"} {
|
||||
contents, err := os.ReadFile(filepath.Join(repository.work, name))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
moduleBytes += int64(len(contents))
|
||||
}
|
||||
return repository, moduleBytes
|
||||
}
|
||||
304
performance_delivery_test.go
Normal file
304
performance_delivery_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
|
||||
const performanceDeliveryArtifactSize = 16 << 20
|
||||
|
||||
type deliveryBenchmarkAsset struct {
|
||||
name string
|
||||
data []byte
|
||||
digest string
|
||||
}
|
||||
|
||||
type deliveryBenchmarkServer struct {
|
||||
mu sync.Mutex
|
||||
baseURL string
|
||||
commit string
|
||||
assets []deliveryBenchmarkAsset
|
||||
page bool
|
||||
attached map[string]deliveryBenchmarkIdentity
|
||||
}
|
||||
|
||||
type deliveryBenchmarkIdentity struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
|
||||
func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
|
||||
repository, build, expected := newDeliveryBenchmarkFixture(b)
|
||||
state := &deliveryBenchmarkServer{commit: gitText(b, repository, "rev-parse", "HEAD"), assets: expected}
|
||||
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.baseURL = server.URL
|
||||
b.Cleanup(server.Close)
|
||||
request := releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &build,
|
||||
}
|
||||
totalBytes := int64(2 * performanceDeliveryArtifactSize)
|
||||
|
||||
b.Run("FreshUploads", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(totalBytes)
|
||||
b.ReportMetric(float64(totalBytes), "artifact-bytes/op")
|
||||
b.ReportMetric(2, "binary-files/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
b.StopTimer()
|
||||
state.reset(false)
|
||||
b.StartTimer()
|
||||
result, err := releasekit.Deliver(context.Background(), request)
|
||||
b.StopTimer()
|
||||
if err != nil {
|
||||
b.Fatalf("Deliver() error = %v", err)
|
||||
}
|
||||
if len(result.Uploaded) != 3 {
|
||||
b.Fatalf("uploaded %d assets, want two binaries and a manifest", len(result.Uploaded))
|
||||
}
|
||||
state.verifyUploads(b)
|
||||
b.StartTimer()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MatchingRetry", func(b *testing.B) {
|
||||
b.StopTimer()
|
||||
state.reset(true)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(totalBytes)
|
||||
b.ReportMetric(float64(totalBytes), "artifact-bytes/op")
|
||||
b.ReportMetric(2, "binary-files/op")
|
||||
b.ResetTimer()
|
||||
b.StartTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, err := releasekit.Deliver(context.Background(), request)
|
||||
if err != nil {
|
||||
b.Fatalf("Deliver() error = %v", err)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || len(result.Matching) != 3 {
|
||||
b.Fatalf("matching retry uploaded %d and matched %d assets; want 0 and 3", len(result.Uploaded), len(result.Matching))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) {
|
||||
b.Helper()
|
||||
repository := filepath.Join(b.TempDir(), "repository")
|
||||
if err := os.MkdirAll(repository, 0o755); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
gitRun(b, repository, "init", "-b", "main")
|
||||
gitRun(b, repository, "config", "user.name", "ReleaseKit Benchmark")
|
||||
gitRun(b, repository, "config", "user.email", "benchmark@example.invalid")
|
||||
writeFile(b, repository, "docs/releases/v1.2.3.md", "# Sample App v1.2.3\n\nDelivery fixture.\n")
|
||||
gitRun(b, repository, "add", ".")
|
||||
gitRun(b, repository, "commit", "-m", "prepare delivery benchmark")
|
||||
gitRun(b, repository, "tag", "v1.2.3")
|
||||
|
||||
data := make([]byte, performanceDeliveryArtifactSize)
|
||||
for i := range data {
|
||||
data[i] = byte((i*17 + i/127 + 29) % 251)
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
digestText := hex.EncodeToString(digest[:])
|
||||
outputDir := filepath.Join(b.TempDir(), "artifacts")
|
||||
if err := os.MkdirAll(outputDir, 0o700); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
targets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "amd64"}}
|
||||
build := releasekit.BuildResult{
|
||||
RepositoryDir: repository, CandidateCommit: gitText(b, repository, "rev-parse", "HEAD"),
|
||||
SourceVerified: true, Version: "v1.2.3", MainPackage: "./cmd/app",
|
||||
BinaryName: "releasekit", Targets: targets, OutputDir: outputDir,
|
||||
}
|
||||
assets := make([]deliveryBenchmarkAsset, 0, len(targets)+1)
|
||||
for _, target := range targets {
|
||||
filename := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
|
||||
if target.OS == "windows" {
|
||||
filename += ".exe"
|
||||
}
|
||||
path := filepath.Join(outputDir, filename)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
build.Artifacts = append(build.Artifacts, releasekit.BuildArtifact{Path: path, Filename: filename, Target: target, SHA256: digestText})
|
||||
assets = append(assets, deliveryBenchmarkAsset{name: filename, data: data, digest: digestText})
|
||||
}
|
||||
manifest := performanceDeliveryManifest(build.Artifacts)
|
||||
manifestHash := sha256.Sum256(manifest)
|
||||
assets = append(assets, deliveryBenchmarkAsset{name: gitea.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])})
|
||||
return repository, build, assets
|
||||
}
|
||||
|
||||
func performanceDeliveryManifest(artifacts []releasekit.BuildArtifact) []byte {
|
||||
ordered := append([]releasekit.BuildArtifact(nil), artifacts...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
|
||||
var manifest strings.Builder
|
||||
for _, artifact := range ordered {
|
||||
fmt.Fprintf(&manifest, "%s %s\n", artifact.SHA256, artifact.Filename)
|
||||
}
|
||||
return []byte(manifest.String())
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) reset(populated bool) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
state.page = populated
|
||||
state.attached = make(map[string]deliveryBenchmarkIdentity)
|
||||
if populated {
|
||||
for _, asset := range state.assets {
|
||||
state.attached[asset.name] = deliveryBenchmarkIdentity{digest: asset.digest, size: int64(len(asset.data))}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) verifyUploads(b *testing.B) {
|
||||
b.Helper()
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if !state.page || len(state.attached) != len(state.assets) {
|
||||
b.Fatalf("fresh upload left release page %t and %d attached assets, want page and %d assets", state.page, len(state.attached), len(state.assets))
|
||||
}
|
||||
for _, asset := range state.assets {
|
||||
identity, ok := state.attached[asset.name]
|
||||
if !ok || identity.digest != asset.digest || identity.size != int64(len(asset.data)) {
|
||||
b.Fatalf("uploaded %q identity = %#v, present %t; want digest %s and size %d", asset.name, identity, ok, asset.digest, len(asset.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
state.mu.Lock()
|
||||
page := state.page
|
||||
state.mu.Unlock()
|
||||
if !page {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases":
|
||||
state.mu.Lock()
|
||||
state.page = true
|
||||
state.mu.Unlock()
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
state.writeAssetList(w)
|
||||
return
|
||||
}
|
||||
state.receiveUpload(w, r)
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
state.serveFile(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) writeRelease(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 10, "html_url": state.baseURL + "/releases/10", "tag_name": "v1.2.3",
|
||||
"target_commitish": state.commit, "name": "Fixture v1.2.3",
|
||||
"body": "# Sample App v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) writeAssetList(w http.ResponseWriter) {
|
||||
state.mu.Lock()
|
||||
assets := make([]map[string]any, 0, len(state.attached))
|
||||
for name, identity := range state.attached {
|
||||
assets = append(assets, map[string]any{
|
||||
"id": len(assets) + 1, "name": name, "size": identity.size,
|
||||
"browser_download_url": state.baseURL + "/files/" + name,
|
||||
})
|
||||
}
|
||||
state.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(assets)
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
http.Error(w, "invalid multipart request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var digest hash.Hash = sha256.New()
|
||||
var size int64
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if part.FormName() == "attachment" {
|
||||
size, err = io.Copy(digest, part)
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
http.Error(w, "cannot read attachment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = part.Close()
|
||||
}
|
||||
identity := deliveryBenchmarkIdentity{digest: hex.EncodeToString(digest.Sum(nil)), size: size}
|
||||
var expected *deliveryBenchmarkAsset
|
||||
for i := range state.assets {
|
||||
if state.assets[i].name == name {
|
||||
expected = &state.assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if expected == nil || identity.digest != expected.digest || identity.size != int64(len(expected.data)) {
|
||||
http.Error(w, "attachment digest or size mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.attached[name] = identity
|
||||
assetID := len(state.attached)
|
||||
state.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": assetID, "name": name, "size": size})
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
for _, asset := range state.assets {
|
||||
if asset.name == name {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(asset.data)))
|
||||
_, _ = io.Copy(w, bytes.NewReader(asset.data))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -797,7 +797,7 @@ func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func newPublishFixture(t *testing.T) publishFixture {
|
||||
func newPublishFixture(t testing.TB) publishFixture {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
work := filepath.Join(base, "work")
|
||||
@@ -870,7 +870,7 @@ func TestRaceBuildSelection(t *testing.T) {
|
||||
return publishFixture{work: work, bare: bare, commit: commit, unrelatedCommit: other}
|
||||
}
|
||||
|
||||
func gitRun(t *testing.T, dir string, args ...string) {
|
||||
func gitRun(t testing.TB, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
@@ -879,7 +879,7 @@ func gitRun(t *testing.T, dir string, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func gitText(t *testing.T, dir string, args ...string) string {
|
||||
func gitText(t testing.TB, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
@@ -901,7 +901,7 @@ func gitBytes(t *testing.T, dir string, args ...string) string {
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, root, name, contents string) {
|
||||
func writeFile(t testing.TB, root, name, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user