30 Commits

Author SHA1 Message Date
e43df4416e Remove completed roadmaps and repair the benchmark link 2026-10-09 03:34:31 +00:00
9b51447acd Align COPYRIGHT with the upstream library template 2026-10-09 03:32:52 +00:00
80ec9e3fb4 Adopt release policy 1.6 and preserve local release conventions 2026-10-09 03:29:37 +00:00
86b73db71f Raise the Go minimum to 1.26.0 2026-10-09 03:20:21 +00:00
52e3a389bb Add release notes and upgrade guidance for v0.3.0 2026-10-09 03:17:16 +00:00
cf9e451ecd Reconcile completed performance and remediation roadmaps 2026-10-09 03:09:21 +00:00
6376ef23f1 Measure output capture allocations for chunked writes 2026-10-09 03:08:51 +00:00
7d92c477c2 Attribute reader shutdown failures to checks that read content 2026-10-09 03:07:13 +00:00
6eec022bb0 Preserve validation cancellation during reader shutdown 2026-10-09 03:06:06 +00:00
15dd58f5ac Record performance implementation review findings 2026-10-09 02:15:56 +00:00
df064bd1d3 Record final performance implementation results 2026-10-09 01:55:56 +00:00
42569b4af2 Cache runtime version metadata fallback 2026-10-09 01:46:07 +00:00
50d3573257 Transfer completed Go output without copies 2026-10-09 01:44:33 +00:00
39196a525c Use one delivery asset verification pass 2026-10-09 01:40:43 +00:00
cfd8ba7cb2 Reuse parsed validation content by blob 2026-10-09 01:37:33 +00:00
182da397e3 Release validation source bytes after checks 2026-10-09 01:33:21 +00:00
a3e993d95a Scope candidate blob readers to validation 2026-10-09 01:30:30 +00:00
33e10635b6 Add local batched Git blob reader 2026-10-09 01:23:00 +00:00
618a7c9dd3 Add piped child process support 2026-10-09 01:17:58 +00:00
41e58ef894 Measure delivery output and version costs 2026-10-09 01:11:07 +00:00
29e1bab228 Record validation performance baseline 2026-10-09 01:02:05 +00:00
ca571aca4f Plan the computational efficiency implementation 2026-10-09 00:42:08 +00:00
ff90510c38 Record the computational efficiency roadmap 2026-10-09 00:10:13 +00:00
db9e24f7dc Document versioned Go tool adoption for CLI consumers 2026-10-08 16:42:18 +00:00
4bb480f990 Separate the Go minimum from the release build toolchain 2026-10-08 16:29:48 +00:00
d51a0ada5e Record successful PromptKit cache recovery 2026-10-08 16:10:23 +00:00
f2124b3b7b Isolate synthetic module downloads in workflow tests 2026-10-08 14:30:08 +00:00
ae0e4a0263 Document checksum recovery and synthetic module cache isolation 2026-10-08 14:25:46 +00:00
c7942210d5 Record PromptKit checksum investigation findings 2026-10-08 14:23:24 +00:00
c339872bfe Report PromptKit's v0.2.0 checksum mismatch 2026-10-08 14:15:00 +00:00
55 changed files with 2973 additions and 205 deletions

View File

@@ -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.

View File

@@ -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`,

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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

View 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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` |

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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)

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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")

View 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
View 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
}

View 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")
}

View 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)
}
})
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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 {
@@ -209,6 +230,7 @@ type combinedWriter struct {
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()
}

View File

@@ -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{

View 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))
}
}
})
}
}

View File

@@ -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() {

View File

@@ -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)
}

View 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")
}
}
})
}

View File

@@ -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 {

View File

@@ -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)
}
})
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"))

View File

@@ -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,6 +29,16 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
Cause: fmt.Errorf("inspect 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{
@@ -32,18 +47,31 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
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 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
}

View File

@@ -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")

View File

@@ -2,6 +2,7 @@ package validation
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
@@ -31,18 +32,28 @@ type markdownDocument struct {
type markdownCache struct {
tree *CandidateTree
documents map[string]*markdownDocument
byPath map[string]*markdownDocument
byObjectID map[string]*markdownDocument
}
func newMarkdownCache(tree *CandidateTree) *markdownCache {
return &markdownCache{
tree: tree,
documents: make(map[string]*markdownDocument),
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:

View File

@@ -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
}

View File

@@ -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)
}
}

View 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())
}

View File

@@ -13,26 +13,32 @@ import (
// CandidateTree is an inventory of one captured commit with blob contents
// loaded lazily by object ID.
type CandidateTree struct {
context context.Context
repositoryDir string
entries []git.TreeEntry
byPath map[string]int
directories map[string]struct{}
blobs map[string][]byte
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{
context: ctx,
repositoryDir: repositoryDir,
entries: entries,
byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}),
blobs: make(map[string][]byte),
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
}
contents, err := git.ReadBlob(ctx, tree.repositoryDir, entry.ObjectID)
if entry.Mode == "120000" {
target, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return nil, fmt.Errorf("read committed path %q: %w", path, err)
return nil, err
}
return []byte(target), nil
}
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 "", 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)

View 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")
}

View File

@@ -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

View File

@@ -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"})
}
}

View 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
}

View 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)
}

View File

@@ -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 {