Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d033c5ca1 | |||
| f076da99b6 | |||
| 3f2a1a1052 | |||
| 2d5a8110c6 | |||
| d42727b038 | |||
| 059a79525a | |||
| 6176971307 | |||
| 8f6e72b0b8 | |||
| b96262eb0c | |||
| 5a0bc6f78b | |||
| f27abcb53e | |||
| 6c7b77970b | |||
| 80d88a0352 | |||
| c0a3f55f12 | |||
| 7216fb5f98 | |||
| 6b0b8ce6de | |||
| 8112a001d2 | |||
| 8552f92ddb | |||
| c09c7a2ac7 | |||
| 54cc6baf95 | |||
| 7eebbbbfc3 | |||
| c8eb64adec | |||
| 9333573cd8 | |||
| d44ff1260d | |||
| f059fef320 | |||
| 7555d3e6c9 | |||
| 750632f80e | |||
| 90559fe01b | |||
| b49c1f379e | |||
| cd828dc06d | |||
| c9879cffa2 | |||
| c0cc50b127 | |||
| f837871b77 | |||
| d06b62efe2 | |||
| 926b74bc0b | |||
| fdce017aff | |||
| 8350cbf9c9 | |||
| 02896b6e12 | |||
| 3a99536f9c | |||
| fa34cbaff3 | |||
| 3e516d0ac1 | |||
| f99fb59cd4 | |||
| dbfae9ea81 | |||
| f33023f8e0 | |||
| 5847f40ab5 | |||
| f656a2d2e9 | |||
| 578503c1ca | |||
| 39dca97f9b | |||
| 283f62813f | |||
| f89bdf7edf | |||
| 86c1da2cf5 | |||
| e43df4416e | |||
| 9b51447acd | |||
| 80ec9e3fb4 | |||
| 86b73db71f | |||
| 52e3a389bb | |||
| cf9e451ecd | |||
| 6376ef23f1 | |||
| 7d92c477c2 | |||
| 6eec022bb0 | |||
| 15dd58f5ac | |||
| df064bd1d3 | |||
| 42569b4af2 | |||
| 50d3573257 | |||
| 39196a525c | |||
| cfd8ba7cb2 | |||
| 182da397e3 | |||
| a3e993d95a | |||
| 33e10635b6 | |||
| 618a7c9dd3 | |||
| 41e58ef894 | |||
| 29e1bab228 | |||
| ca571aca4f | |||
| ff90510c38 | |||
| db9e24f7dc | |||
| 4bb480f990 | |||
| d51a0ada5e | |||
| f2124b3b7b | |||
| ae0e4a0263 | |||
| c7942210d5 | |||
| c339872bfe | |||
| e030794969 | |||
| 10964bebd4 | |||
| e84d28d249 | |||
| 8314e0e4d8 | |||
| b6a490cefc | |||
| 2e0d302178 | |||
| 95599e96f6 | |||
| 1eb1cc6491 | |||
| edd670e62f | |||
| c8fa8ef85f | |||
| eee979db7b | |||
| b80d4ee071 | |||
| 40068af1bf | |||
| 6397d41517 | |||
| b3b959ce77 | |||
| f80970a9d4 | |||
| eed95896f4 | |||
| 45dd827dda | |||
| c5f925c949 | |||
| 368043984e | |||
| cabc56b134 | |||
| 1c41f7a0d1 | |||
| 30fe4fe206 | |||
| 89981abbe5 | |||
| 9086306498 | |||
| 3974a4686a | |||
| 1dfa774164 | |||
| 9d2ca66a79 | |||
| 02050016c1 | |||
| ff83af1e34 | |||
| 56e652aae3 | |||
| 1498ec7498 | |||
| f53354be5f | |||
| 89c5b2c1fd | |||
| 1ebd45c975 | |||
| 8bac90a9a8 | |||
| 5848307948 | |||
| 1cad13649e | |||
| 136a97fb6c | |||
| 1de7555b3e | |||
| 0c7129ab75 | |||
| 8c742cd11d | |||
| 7afc0de092 | |||
| 2a246990fe | |||
| f9f0349eca | |||
| f4a32dae73 | |||
| a4b1b6d5d2 | |||
| f55f6331cb | |||
| 8ff8efa4a2 | |||
| 6de0428675 | |||
| 9e480f0b4f | |||
| 60175d6690 |
@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|||||||
GNU Lesser General Public License for more details.
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
Third-party code and assets are subject to their respective license terms.
|
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.
|
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -6,12 +6,18 @@ local candidate check, source-tag publication API, selected-target build API,
|
|||||||
tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
||||||
`deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only
|
`deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only
|
||||||
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||||
delivery are documented. ReleaseKit does not declare binary deliverables. The
|
delivery are documented. ReleaseKit does not declare binary deliverables.
|
||||||
[product roadmap](docs/roadmap/releasekit.md) records remaining adoption and
|
|
||||||
deployment work.
|
|
||||||
|
|
||||||
See the [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline
|
Release operations can select one Go module within a repository. The repository
|
||||||
and installation commands.
|
root remains the default; a selected module's declared import path and physical
|
||||||
|
directory determine its complete Git tag and module-local release note. See the
|
||||||
|
[candidate check](docs/consumers/check.md),
|
||||||
|
[publication](docs/consumers/publish.md), and
|
||||||
|
[multi-module example](examples/multi-module/).
|
||||||
|
|
||||||
|
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:
|
Run the offline Go test suite from the repository root with:
|
||||||
|
|
||||||
@@ -26,7 +32,8 @@ go run ./cmd/releasekit version
|
|||||||
```
|
```
|
||||||
|
|
||||||
The local candidate check validates a committed release note, runs the standard
|
The local candidate check validates a committed release note, runs the standard
|
||||||
offline Go test suite, and then runs any configured additional checks:
|
offline Go suite with any selected shared validation checks, and then runs
|
||||||
|
configured product checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/releasekit check v1.2.3
|
go run ./cmd/releasekit check v1.2.3
|
||||||
@@ -43,8 +50,8 @@ failure recovery.
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Product roadmap](docs/roadmap/releasekit.md): consumer adoption goals and
|
- [Consumer adoption](docs/consumers/adoption.md): versioned CLI tool and Go API
|
||||||
remaining release and deployment work.
|
dependencies, upgrades, and CI adoption.
|
||||||
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
||||||
policy reading.
|
policy reading.
|
||||||
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`,
|
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`,
|
||||||
|
|||||||
78
build.go
78
build.go
@@ -2,9 +2,14 @@ package releasekit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target identifies one explicit Go operating-system and architecture pair.
|
// Target identifies one explicit Go operating-system and architecture pair.
|
||||||
@@ -22,10 +27,13 @@ type BuildSpec struct {
|
|||||||
|
|
||||||
// BuildRequest contains the source and output settings for Build.
|
// BuildRequest contains the source and output settings for Build.
|
||||||
type BuildRequest struct {
|
type BuildRequest struct {
|
||||||
RepositoryDir string // source repository whose current HEAD is recorded.
|
RepositoryDir string // directory within the source Git checkout whose HEAD is recorded.
|
||||||
|
// ModuleDir selects a repository-relative module directory; empty selects
|
||||||
|
// the repository root.
|
||||||
|
ModuleDir string
|
||||||
Version string // stable vMAJOR.MINOR.PATCH value injected at link time.
|
Version string // stable vMAJOR.MINOR.PATCH value injected at link time.
|
||||||
Spec BuildSpec // application package, artifact name, and targets.
|
Spec BuildSpec // application package, artifact name, and targets.
|
||||||
OutputDir string // output location; empty selects <repository>/dist.
|
OutputDir string // output location; relative paths use ModuleDir and empty selects <module>/dist.
|
||||||
Output io.Writer // optional build progress destination.
|
Output io.Writer // optional build progress destination.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +48,9 @@ type BuildArtifact struct {
|
|||||||
// BuildResult describes the source and artifacts reached by Build.
|
// BuildResult describes the source and artifacts reached by Build.
|
||||||
type BuildResult struct {
|
type BuildResult struct {
|
||||||
RepositoryDir string // absolute source repository path.
|
RepositoryDir string // absolute source repository path.
|
||||||
|
ModuleDir string // absolute selected module directory.
|
||||||
|
ModulePath string // selected module's declared Go path.
|
||||||
|
Tag string // complete tag derived for this version.
|
||||||
CandidateCommit string // Git HEAD observed when the build started.
|
CandidateCommit string // Git HEAD observed when the build started.
|
||||||
// SourceVerified reports clean source and the same HEAD observed before and
|
// SourceVerified reports clean source and the same HEAD observed before and
|
||||||
// after a completed build. It is required for binary delivery, not an attestation.
|
// after a completed build. It is required for binary delivery, not an attestation.
|
||||||
@@ -53,33 +64,60 @@ type BuildResult struct {
|
|||||||
Compiler string // output of the selected local Go toolchain's version command.
|
Compiler string // output of the selected local Go toolchain's version command.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build compiles the selected targets with Version injected through buildinfo.
|
// Build resolves the selected module's working-tree identity, then compiles the
|
||||||
// It uses the available compatible local Go toolchain, applies ReleaseKit's
|
// selected targets with Version injected through buildinfo. It uses the
|
||||||
// readonly offline build settings, preserves effective cache and tool settings,
|
// available compatible local Go toolchain, applies ReleaseKit's readonly
|
||||||
// and verifies the version command on the Go host before succeeding. An empty
|
// offline build settings, preserves effective cache and tool settings, and
|
||||||
// target set records source identity without running the Go toolchain. Errors
|
// verifies the version command on the Go host before succeeding. Relative
|
||||||
// return partial build facts in the result.
|
// output paths use ModuleDir; an empty path selects its dist directory. An
|
||||||
|
// empty target set records source identity without running the Go toolchain.
|
||||||
|
// Errors return partial build facts in the result.
|
||||||
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
|
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
|
||||||
result, err := internalbuild.Build(ctx, internalbuild.Request{
|
spec := *internalBuildSpec(&request.Spec)
|
||||||
RepositoryDir: request.RepositoryDir,
|
result := internalbuild.Result{Version: request.Version}
|
||||||
Version: request.Version,
|
failure := func(err error) (BuildResult, error) {
|
||||||
Spec: *internalBuildSpec(&request.Spec),
|
result.Settings = artifact.NormalizeSettings(spec.MainPackage, spec.BinaryName, spec.Targets)
|
||||||
|
return publicBuildResult(result), err
|
||||||
|
}
|
||||||
|
if !versioncontract.ValidStableVersion(request.Version) {
|
||||||
|
return failure(fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version))
|
||||||
|
}
|
||||||
|
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return failure(err)
|
||||||
|
}
|
||||||
|
result.RepositoryDir = repositoryDir
|
||||||
|
identity, err := releaseidentity.FromWorkingTree(ctx, repositoryDir, request.ModuleDir, request.Version)
|
||||||
|
result.ModuleDir, result.ModulePath, result.Tag = identity.ModuleDir, identity.ModulePath, identity.Tag
|
||||||
|
if err != nil {
|
||||||
|
return failure(err)
|
||||||
|
}
|
||||||
|
result, err = internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||||
|
Identity: identity,
|
||||||
|
Spec: spec,
|
||||||
OutputDir: request.OutputDir,
|
OutputDir: request.OutputDir,
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
})
|
})
|
||||||
|
return publicBuildResult(result), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicBuildResult(result internalbuild.Result) BuildResult {
|
||||||
public := BuildResult{
|
public := BuildResult{
|
||||||
RepositoryDir: result.RepositoryDir,
|
RepositoryDir: result.RepositoryDir,
|
||||||
|
ModuleDir: result.ModuleDir,
|
||||||
|
ModulePath: result.ModulePath,
|
||||||
|
Tag: result.Tag,
|
||||||
CandidateCommit: result.CandidateCommit,
|
CandidateCommit: result.CandidateCommit,
|
||||||
SourceVerified: result.SourceVerified,
|
SourceVerified: result.SourceVerified,
|
||||||
Version: result.Version,
|
Version: result.Version,
|
||||||
MainPackage: result.MainPackage,
|
MainPackage: result.Settings.MainPackage,
|
||||||
BinaryName: result.BinaryName,
|
BinaryName: result.Settings.BinaryName,
|
||||||
OutputDir: result.OutputDir,
|
OutputDir: result.OutputDir,
|
||||||
Compiler: result.Compiler,
|
Compiler: result.Compiler,
|
||||||
Targets: make([]Target, 0, len(result.Targets)),
|
Targets: make([]Target, 0, len(result.Settings.Targets)),
|
||||||
Artifacts: make([]BuildArtifact, 0, len(result.Artifacts)),
|
Artifacts: make([]BuildArtifact, 0, len(result.Artifacts)),
|
||||||
}
|
}
|
||||||
for _, target := range result.Targets {
|
for _, target := range result.Settings.Targets {
|
||||||
public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch})
|
public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch})
|
||||||
}
|
}
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
@@ -90,7 +128,7 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
|
|||||||
SHA256: artifact.SHA256,
|
SHA256: artifact.SHA256,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return public, err
|
return public
|
||||||
}
|
}
|
||||||
|
|
||||||
func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
||||||
@@ -105,8 +143,10 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
||||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||||
for _, target := range result.Targets {
|
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
||||||
|
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||||
|
for _, target := range settings.Targets {
|
||||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
||||||
}
|
}
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
|
|||||||
223
build_test.go
223
build_test.go
@@ -18,6 +18,29 @@ import (
|
|||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||||
|
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
||||||
|
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: filepath.Join(t.TempDir(), "absent"), Version: "bad-version", Spec: spec,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, releasekit.ErrInvalidVersion) || invalid.Version != "bad-version" || invalid.MainPackage != spec.MainPackage || invalid.BinaryName != spec.BinaryName || len(invalid.Targets) != 1 {
|
||||||
|
t.Fatalf("invalid version result = %#v, error = %v", invalid, err)
|
||||||
|
}
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
writeFile(t, repo.work, "nested/go.mod", "go 1.26.0\n")
|
||||||
|
failed, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repo.work, ModuleDir: "nested", Version: "v1.2.3", Spec: spec,
|
||||||
|
})
|
||||||
|
if err == nil || failed.RepositoryDir != repo.work || failed.ModuleDir != filepath.Join(repo.work, "nested") || failed.Version != "v1.2.3" || failed.MainPackage != spec.MainPackage || failed.BinaryName != spec.BinaryName || len(failed.Targets) != 1 || failed.SourceVerified {
|
||||||
|
t.Fatalf("declaration failure = %#v, error = %v", failed, err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := releasekit.Build(ctx, releasekit.BuildRequest{RepositoryDir: repo.work, Version: "v1.2.3", Spec: spec}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled build error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) {
|
func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) {
|
||||||
repository := newBuildConsumer(t, `package main
|
repository := newBuildConsumer(t, `package main
|
||||||
|
|
||||||
@@ -115,7 +138,7 @@ func main() {
|
|||||||
t.Setenv("GOARCH", "arm64")
|
t.Setenv("GOARCH", "arm64")
|
||||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
RepositoryDir: repository,
|
RepositoryDir: repository,
|
||||||
Version: "v2.3.4",
|
Version: "v1.2.3",
|
||||||
Spec: releasekit.BuildSpec{
|
Spec: releasekit.BuildSpec{
|
||||||
MainPackage: "./cmd/app",
|
MainPackage: "./cmd/app",
|
||||||
BinaryName: "fixture",
|
BinaryName: "fixture",
|
||||||
@@ -126,7 +149,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
||||||
}
|
}
|
||||||
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v2.3.4-windows-arm64.exe" {
|
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v1.2.3-windows-arm64.exe" {
|
||||||
t.Fatalf("build artifacts = %#v", result.Artifacts)
|
t.Fatalf("build artifacts = %#v", result.Artifacts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +189,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
|
|||||||
|
|
||||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
RepositoryDir: repository,
|
RepositoryDir: repository,
|
||||||
Version: "v2.3.4",
|
Version: "v1.2.3",
|
||||||
Spec: releasekit.BuildSpec{
|
Spec: releasekit.BuildSpec{
|
||||||
MainPackage: "./cmd/app",
|
MainPackage: "./cmd/app",
|
||||||
BinaryName: "fixture",
|
BinaryName: "fixture",
|
||||||
@@ -242,6 +265,145 @@ func main() {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
|
||||||
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||||
|
moduleDir := filepath.Join(repository, "integrations", "nested")
|
||||||
|
writeNestedBuildConsumer(t, repository, "integrations/nested", "example.com/nestedbuild", `package main
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||||
|
)
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) == 2 && os.Args[1] == "version" {
|
||||||
|
if logPath := os.Getenv("RELEASEKIT_SMOKE_LOG"); logPath != "" {
|
||||||
|
if executable, err := os.Executable(); err == nil {
|
||||||
|
_ = os.WriteFile(logPath, []byte(executable), 0o600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("nested-app %s\n", buildinfo.Version())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
outputDirectory := "artifacts"
|
||||||
|
crossTarget := releasekit.Target{OS: "darwin", Arch: "arm64"}
|
||||||
|
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||||
|
crossTarget = releasekit.Target{OS: "linux", Arch: "amd64"}
|
||||||
|
}
|
||||||
|
nativeTarget := releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: "integrations/nested",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Spec: releasekit.BuildSpec{
|
||||||
|
MainPackage: "./cmd/app",
|
||||||
|
BinaryName: "nested-app",
|
||||||
|
Targets: []releasekit.Target{nativeTarget, crossTarget},
|
||||||
|
},
|
||||||
|
OutputDir: outputDirectory,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v; result = %#v", err, result)
|
||||||
|
}
|
||||||
|
if result.ModuleDir != moduleDir || result.ModulePath != "example.com/nestedbuild" || result.Tag != "integrations/nested/v1.2.3" {
|
||||||
|
t.Fatalf("nested build identity = %#v", result)
|
||||||
|
}
|
||||||
|
wantOutput := filepath.Join(moduleDir, outputDirectory)
|
||||||
|
if result.OutputDir != wantOutput || len(result.Artifacts) != 2 {
|
||||||
|
t.Fatalf("nested build output = %#v, want directory %q and two artifacts", result, wantOutput)
|
||||||
|
}
|
||||||
|
var native string
|
||||||
|
for _, artifact := range result.Artifacts {
|
||||||
|
if filepath.Dir(artifact.Path) != wantOutput || !strings.Contains(artifact.Filename, "nested-app-v1.2.3-") {
|
||||||
|
t.Fatalf("nested artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
if artifact.Target == nativeTarget {
|
||||||
|
native = artifact.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if native == "" {
|
||||||
|
t.Fatalf("native artifact missing from %#v", result.Artifacts)
|
||||||
|
}
|
||||||
|
if output, err := exec.Command(native, "version").CombinedOutput(); err != nil || string(output) != "nested-app v1.2.3\n" {
|
||||||
|
t.Fatalf("nested native version = %q, error = %v", output, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localTemporary := filepath.Join(repository, "source-temporary")
|
||||||
|
if err := os.Mkdir(localTemporary, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
crossOutput := filepath.Join(t.TempDir(), "cross-artifacts")
|
||||||
|
smokeLog := filepath.Join(t.TempDir(), "smoke-executable")
|
||||||
|
t.Setenv("RELEASEKIT_SMOKE_LOG", smokeLog)
|
||||||
|
t.Setenv("TMPDIR", localTemporary)
|
||||||
|
smokeResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: "integrations/nested",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Spec: releasekit.BuildSpec{
|
||||||
|
MainPackage: "./cmd/app",
|
||||||
|
BinaryName: "nested-app",
|
||||||
|
Targets: []releasekit.Target{crossTarget},
|
||||||
|
},
|
||||||
|
OutputDir: crossOutput,
|
||||||
|
})
|
||||||
|
if err != nil || len(smokeResult.Artifacts) != 1 {
|
||||||
|
t.Fatalf("nested cross build with source-local TMPDIR = %#v, %v", smokeResult, err)
|
||||||
|
}
|
||||||
|
smokePathBytes, err := os.ReadFile(smokeLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read native smoke executable path: %v", err)
|
||||||
|
}
|
||||||
|
smokePath := string(smokePathBytes)
|
||||||
|
relativeSmokePath, err := filepath.Rel(repository, smokePath)
|
||||||
|
if err != nil || relativeSmokePath == "." || (!filepath.IsAbs(relativeSmokePath) && relativeSmokePath != ".." && !strings.HasPrefix(relativeSmokePath, ".."+string(filepath.Separator))) {
|
||||||
|
t.Fatalf("native smoke output path %q is inside repository %q (relative %q, error %v)", smokePath, repository, relativeSmokePath, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(smokePath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("native smoke output %q was not removed: %v", smokePath, err)
|
||||||
|
}
|
||||||
|
remaining, err := os.ReadDir(localTemporary)
|
||||||
|
if err != nil || len(remaining) != 0 {
|
||||||
|
t.Fatalf("native smoke output remained in source-local TMPDIR: %v %v", remaining, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: "integrations/nested",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
})
|
||||||
|
if err != nil || emptyResult.Compiler != "" || emptyResult.OutputDir != filepath.Join(moduleDir, "dist") {
|
||||||
|
t.Fatalf("nested empty build = %#v, %v", emptyResult, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(emptyResult.OutputDir); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("empty nested build created dist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
|
||||||
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||||
|
declarationPath := filepath.Join(repository, "go.mod")
|
||||||
|
contents, err := os.ReadFile(declarationPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updated := strings.Replace(string(contents), "module example.com/buildfixture", "module example.com/changedbuild", 1)
|
||||||
|
if updated == string(contents) {
|
||||||
|
t.Fatal("fixture module declaration was not replaced")
|
||||||
|
}
|
||||||
|
writeBuildFixture(t, repository, "go.mod", updated)
|
||||||
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Version: "v1.2.3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.ModulePath != "example.com/changedbuild" || result.Tag != "v1.2.3" {
|
||||||
|
t.Fatalf("dirty declaration identity = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
||||||
const source = `package main
|
const source = `package main
|
||||||
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
||||||
@@ -432,6 +594,24 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
|
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
|
||||||
repository := newPublishFixture(t)
|
repository := newPublishFixture(t)
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
goShimDir := t.TempDir()
|
||||||
|
goLog := filepath.Join(goShimDir, "go-commands")
|
||||||
|
goShim := filepath.Join(goShimDir, "go")
|
||||||
|
shimSource := "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$RELEASEKIT_REAL_GO_LOG\"\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n"
|
||||||
|
if err := os.WriteFile(goShim, []byte(shimSource), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||||
|
t.Setenv("RELEASEKIT_REAL_GO_LOG", goLog)
|
||||||
|
t.Setenv("GOENV", "off")
|
||||||
|
t.Setenv("PATH", goShimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n")
|
||||||
|
gitRun(t, repository.work, "add", "guide.md")
|
||||||
|
gitRun(t, repository.work, "commit", "-m", "add Markdown guide")
|
||||||
prepareCandidateBuild(t, repository.work, `package main
|
prepareCandidateBuild(t, repository.work, `package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -445,6 +625,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
|
|||||||
RepositoryDir: repository.work,
|
RepositoryDir: repository.work,
|
||||||
Product: "Sample App",
|
Product: "Sample App",
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
|
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
|
||||||
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
||||||
validations++
|
validations++
|
||||||
return nil
|
return nil
|
||||||
@@ -461,6 +642,26 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
|
|||||||
if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") {
|
if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") {
|
||||||
t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations)
|
t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations)
|
||||||
}
|
}
|
||||||
|
goCommands, err := os.ReadFile(goLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var discoveries, suites, builds, versions int
|
||||||
|
for _, command := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(command, "env "):
|
||||||
|
discoveries++
|
||||||
|
case strings.HasPrefix(command, "test "):
|
||||||
|
suites++
|
||||||
|
case strings.HasPrefix(command, "build "):
|
||||||
|
builds++
|
||||||
|
case command == "version":
|
||||||
|
versions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if discoveries != 2 || suites != 1 || builds != 1 || versions != 1 {
|
||||||
|
t.Fatalf("Go commands = %q; discovery=%d suite=%d build=%d version=%d, want 2, 1, 1, 1", goCommands, discoveries, suites, builds, versions)
|
||||||
|
}
|
||||||
if got := gitText(t, repository.work, "status", "--porcelain"); got != "" {
|
if got := gitText(t, repository.work, "status", "--porcelain"); got != "" {
|
||||||
t.Fatalf("candidate build dirtied worktree: %s", got)
|
t.Fatalf("candidate build dirtied worktree: %s", got)
|
||||||
}
|
}
|
||||||
@@ -512,7 +713,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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, "go.mod", module)
|
||||||
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
|
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")
|
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
||||||
@@ -524,13 +725,25 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
|||||||
return repository
|
return repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, mainSource string) string {
|
||||||
|
t.Helper()
|
||||||
|
root, err := filepath.Abs(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", modulePath, filepath.ToSlash(root))
|
||||||
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
|
||||||
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
|
||||||
|
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||||
|
}
|
||||||
|
|
||||||
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root, err := filepath.Abs(".")
|
root, err := filepath.Abs(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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, "go.mod", module)
|
||||||
writeFile(t, repository, ".gitignore", "dist/\n")
|
writeFile(t, repository, ".gitignore", "dist/\n")
|
||||||
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestVersionInConsumerExecutable(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
module := fmt.Sprintf(`module example.com/versionfixture
|
module := fmt.Sprintf(`module example.com/versionfixture
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0
|
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0
|
||||||
|
|
||||||
|
|||||||
233
check.go
233
check.go
@@ -6,66 +6,241 @@ import (
|
|||||||
|
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
|
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
|
||||||
var ErrInvalidVersion = candidate.ErrInvalidVersion
|
var ErrInvalidVersion = candidate.ErrInvalidVersion
|
||||||
|
|
||||||
|
// ErrUnexpectedPushURL reports that the effective publication push URL does
|
||||||
|
// not exactly match ReleaseRequest.ExpectedPushURL.
|
||||||
|
var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL
|
||||||
|
|
||||||
// Candidate identifies one committed local source revision selected for
|
// Candidate identifies one committed local source revision selected for
|
||||||
// validation.
|
// validation. ModuleDir is the absolute selected module directory, ModulePath
|
||||||
|
// is its declared Go import path, and Tag is the complete derived Git tag.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
|
ModulePath string
|
||||||
|
Tag string
|
||||||
Commit string
|
Commit string
|
||||||
Version string
|
Version string // selected stable semantic version.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseRequest contains the inputs used to validate and optionally build one
|
// ReleaseRequest contains the inputs used to validate and optionally build one
|
||||||
// local candidate. Remote is retained for publication operations; Check makes
|
// local candidate. Remote is retained for publication operations; Check makes
|
||||||
// no remote availability claim.
|
// no remote availability claim.
|
||||||
type ReleaseRequest struct {
|
type ReleaseRequest struct {
|
||||||
|
// RepositoryDir names a directory within the source Git checkout. Operations
|
||||||
|
// resolve its root before applying repository-relative selections.
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
// ModuleDir selects a repository-relative module directory; empty selects
|
||||||
|
// the repository root.
|
||||||
|
ModuleDir string
|
||||||
Product string
|
Product string
|
||||||
Version string
|
Version string // stable semantic version, independent of the derived Git tag.
|
||||||
Remote string
|
Remote string
|
||||||
// AdditionalChecks optionally runs product checks after the standard suite.
|
// ExpectedPushURL optionally requires Publish's resolved push URL to match
|
||||||
|
// this string exactly before validation or tag creation. Check ignores this
|
||||||
|
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
||||||
|
ExpectedPushURL string
|
||||||
|
// Validation selects optional checks run against the captured candidate.
|
||||||
|
Validation ValidationSpec
|
||||||
|
// AdditionalChecks optionally runs product checks after shared validation.
|
||||||
AdditionalChecks func(context.Context, Candidate) error
|
AdditionalChecks func(context.Context, Candidate) error
|
||||||
Build *BuildSpec // optional selected-target build after validation.
|
Build *BuildSpec // optional selected-target build after validation.
|
||||||
Output io.Writer
|
Output io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidationSpec selects optional shared validation checks. Its zero value
|
||||||
|
// preserves the current validation behavior. File checks inspect the selected
|
||||||
|
// module and shared-file inputs. Independent nested modules form automatic
|
||||||
|
// input boundaries; vendored files remain included. Referenced destinations
|
||||||
|
// remain available for targeted inspection without expanding the input set.
|
||||||
|
type ValidationSpec struct {
|
||||||
|
// SharedFiles adds repository-relative committed files to optional file
|
||||||
|
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
||||||
|
// docs/oss/attribution.md defaults; an empty non-nil slice selects none.
|
||||||
|
// Explicit files replace the defaults and must exist even when file checks
|
||||||
|
// are off.
|
||||||
|
SharedFiles []string
|
||||||
|
Go GoValidationSpec
|
||||||
|
// GoFormat checks the formatting of committed regular Go source files. It
|
||||||
|
// does not modify repository contents and is disabled when false.
|
||||||
|
GoFormat bool
|
||||||
|
// Markdown selects committed Markdown destination and fragment checking.
|
||||||
|
// Nil disables the check; an empty object enables it with the default
|
||||||
|
// behavior, reporting outside-checkout destinations as out of scope.
|
||||||
|
Markdown *MarkdownValidationSpec
|
||||||
|
// ModuleDistribution selects checks for files that affect module-source
|
||||||
|
// distribution. Its zero value disables those checks.
|
||||||
|
ModuleDistribution ModuleDistributionValidationSpec
|
||||||
|
// FileSize selects a committed-file size check. Nil disables it; an empty
|
||||||
|
// object selects the 1,000,000-byte default.
|
||||||
|
FileSize *FileSizeValidationSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkdownValidationSpec selects behavior for committed Markdown links.
|
||||||
|
type MarkdownValidationSpec struct {
|
||||||
|
// EnforceCheckoutBoundary fails when a local destination resolves outside
|
||||||
|
// the candidate checkout. Submodule contents remain out of scope.
|
||||||
|
EnforceCheckoutBoundary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSizeValidationSpec selects a maximum size for each committed blob.
|
||||||
|
type FileSizeValidationSpec struct {
|
||||||
|
// LimitBytes is the maximum decimal byte count. Nil selects 1,000,000;
|
||||||
|
// zero is a zero-byte limit, and negative values are invalid.
|
||||||
|
LimitBytes *int64
|
||||||
|
// Exclusions are literal module-relative file or directory paths.
|
||||||
|
// A directory exclusion includes committed descendants.
|
||||||
|
Exclusions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDistributionValidationSpec selects independent module-source
|
||||||
|
// distribution checks.
|
||||||
|
type ModuleDistributionValidationSpec struct {
|
||||||
|
// WorkspaceFiles rejects committed go.work and go.work.sum blobs, including
|
||||||
|
// nested paths and symlink entries. It does not select Go's test workspace.
|
||||||
|
WorkspaceFiles bool
|
||||||
|
// LocalReplacements rejects filesystem replacements in committed go.mod
|
||||||
|
// declarations in the selected inputs, including vendor and explicit shared
|
||||||
|
// files. It does not select Go's workspace.
|
||||||
|
LocalReplacements bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoValidationSpec selects checks performed by the Go toolchain.
|
||||||
|
type GoValidationSpec struct {
|
||||||
|
// Race runs the one standard Go test suite with the race detector enabled.
|
||||||
|
Race bool
|
||||||
|
// FullVet runs go vet after the suite instead of the test-time vet pass.
|
||||||
|
// Failures use the go-vet validation check identity.
|
||||||
|
FullVet bool
|
||||||
|
// CompileTargets checks that every package builds for each complete target
|
||||||
|
// pair after testing and vet. Compilation uses CGO_ENABLED=0 and does not
|
||||||
|
// produce artifacts or execute target binaries. Failures use the
|
||||||
|
// package-compile validation check identity.
|
||||||
|
CompileTargets []Target
|
||||||
|
}
|
||||||
|
|
||||||
// Check validates one local, committed release candidate. It requires a clean
|
// Check validates one local, committed release candidate. It requires a clean
|
||||||
// worktree and index on main, a committed note matching Product and Version,
|
// worktree and index on main, a selected module declaration and module-local
|
||||||
// and an unused local tag. It always runs the offline Go test suite with the
|
// committed note matching Product and Version, and an unused local tag. An
|
||||||
// available compatible local Go toolchain and Go host target, then calls
|
// empty ModuleDir selects the repository root. The module declaration
|
||||||
// AdditionalChecks when supplied. If Build selects targets, Check builds them
|
// determines the full Git tag for the semantic Version. It always runs the
|
||||||
// after validation in temporary storage outside the worktree, then rechecks
|
// offline Go test suite with the available compatible local Go toolchain and
|
||||||
|
// Go host target, then calls
|
||||||
|
// AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
|
||||||
|
// the race detector, Validation.Go.FullVet runs go vet after the suite instead
|
||||||
|
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
||||||
|
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
||||||
|
// compilation produces no artifacts and does not execute target binaries.
|
||||||
|
// Validation.GoFormat checks regular committed Go source after those selected
|
||||||
|
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
||||||
|
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
||||||
|
// files after formatting. Its zero value disables that distribution check and
|
||||||
|
// does not change Go's workspace environment.
|
||||||
|
// Validation.ModuleDistribution.LocalReplacements parses committed go.mod
|
||||||
|
// declarations in the selected inputs and rejects filesystem replacements
|
||||||
|
// after workspace-file inspection. Its zero value disables that check.
|
||||||
|
// Validation.FileSize checks each selected committed blob after module
|
||||||
|
// distribution checks. Its nil value disables size checks; an empty object
|
||||||
|
// enables the default limit.
|
||||||
|
// Validation.Markdown checks committed local Markdown destinations and
|
||||||
|
// supported fragments after file-size inspection and before AdditionalChecks.
|
||||||
|
// Nil disables the check; an empty object enables it. Outside-checkout
|
||||||
|
// destinations are reported as out of scope unless the selected specification
|
||||||
|
// enforces the checkout boundary. Submodule contents remain out of scope.
|
||||||
|
// The zero values of all selections preserve the ordinary suite. If
|
||||||
|
// Build selects targets, Check builds them after validation in temporary
|
||||||
|
// storage outside the worktree, then rechecks
|
||||||
// branch, HEAD, worktree, index, and local tag state. Success establishes local
|
// branch, HEAD, worktree, index, and local tag state. Success establishes local
|
||||||
// candidate evidence only; it does not establish remote availability.
|
// candidate evidence only; it does not establish remote availability.
|
||||||
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
|
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
|
||||||
var validate func(context.Context, candidate.Candidate) error
|
checked, err := candidate.Check(ctx, candidateRequest(request))
|
||||||
if request.AdditionalChecks != nil {
|
|
||||||
validate = func(ctx context.Context, captured candidate.Candidate) error {
|
|
||||||
return request.AdditionalChecks(ctx, Candidate{
|
|
||||||
RepositoryDir: captured.RepositoryDir,
|
|
||||||
Commit: captured.Commit,
|
|
||||||
Version: captured.Version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checked, err := candidate.Check(ctx, candidate.Request{
|
|
||||||
RepositoryDir: request.RepositoryDir,
|
|
||||||
Product: request.Product,
|
|
||||||
Version: request.Version,
|
|
||||||
AdditionalChecks: validate,
|
|
||||||
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
|
|
||||||
Output: request.Output,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Candidate{}, err
|
return publicCandidate(checked), convertValidationError(err)
|
||||||
}
|
}
|
||||||
|
return publicCandidate(checked), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicCandidate(checked candidate.Candidate) Candidate {
|
||||||
return Candidate{
|
return Candidate{
|
||||||
RepositoryDir: checked.RepositoryDir,
|
RepositoryDir: checked.RepositoryDir,
|
||||||
|
ModuleDir: checked.ModuleDir,
|
||||||
|
ModulePath: checked.ModulePath,
|
||||||
|
Tag: checked.Tag,
|
||||||
Commit: checked.Commit,
|
Commit: checked.Commit,
|
||||||
Version: checked.Version,
|
Version: checked.Version,
|
||||||
}, nil
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||||
|
fileSize := internalFileSizeSettings(request.Validation.FileSize)
|
||||||
|
result := candidate.Request{
|
||||||
|
RepositoryDir: request.RepositoryDir,
|
||||||
|
ModuleDir: request.ModuleDir,
|
||||||
|
Product: request.Product,
|
||||||
|
Version: request.Version,
|
||||||
|
Validation: validation.Settings{
|
||||||
|
Race: request.Validation.Go.Race,
|
||||||
|
FullVet: request.Validation.Go.FullVet,
|
||||||
|
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||||
|
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||||
|
GoFormat: request.Validation.GoFormat,
|
||||||
|
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||||
|
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||||
|
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
|
||||||
|
LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements,
|
||||||
|
},
|
||||||
|
FileSize: fileSize,
|
||||||
|
},
|
||||||
|
AdditionalChecks: candidateChecks(request.AdditionalChecks),
|
||||||
|
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
|
||||||
|
Output: request.Output,
|
||||||
|
}
|
||||||
|
for index, target := range request.Validation.Go.CompileTargets {
|
||||||
|
result.Validation.CompileTargets[index] = validation.Target{OS: target.OS, Arch: target.Arch}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringSlice(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append(make([]string, 0, len(values)), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings {
|
||||||
|
if spec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &validation.MarkdownSettings{EnforceCheckoutBoundary: spec.EnforceCheckoutBoundary}
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalFileSizeSettings(spec *FileSizeValidationSpec) *validation.FileSizeSettings {
|
||||||
|
if spec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var limitBytes *int64
|
||||||
|
if spec.LimitBytes != nil {
|
||||||
|
limit := *spec.LimitBytes
|
||||||
|
limitBytes = &limit
|
||||||
|
}
|
||||||
|
return &validation.FileSizeSettings{
|
||||||
|
LimitBytes: limitBytes,
|
||||||
|
Exclusions: append([]string(nil), spec.Exclusions...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error {
|
||||||
|
if checks == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, captured candidate.Candidate) error {
|
||||||
|
return checks(ctx, publicCandidate(captured))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
check_request_test.go
Normal file
38
check_request_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package releasekit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCandidateRequestPreservesSharedFileSelection(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
wantNil bool
|
||||||
|
wantValues []string
|
||||||
|
}{
|
||||||
|
{name: "omitted defaults", input: nil, wantNil: true},
|
||||||
|
{name: "explicit empty", input: []string{}, wantValues: []string{}},
|
||||||
|
{name: "explicit paths", input: []string{"LICENSE", "docs/shared.md"}, wantValues: []string{"LICENSE", "docs/shared.md"}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
request := ReleaseRequest{Validation: ValidationSpec{SharedFiles: tt.input}}
|
||||||
|
mapped := candidateRequest(request).Validation.SharedFiles
|
||||||
|
if (mapped == nil) != tt.wantNil {
|
||||||
|
t.Fatalf("mapped shared files nil=%t, want %t", mapped == nil, tt.wantNil)
|
||||||
|
}
|
||||||
|
if len(mapped) != len(tt.wantValues) {
|
||||||
|
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
|
||||||
|
}
|
||||||
|
for index, value := range tt.wantValues {
|
||||||
|
if mapped[index] != value {
|
||||||
|
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tt.input) != 0 {
|
||||||
|
request.Validation.SharedFiles[0] = "changed"
|
||||||
|
if mapped[0] != tt.wantValues[0] {
|
||||||
|
t.Fatal("mapped shared-file selection aliases the caller's slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
103
cmd/releasekit/assets_test.go
Normal file
103
cmd/releasekit/assets_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name, module string
|
||||||
|
absolute bool
|
||||||
|
alias bool
|
||||||
|
}{
|
||||||
|
{name: "root relative"},
|
||||||
|
{name: "nested relative", module: "apps/widget"},
|
||||||
|
{name: "nested absolute", module: "apps/widget", absolute: true},
|
||||||
|
{name: "nested checkout alias", module: "apps/widget", absolute: true, alias: true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
repository := newDeliveryBuildRepository(t)
|
||||||
|
moduleDirectory := repository
|
||||||
|
tag := "v1.2.3"
|
||||||
|
if tt.module != "" {
|
||||||
|
moduleDirectory = filepath.Join(repository, filepath.FromSlash(tt.module))
|
||||||
|
tag = tt.module + "/v1.2.3"
|
||||||
|
for _, name := range []string{"go.mod", "main.go", "docs/releases/v1.2.3.md"} {
|
||||||
|
contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, tt.module+"/"+name, string(contents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, ".gitignore", "dist/\n")
|
||||||
|
state := &deliveryServerState{data: deliveryServerData{tag: tag}}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
}
|
||||||
|
writeDeliveryProfile(t, repository, settings)
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Prepare module source and output exclusion")
|
||||||
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
|
cliGitRun(t, repository, "tag", tag)
|
||||||
|
output, resolved := "dist", filepath.Join(moduleDirectory, "dist")
|
||||||
|
if tt.absolute {
|
||||||
|
output = filepath.Join(t.TempDir(), "assets")
|
||||||
|
resolved = output
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
invocationRepository := repository
|
||||||
|
if tt.alias {
|
||||||
|
invocationRepository = filepath.Join(t.TempDir(), "checkout-alias")
|
||||||
|
if err := os.Symlink(repository, invocationRepository); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := run(context.Background(), []string{"build", "--repo", invocationRepository, "--output", output, "v1.2.3"}, &stdout, &stderr); status != 0 {
|
||||||
|
t.Fatalf("build status=%d, stderr=%s", status, stderr.String())
|
||||||
|
}
|
||||||
|
receipt, err := cli.LoadBuildReceipt(filepath.Join(resolved, cli.BuildReceiptName))
|
||||||
|
if err != nil || receipt.ModuleDir != moduleDirectory || receipt.Tag != tag {
|
||||||
|
t.Fatalf("build receipt=%#v, error=%v", receipt, err)
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
args := []string{"deliver", "--repo", invocationRepository, "--assets", output, "v1.2.3"}
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||||
|
t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||||
|
}
|
||||||
|
if tt.alias {
|
||||||
|
if status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().assetUploads != 2 {
|
||||||
|
t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.snapshot().assetUploads, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.module != "" {
|
||||||
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
|
wrongModule := settings
|
||||||
|
wrongModule.ModuleDir = ""
|
||||||
|
invocation := cli.Invocation{Repository: repository, AssetsDir: resolved, Version: "v1.2.3"}
|
||||||
|
requestsBefore := state.snapshot().apiRequests
|
||||||
|
if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
|
||||||
|
t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
requestsBefore := state.snapshot().apiRequests
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
|
||||||
|
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
71
cmd/releasekit/committed_identity_test.go
Normal file
71
cmd/releasekit/committed_identity_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
if err := os.Rename(filepath.Join(repository, "go.mod"), filepath.Join(repository, "module.txt")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("module.txt", filepath.Join(repository, "go.mod")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product"})
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Use committed declaration symlink")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 {
|
||||||
|
t.Fatalf("linked declaration check status=%d stderr=%s", status, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "apps/widget"
|
||||||
|
writeRepositoryFile(t, repository, module+"/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
|
||||||
|
writeRepositoryFile(t, repository, module+"/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n")
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Add sparse release module")
|
||||||
|
state := &deliveryServerState{data: deliveryServerData{tag: module + "/v1.2.3"}}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
})
|
||||||
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
|
cliGitRun(t, repository, "tag", state.snapshot().tag)
|
||||||
|
cliGitRun(t, repository, "checkout", "--detach", state.snapshot().tag)
|
||||||
|
cliGitRun(t, repository, "sparse-checkout", "set", "--cone", "docs")
|
||||||
|
if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(module))); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("module fixture is materialized: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
gitOnly := t.TempDir()
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", gitOnly)
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"deliver", "--repo", repository, "v1.2.3"},
|
||||||
|
{"ci-deliver", "--repo", repository, "--prepared", "--expected-module", module, "--commit", state.snapshot().commit, state.snapshot().tag},
|
||||||
|
} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||||
|
t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
cmd/releasekit/example_source_test.go
Normal file
54
cmd/releasekit/example_source_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMultiModuleExampleSetupUsesCurrentSourceTool(t *testing.T) {
|
||||||
|
root := cliRepositoryRoot(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository)
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=")
|
||||||
|
goCommand := func(directory string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command("go", args...)
|
||||||
|
command.Dir, command.Env = directory, environment
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("go %q: %v\n%s", args, err, output)
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
selections := []struct {
|
||||||
|
directory, profile, product, version string
|
||||||
|
}{
|
||||||
|
{".", "releasekit-root.json", "Example Consumer", "v1.2.3"},
|
||||||
|
{"integrations/ckconfig", "releasekit-ckconfig.json", "Example Consumer Integration", "v1.2.3"},
|
||||||
|
{"integrations/core/v2", "releasekit-core-v2.json", "Example Consumer Core v2", "v2.1.0"},
|
||||||
|
}
|
||||||
|
for _, selected := range selections {
|
||||||
|
directory := filepath.Join(repository, filepath.FromSlash(selected.directory))
|
||||||
|
if selected.directory != "integrations/core/v2" {
|
||||||
|
writeRepositoryFile(t, repository, selected.directory+"/source.go", "package candidate\n")
|
||||||
|
}
|
||||||
|
goCommand(directory, "mod", "edit", "-require="+module+"@v0.0.0", "-replace="+module+"="+root, "-tool="+module+"/cmd/releasekit")
|
||||||
|
goCommand(directory, "mod", "tidy")
|
||||||
|
writeRepositoryFile(t, repository, selected.directory+"/docs/releases/"+selected.version+".md", "# "+selected.product+" "+selected.version+"\n\nSource verification fixture.\n")
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Select current source in disposable example")
|
||||||
|
for _, selected := range selections {
|
||||||
|
output := goCommand(filepath.Join(repository, filepath.FromSlash(selected.directory)), "tool", "releasekit", "check", "--config", selected.profile, selected.version)
|
||||||
|
if !strings.Contains(output, "is valid") {
|
||||||
|
t.Fatalf("source-selected check output=%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("source verification changed committed fixture: %s", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION"
|
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG"
|
||||||
|
|
||||||
type Command string
|
type Command string
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ type Invocation struct {
|
|||||||
Remote string
|
Remote string
|
||||||
Commit string
|
Commit string
|
||||||
Version string
|
Version string
|
||||||
|
ExpectedModule string
|
||||||
|
ExpectedTag string
|
||||||
|
Prepared bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse accepts only commands that have been implemented by the executable.
|
// Parse accepts only commands that have been implemented by the executable.
|
||||||
@@ -67,10 +71,16 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
|
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
|
||||||
}
|
}
|
||||||
var remote *string
|
var remote *string
|
||||||
|
expectedModule := ""
|
||||||
|
if action == PublishAction || action == CIDeliveryAction {
|
||||||
|
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
|
||||||
|
}
|
||||||
expectedCommit := ""
|
expectedCommit := ""
|
||||||
|
prepared := false
|
||||||
if action == CIDeliveryAction {
|
if action == CIDeliveryAction {
|
||||||
remote = flags.String("remote", "origin", "Git remote")
|
remote = flags.String("remote", "origin", "Git remote")
|
||||||
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit")
|
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit")
|
||||||
|
flags.BoolVar(&prepared, "prepared", false, "verify an already prepared tagged checkout without fetching or checkout")
|
||||||
}
|
}
|
||||||
if err := flags.Parse(args[1:]); err != nil {
|
if err := flags.Parse(args[1:]); err != nil {
|
||||||
return Invocation{}, err
|
return Invocation{}, err
|
||||||
@@ -84,11 +94,25 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
|
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
|
||||||
return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID")
|
return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID")
|
||||||
}
|
}
|
||||||
|
version := flags.Arg(0)
|
||||||
|
expectedTag := ""
|
||||||
|
if action == CIDeliveryAction {
|
||||||
|
expectedTag = version
|
||||||
|
if slash := strings.LastIndex(version, "/"); slash >= 0 {
|
||||||
|
version = version[slash+1:]
|
||||||
|
}
|
||||||
|
if !versioncontract.ValidStableVersion(version) {
|
||||||
|
return Invocation{}, errors.New("ci-deliver TAG must end in a stable vMAJOR.MINOR.PATCH version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !versioncontract.ValidStableVersion(version) {
|
||||||
|
return Invocation{}, errors.New("VERSION must be a stable vMAJOR.MINOR.PATCH version")
|
||||||
|
}
|
||||||
parsedRemote := ""
|
parsedRemote := ""
|
||||||
if remote != nil {
|
if remote != nil {
|
||||||
parsedRemote = *remote
|
parsedRemote = *remote
|
||||||
}
|
}
|
||||||
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0)}, nil
|
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil
|
||||||
default:
|
default:
|
||||||
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -112,16 +136,65 @@ type CheckCommand struct {
|
|||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings contains the product, remote, and check commands read from a
|
// Settings contains the product, remote, publication expectation, shared
|
||||||
// releasekit.json profile.
|
// validation selections, and check commands read from a releasekit.json profile.
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
Product string `json:"product"`
|
Product string `json:"product"`
|
||||||
|
ModuleDir string `json:"module_dir,omitempty"`
|
||||||
Remote string `json:"remote,omitempty"`
|
Remote string `json:"remote,omitempty"`
|
||||||
|
ExpectedPushURL string `json:"expected_push_url,omitempty"`
|
||||||
Checks []CheckCommand `json:"checks,omitempty"`
|
Checks []CheckCommand `json:"checks,omitempty"`
|
||||||
|
Validation *ValidationSettings `json:"validation,omitempty"`
|
||||||
Build *BuildSettings `json:"build,omitempty"`
|
Build *BuildSettings `json:"build,omitempty"`
|
||||||
Delivery *DeliverySettings `json:"delivery,omitempty"`
|
Delivery *DeliverySettings `json:"delivery,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidationSettings selects shared candidate checks in a release profile.
|
||||||
|
type ValidationSettings struct {
|
||||||
|
// SharedFiles selects committed files relative to the repository root.
|
||||||
|
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
||||||
|
SharedFiles []string `json:"shared_files"`
|
||||||
|
Go GoValidationSettings `json:"go,omitempty"`
|
||||||
|
// GoFormat checks the formatting of committed regular Go source files.
|
||||||
|
// Omitted or false disables the check.
|
||||||
|
GoFormat bool `json:"go_format,omitempty"`
|
||||||
|
Markdown *MarkdownSettings `json:"markdown,omitempty"`
|
||||||
|
ModuleDistribution ModuleDistributionSettings `json:"module_distribution,omitempty"`
|
||||||
|
// FileSize checks the size of each committed blob. Null or omission disables
|
||||||
|
// the check; an empty object enables the default limit.
|
||||||
|
FileSize *FileSizeSettings `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkdownSettings selects committed Markdown destination and fragment checks.
|
||||||
|
type MarkdownSettings struct {
|
||||||
|
// EnforceCheckoutBoundary fails local destinations that resolve outside
|
||||||
|
// the candidate checkout. Submodule contents remain out of scope.
|
||||||
|
EnforceCheckoutBoundary bool `json:"enforce_checkout_boundary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSizeSettings selects the maximum committed-file size and literal exclusions.
|
||||||
|
type FileSizeSettings struct {
|
||||||
|
LimitBytes *int64 `json:"limit_bytes,omitempty"`
|
||||||
|
Exclusions []string `json:"exclusions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDistributionSettings selects committed module-source distribution checks.
|
||||||
|
type ModuleDistributionSettings struct {
|
||||||
|
// WorkspaceFiles rejects committed go.work and go.work.sum blobs.
|
||||||
|
// Omitted or false disables the check.
|
||||||
|
WorkspaceFiles bool `json:"workspace_files,omitempty"`
|
||||||
|
// LocalReplacements rejects filesystem replacements in committed go.mod files.
|
||||||
|
// Omitted or false disables the check.
|
||||||
|
LocalReplacements bool `json:"local_replacements,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoValidationSettings selects checks performed by the Go toolchain.
|
||||||
|
type GoValidationSettings struct {
|
||||||
|
Race bool `json:"race,omitempty"`
|
||||||
|
FullVet bool `json:"full_vet,omitempty"`
|
||||||
|
CompileTargets []string `json:"compile_targets,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// DeliverySettings selects the hosted Gitea release destination.
|
// DeliverySettings selects the hosted Gitea release destination.
|
||||||
type DeliverySettings struct {
|
type DeliverySettings struct {
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
@@ -207,12 +280,26 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if settings.Validation != nil {
|
||||||
|
if fileSize := settings.Validation.FileSize; fileSize != nil && fileSize.LimitBytes != nil && *fileSize.LimitBytes < 0 {
|
||||||
|
return Settings{}, fmt.Errorf("profile %q: validation.file_size.limit_bytes must not be negative", configPath)
|
||||||
|
}
|
||||||
|
for i, target := range settings.Validation.Go.CompileTargets {
|
||||||
|
if strings.Count(target, "/") != 1 {
|
||||||
|
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
|
||||||
|
}
|
||||||
|
goos, goarch, _ := strings.Cut(target, "/")
|
||||||
|
if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" {
|
||||||
|
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunChecks runs configured argument vectors in repoDir. Each command receives
|
// RunChecks runs configured argument vectors in dir. Each command receives
|
||||||
// its own environment overlay and inherits the remaining process environment.
|
// its own environment overlay and inherits the remaining process environment.
|
||||||
func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error {
|
func RunChecks(ctx context.Context, dir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error {
|
||||||
for i, check := range checks {
|
for i, check := range checks {
|
||||||
environment := make(map[string]string, len(check.Env)+len(ownedEnv))
|
environment := make(map[string]string, len(check.Env)+len(ownedEnv))
|
||||||
for key, value := range check.Env {
|
for key, value := range check.Env {
|
||||||
@@ -223,7 +310,7 @@ func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, owned
|
|||||||
}
|
}
|
||||||
if err := process.Run(ctx, process.Invocation{
|
if err := process.Run(ctx, process.Invocation{
|
||||||
Args: check.Args,
|
Args: check.Args,
|
||||||
Dir: repoDir,
|
Dir: dir,
|
||||||
Env: environment,
|
Env: environment,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ func TestParse(t *testing.T) {
|
|||||||
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
|
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
|
||||||
t.Fatalf("Parse(publish) = %#v", publish)
|
t.Fatalf("Parse(publish) = %#v", publish)
|
||||||
}
|
}
|
||||||
|
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
|
||||||
|
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
|
||||||
|
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
|
||||||
|
}
|
||||||
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
|
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -47,11 +51,11 @@ func TestParse(t *testing.T) {
|
|||||||
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
|
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
|
||||||
t.Fatalf("Parse(deliver) = %#v", deliver)
|
t.Fatalf("Parse(deliver) = %#v", deliver)
|
||||||
}
|
}
|
||||||
ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.7"})
|
ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--prepared", "--expected-module", "integrations/ckconfig", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/v1.2.7"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" {
|
if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" || ciDelivery.ExpectedTag != "integrations/ckconfig/v1.2.7" || ciDelivery.ExpectedModule != "integrations/ckconfig" || !ciDelivery.Prepared {
|
||||||
t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery)
|
t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery)
|
||||||
}
|
}
|
||||||
ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"})
|
ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"})
|
||||||
@@ -64,6 +68,9 @@ func TestParse(t *testing.T) {
|
|||||||
if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") {
|
if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") {
|
||||||
t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err)
|
t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err)
|
||||||
}
|
}
|
||||||
|
if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/latest"}); err == nil || !strings.Contains(err.Error(), "stable vMAJOR.MINOR.PATCH") {
|
||||||
|
t.Fatalf("Parse(ci-deliver unstable terminal tag) error = %v", err)
|
||||||
|
}
|
||||||
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
|
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
|
||||||
t.Fatal("Parse accepted a flag after the version")
|
t.Fatal("Parse accepted a flag after the version")
|
||||||
}
|
}
|
||||||
@@ -141,6 +148,151 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadSettingsReadsGoValidation(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||||
|
if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"markdown":{"enforce_checkout_boundary":true},"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := LoadSettings(repoDir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.Validation == nil || !settings.Validation.GoFormat || settings.Validation.Markdown == nil || !settings.Validation.Markdown.EnforceCheckoutBoundary || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
|
||||||
|
t.Fatalf("validation settings = %#v, want Go formatting, workspace inspection, race, and full vet enabled", settings.Validation)
|
||||||
|
}
|
||||||
|
if settings.Validation.FileSize == nil || settings.Validation.FileSize.LimitBytes == nil || *settings.Validation.FileSize.LimitBytes != 0 || len(settings.Validation.FileSize.Exclusions) != 1 || settings.Validation.FileSize.Exclusions[0] != "assets" {
|
||||||
|
t.Fatalf("file size settings = %#v, want explicit zero limit and exclusions", settings.Validation.FileSize)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want {
|
||||||
|
t.Fatalf("compile targets = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if settings.ExpectedPushURL != "ssh://git.example/owner/repo.git" {
|
||||||
|
t.Fatalf("expected push URL = %q", settings.ExpectedPushURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSettingsPreservesSharedFilesSelection(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
profileDir := filepath.Join(repoDir, "profiles")
|
||||||
|
if err := os.MkdirAll(profileDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
profile string
|
||||||
|
wantNil bool
|
||||||
|
wantFile string
|
||||||
|
}{
|
||||||
|
{name: "omitted", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{}}`, wantNil: true},
|
||||||
|
{name: "null", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":null}}`, wantNil: true},
|
||||||
|
{name: "empty", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":[]}}`},
|
||||||
|
{name: "selected", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":["../README.md"]}}`, wantFile: "../README.md"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(profileDir, "release.json")
|
||||||
|
if err := os.WriteFile(path, []byte(test.profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := LoadSettings(repoDir, filepath.Join("profiles", "release.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.ModuleDir != "integrations/ckconfig" {
|
||||||
|
t.Fatalf("module_dir = %q", settings.ModuleDir)
|
||||||
|
}
|
||||||
|
if settings.Validation == nil || (settings.Validation.SharedFiles == nil) != test.wantNil {
|
||||||
|
t.Fatalf("shared_files = %#v, want nil=%t", settings.Validation, test.wantNil)
|
||||||
|
}
|
||||||
|
if test.wantFile != "" && (len(settings.Validation.SharedFiles) != 1 || settings.Validation.SharedFiles[0] != test.wantFile) {
|
||||||
|
t.Fatalf("shared_files = %#v, want %q", settings.Validation.SharedFiles, test.wantFile)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var roundTrip Settings
|
||||||
|
if err := json.Unmarshal(encoded, &roundTrip); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if (roundTrip.Validation.SharedFiles == nil) != test.wantNil {
|
||||||
|
t.Fatalf("serialized shared_files = %#v, want nil=%t; JSON=%s", roundTrip.Validation.SharedFiles, test.wantNil, encoded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
profile string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "omitted", profile: `{"product":"Example","validation":{}}`},
|
||||||
|
{name: "null", profile: `{"product":"Example","validation":{"markdown":null}}`},
|
||||||
|
{name: "empty object", profile: `{"product":"Example","validation":{"markdown":{}}}`, want: true},
|
||||||
|
{name: "boundary enabled", profile: `{"product":"Example","validation":{"markdown":{"enforce_checkout_boundary":true}}}`, want: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := LoadSettings(repoDir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := settings.Validation != nil && settings.Validation.Markdown != nil
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("Markdown selected = %t, want %t", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSettingsPreservesEmptyAndNullFileSize(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
profile string
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{name: "empty object", profile: `{"product":"Example","validation":{"file_size":{}}}`, enabled: true},
|
||||||
|
{name: "default limit", profile: `{"product":"Example","validation":{"file_size":{"limit_bytes":null}}}`, enabled: true},
|
||||||
|
{name: "null object", profile: `{"product":"Example","validation":{"file_size":null}}`},
|
||||||
|
{name: "omitted object", profile: `{"product":"Example","validation":{}}`},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||||
|
if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := LoadSettings(repoDir, profilePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := settings.Validation != nil && settings.Validation.FileSize != nil
|
||||||
|
if got != test.enabled {
|
||||||
|
t.Fatalf("FileSize enabled = %t, want %t", got, test.enabled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSettingsRejectsNegativeFileSizeLimit(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||||
|
profile := `{"product":"Example","validation":{"file_size":{"limit_bytes":-1}}}`
|
||||||
|
if err := os.WriteFile(profilePath, []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadSettings(repoDir, profilePath); err == nil || !strings.Contains(err.Error(), "validation.file_size.limit_bytes") {
|
||||||
|
t.Fatalf("LoadSettings() error = %v, want invalid negative file-size limit", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExampleProfileLoads(t *testing.T) {
|
func TestExampleProfileLoads(t *testing.T) {
|
||||||
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
|
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,6 +343,10 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
|
|||||||
{name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"},
|
{name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"},
|
||||||
{name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
|
{name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
|
||||||
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
|
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
|
||||||
|
{name: "compile target format", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
|
||||||
|
{name: "compile target missing architecture", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
|
||||||
|
{name: "compile target missing operating system", content: `{"product":"Example","validation":{"go":{"compile_targets":["/amd64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
|
||||||
|
{name: "compile target repeated separator", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/amd64/arm64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
|
||||||
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},
|
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},
|
||||||
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
|
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
|
||||||
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},
|
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,10 +11,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
const BuildReceiptName = ".releasekit-build.json"
|
const BuildReceiptName = ".releasekit-build.json"
|
||||||
|
const BuildReceiptSchemaVersion = 1
|
||||||
|
|
||||||
type ReceiptTarget struct {
|
type ReceiptTarget struct {
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
@@ -31,7 +32,11 @@ type ReceiptArtifact struct {
|
|||||||
|
|
||||||
// BuildReceipt is private local coordination data for generated binaries.
|
// BuildReceipt is private local coordination data for generated binaries.
|
||||||
type BuildReceipt struct {
|
type BuildReceipt struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
RepositoryDir string `json:"repository_dir"`
|
RepositoryDir string `json:"repository_dir"`
|
||||||
|
ModuleDir string `json:"module_dir"`
|
||||||
|
ModulePath string `json:"module_path"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
CandidateCommit string `json:"candidate_commit"`
|
CandidateCommit string `json:"candidate_commit"`
|
||||||
SourceVerified bool `json:"source_verified"`
|
SourceVerified bool `json:"source_verified"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -49,7 +54,7 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
|||||||
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
||||||
}
|
}
|
||||||
receipt.OutputDir = resolvedDir
|
receipt.OutputDir = resolvedDir
|
||||||
if err := validateBuildReceipt(receipt, true); err != nil {
|
if err := validateBuildReceipt(receipt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
contents, err := json.MarshalIndent(receipt, "", " ")
|
contents, err := json.MarshalIndent(receipt, "", " ")
|
||||||
@@ -111,41 +116,62 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) {
|
|||||||
}
|
}
|
||||||
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
|
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateBuildReceipt(receipt, true); err != nil {
|
if err := validateBuildReceipt(receipt); err != nil {
|
||||||
|
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
|
||||||
|
return BuildReceipt{}, fmt.Errorf("%w; regenerate it with the updated build command", err)
|
||||||
|
}
|
||||||
return BuildReceipt{}, err
|
return BuildReceipt{}, err
|
||||||
}
|
}
|
||||||
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) {
|
if !sameDirectory(receipt.OutputDir, filepath.Dir(resolvedPath)) {
|
||||||
return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
|
return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
|
||||||
}
|
}
|
||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
|
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
|
||||||
resolvedRepo, err := filepath.Abs(repositoryDir)
|
if !sameDirectory(receipt.RepositoryDir, repositoryDir) {
|
||||||
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName {
|
if receipt.CandidateCommit != commit || receipt.Version != version {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
want := normalizeReceiptTargets(settings.Targets)
|
want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets))
|
||||||
if len(want) != len(receipt.Targets) {
|
got := artifact.NormalizeSettings(receipt.MainPackage, receipt.BinaryName, parseStoredTargets(receipt.Targets))
|
||||||
|
if want.MainPackage != got.MainPackage || want.BinaryName != got.BinaryName || len(want.Targets) != len(got.Targets) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i, target := range want {
|
for i, target := range want.Targets {
|
||||||
if target != receipt.Targets[i] {
|
if target != got.Targets[i] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
|
func sameDirectory(first, second string) bool {
|
||||||
|
firstInfo, err := os.Stat(first)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
secondInfo, err := os.Stat(second)
|
||||||
|
return err == nil && os.SameFile(firstInfo, secondInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBuildReceipt(receipt BuildReceipt) error {
|
||||||
|
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
|
||||||
|
return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion)
|
||||||
|
}
|
||||||
if !versioncontract.ValidStableVersion(receipt.Version) {
|
if !versioncontract.ValidStableVersion(receipt.Version) {
|
||||||
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
|
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) {
|
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) {
|
||||||
return errors.New("build receipt repository and output directories must be absolute")
|
return errors.New("build receipt repository, module, and output directories must be absolute")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" {
|
||||||
|
return errors.New("build receipt is missing module path or full tag")
|
||||||
|
}
|
||||||
|
if err := validateRecordedIdentity(receipt); err != nil {
|
||||||
|
return fmt.Errorf("build receipt has inconsistent module identity: %w", err)
|
||||||
}
|
}
|
||||||
if !validCommitID(receipt.CandidateCommit) {
|
if !validCommitID(receipt.CandidateCommit) {
|
||||||
return errors.New("build receipt has invalid candidate commit")
|
return errors.New("build receipt has invalid candidate commit")
|
||||||
@@ -191,39 +217,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
|
|||||||
if !validSHA256(artifact.SHA256) {
|
if !validSHA256(artifact.SHA256) {
|
||||||
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
|
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
|
||||||
}
|
}
|
||||||
if verifyFiles {
|
}
|
||||||
if err := verifyReceiptArtifact(artifact); err != nil {
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRecordedIdentity(receipt BuildReceipt) error {
|
||||||
|
repositoryDir := filepath.Clean(receipt.RepositoryDir)
|
||||||
|
moduleDir := filepath.Clean(receipt.ModuleDir)
|
||||||
|
relative, err := filepath.Rel(repositoryDir, moduleDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("relate module directory to repository: %w", err)
|
||||||
|
}
|
||||||
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||||
|
return errors.New("module directory is outside repository")
|
||||||
|
}
|
||||||
|
if relative == "" {
|
||||||
|
relative = "."
|
||||||
|
}
|
||||||
|
identity, err := releaseidentity.Derive(releaseidentity.Selection{
|
||||||
|
RepositoryDir: repositoryDir,
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
ModuleSubdir: filepath.ToSlash(relative),
|
||||||
|
}, []byte("module "+receipt.ModulePath+"\n"), receipt.Version)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
if identity.Tag != receipt.Tag {
|
||||||
|
return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyReceiptArtifact(artifact ReceiptArtifact) error {
|
func parseReceiptTargets(targets []string) []artifact.Target {
|
||||||
info, err := os.Lstat(artifact.Path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
|
|
||||||
}
|
|
||||||
file, err := os.Open(artifact.Path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
hash := sha256.New()
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err)
|
|
||||||
}
|
|
||||||
if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 {
|
|
||||||
return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
|
|
||||||
parsed := make([]artifact.Target, 0, len(targets))
|
parsed := make([]artifact.Target, 0, len(targets))
|
||||||
for _, value := range targets {
|
for _, value := range targets {
|
||||||
goos, goarch, ok := strings.Cut(value, "/")
|
goos, goarch, ok := strings.Cut(value, "/")
|
||||||
@@ -232,12 +257,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget {
|
|||||||
}
|
}
|
||||||
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
|
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
|
||||||
}
|
}
|
||||||
normalized := artifact.NormalizeTargets(parsed)
|
return parsed
|
||||||
result := make([]ReceiptTarget, 0, len(normalized))
|
}
|
||||||
for _, target := range normalized {
|
|
||||||
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
|
func parseStoredTargets(targets []ReceiptTarget) []artifact.Target {
|
||||||
|
parsed := make([]artifact.Target, 0, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
parsed = append(parsed, artifact.Target{OS: target.OS, Arch: target.Arch})
|
||||||
}
|
}
|
||||||
return result
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func validCommitID(value string) bool {
|
func validCommitID(value string) bool {
|
||||||
|
|||||||
@@ -3,15 +3,33 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
moduleDir string
|
||||||
|
modulePath string
|
||||||
|
tag string
|
||||||
|
}{
|
||||||
|
{name: "root", moduleDir: ".", modulePath: "example.com/receipt", tag: "v1.2.3"},
|
||||||
|
{name: "nested", moduleDir: "integrations/ckconfig", modulePath: "example.com/ckconfig", tag: "integrations/ckconfig/v1.2.3"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
outputDir := t.TempDir()
|
outputDir := t.TempDir()
|
||||||
repositoryDir := t.TempDir()
|
repositoryDir := t.TempDir()
|
||||||
|
moduleDir := repositoryDir
|
||||||
|
if test.moduleDir != "." {
|
||||||
|
moduleDir = filepath.Join(repositoryDir, filepath.FromSlash(test.moduleDir))
|
||||||
|
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
filename := "example-v1.2.3-linux-amd64"
|
filename := "example-v1.2.3-linux-amd64"
|
||||||
artifactPath := filepath.Join(outputDir, filename)
|
artifactPath := filepath.Join(outputDir, filename)
|
||||||
contents := []byte("built application")
|
contents := []byte("built application")
|
||||||
@@ -20,7 +38,11 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
digest := sha256.Sum256(contents)
|
digest := sha256.Sum256(contents)
|
||||||
receipt := BuildReceipt{
|
receipt := BuildReceipt{
|
||||||
|
SchemaVersion: BuildReceiptSchemaVersion,
|
||||||
RepositoryDir: repositoryDir,
|
RepositoryDir: repositoryDir,
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
ModulePath: test.modulePath,
|
||||||
|
Tag: test.tag,
|
||||||
CandidateCommit: strings.Repeat("a", 40),
|
CandidateCommit: strings.Repeat("a", 40),
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
MainPackage: "./cmd/example",
|
MainPackage: "./cmd/example",
|
||||||
@@ -28,10 +50,8 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
|||||||
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
||||||
Compiler: "go version go1.25.0 linux/amd64",
|
Compiler: "go version go1.25.0 linux/amd64",
|
||||||
Artifacts: []ReceiptArtifact{{
|
Artifacts: []ReceiptArtifact{{
|
||||||
Path: artifactPath,
|
Path: artifactPath, Filename: filename,
|
||||||
Filename: filename,
|
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
|
||||||
Target: ReceiptTarget{OS: "linux", Arch: "amd64"},
|
|
||||||
SHA256: hex.EncodeToString(digest[:]),
|
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
path, err := WriteBuildReceipt(outputDir, receipt)
|
path, err := WriteBuildReceipt(outputDir, receipt)
|
||||||
@@ -42,14 +62,27 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings := BuildSettings{
|
settings := BuildSettings{MainPackage: "./cmd/example", BinaryName: "example", Targets: []string{"linux/amd64", "linux/amd64"}}
|
||||||
MainPackage: "./cmd/example",
|
|
||||||
BinaryName: "example",
|
|
||||||
Targets: []string{"linux/amd64", "linux/amd64"},
|
|
||||||
}
|
|
||||||
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||||
t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
|
t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
|
||||||
}
|
}
|
||||||
|
aliases := t.TempDir()
|
||||||
|
repositoryAlias, outputAlias := filepath.Join(aliases, "repository"), filepath.Join(aliases, "output")
|
||||||
|
if err := os.Symlink(repositoryDir, repositoryAlias); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outputDir, outputAlias); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !loaded.MatchesBuild(repositoryAlias, receipt.CandidateCommit, receipt.Version, settings) {
|
||||||
|
t.Fatal("receipt rejected an alias of its source repository")
|
||||||
|
}
|
||||||
|
if _, err := LoadBuildReceipt(filepath.Join(outputAlias, BuildReceiptName)); err != nil {
|
||||||
|
t.Fatalf("load receipt through output alias: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.MatchesBuild(t.TempDir(), receipt.CandidateCommit, receipt.Version, settings) {
|
||||||
|
t.Fatal("receipt matched a different repository")
|
||||||
|
}
|
||||||
settings.BinaryName = "other"
|
settings.BinaryName = "other"
|
||||||
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||||
t.Fatal("receipt matched changed build settings")
|
t.Fatal("receipt matched changed build settings")
|
||||||
@@ -57,8 +90,16 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
|||||||
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
|
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") {
|
if _, err := LoadBuildReceipt(path); err != nil {
|
||||||
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err)
|
t.Fatalf("receipt loading verified changed artifact bytes: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(artifactPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadBuildReceipt(path); err != nil {
|
||||||
|
t.Fatalf("receipt loading required artifact bytes: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +109,9 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"},
|
{name: "unknown field", body: `{"schema_version":1,"unexpected":true}`, want: "unknown field"},
|
||||||
|
{name: "unversioned receipt", body: `{}`, want: "regenerate it with the updated build command"},
|
||||||
|
{name: "unknown schema", body: `{"schema_version":2}`, want: "regenerate it with the updated build command"},
|
||||||
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
|
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -82,3 +125,66 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteBuildReceiptRejectsInconsistentRecordedIdentity(t *testing.T) {
|
||||||
|
repositoryDir := t.TempDir()
|
||||||
|
moduleDir := filepath.Join(repositoryDir, "integrations", "ckconfig")
|
||||||
|
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
receipt := BuildReceipt{
|
||||||
|
SchemaVersion: BuildReceiptSchemaVersion,
|
||||||
|
RepositoryDir: repositoryDir, ModuleDir: moduleDir, ModulePath: "example.com/ckconfig", Tag: "v1.2.3",
|
||||||
|
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
|
||||||
|
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
|
||||||
|
Artifacts: []ReceiptArtifact{{
|
||||||
|
Path: filepath.Join(outputDir, "helper-v1.2.3-linux-amd64"), Filename: "helper-v1.2.3-linux-amd64",
|
||||||
|
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if _, err := WriteBuildReceipt(outputDir, receipt); err == nil || !strings.Contains(err.Error(), "full tag") {
|
||||||
|
t.Fatalf("WriteBuildReceipt() error = %v, want inconsistent full tag", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBuildReceiptRejectsMismatchedModuleIdentity(t *testing.T) {
|
||||||
|
repositoryDir := t.TempDir()
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
artifactPath := filepath.Join(outputDir, "helper-v1.2.3-linux-amd64")
|
||||||
|
contents := []byte("fixture")
|
||||||
|
if err := os.WriteFile(artifactPath, contents, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(contents)
|
||||||
|
receipt := BuildReceipt{
|
||||||
|
SchemaVersion: BuildReceiptSchemaVersion,
|
||||||
|
RepositoryDir: repositoryDir, ModuleDir: repositoryDir, ModulePath: "example.com/receipt", Tag: "v1.2.3",
|
||||||
|
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
|
||||||
|
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
|
||||||
|
Artifacts: []ReceiptArtifact{{
|
||||||
|
Path: artifactPath, Filename: filepath.Base(artifactPath),
|
||||||
|
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
contents, err := json.Marshal(receipt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(contents, &fields); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fields["module_dir"] = filepath.Join(repositoryDir, "integrations", "ckconfig")
|
||||||
|
contents, err = json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(outputDir, BuildReceiptName)
|
||||||
|
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "inconsistent module identity") {
|
||||||
|
t.Fatalf("LoadBuildReceipt() error = %v, want inconsistent module identity", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
cmd/releasekit/local_module_test.go
Normal file
44
cmd/releasekit/local_module_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
|
||||||
|
repository := newDeliveryBuildRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example\n\ngo 1.26.0\n")
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Use local main module path")
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
settings := cli.Settings{Product: "Example Product",
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
}
|
||||||
|
writeDeliveryProfile(t, repository, settings)
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"check", "--repo", repository, "v1.2.3"},
|
||||||
|
{"build", "--repo", repository, "--output", filepath.Join(t.TempDir(), "assets"), "v1.2.3"},
|
||||||
|
} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || !strings.Contains(stdout.String(), "module example version v1.2.3 tag v1.2.3") {
|
||||||
|
t.Fatalf("%s status=%d stdout=%s stderr=%s", args[0], status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.Build = nil
|
||||||
|
writeDeliveryProfile(t, repository, settings)
|
||||||
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||||
|
t.Fatalf("page delivery status=%d creates=%d uploads=%d stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
"gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -29,6 +32,13 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if invocation.Command != cli.VersionCommand {
|
||||||
|
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "%v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch invocation.Command {
|
switch invocation.Command {
|
||||||
case cli.VersionCommand:
|
case cli.VersionCommand:
|
||||||
@@ -40,28 +50,17 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
request := releasekit.ReleaseRequest{
|
request := candidateRequest(invocation, settings, stderr)
|
||||||
RepositoryDir: invocation.Repository,
|
|
||||||
Product: settings.Product,
|
|
||||||
Version: invocation.Version,
|
|
||||||
Remote: settings.Remote,
|
|
||||||
Build: configuredBuildSpec(settings.Build),
|
|
||||||
Output: stderr,
|
|
||||||
}
|
|
||||||
if len(settings.Checks) != 0 {
|
|
||||||
checks := settings.Checks
|
|
||||||
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
|
|
||||||
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
|
|
||||||
"RELEASEKIT_VERSION": candidate.Version,
|
|
||||||
"RELEASEKIT_CANDIDATE": candidate.Commit,
|
|
||||||
}, stderr, stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if invocation.Command == cli.PublishAction {
|
if invocation.Command == cli.PublishAction {
|
||||||
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version)
|
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
request.ExpectedPushURL = settings.ExpectedPushURL
|
||||||
|
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||||
result, err := releasekit.Publish(ctx, request)
|
result, err := releasekit.Publish(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, err)
|
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err))
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -70,18 +69,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "published %s at %s (tag object %s)\n", result.Version, result.CandidateCommit, result.TagObject)
|
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
candidate, err := releasekit.Check(ctx, request)
|
candidate, err := releasekit.Check(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "check failed: %v\n", err)
|
fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit)
|
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
||||||
return 0
|
return 0
|
||||||
case cli.BuildAction:
|
case cli.BuildAction:
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
@@ -95,6 +94,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
|
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
|
||||||
RepositoryDir: invocation.Repository,
|
RepositoryDir: invocation.Repository,
|
||||||
|
ModuleDir: settings.ModuleDir,
|
||||||
Version: invocation.Version,
|
Version: invocation.Version,
|
||||||
Spec: *configuredBuildSpec(settings.Build),
|
Spec: *configuredBuildSpec(settings.Build),
|
||||||
OutputDir: invocation.OutputDir,
|
OutputDir: invocation.OutputDir,
|
||||||
@@ -107,7 +107,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err)
|
fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
|
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if len(result.Targets) == 0 {
|
if len(result.Targets) == 0 {
|
||||||
fmt.Fprintf(stdout, "no build targets selected for %s at %s\n", result.Version, result.CandidateCommit)
|
fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
receipt := receiptFromBuild(result)
|
receipt := receiptFromBuild(result)
|
||||||
@@ -135,7 +135,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
|
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "built %d artifacts for %s at %s with %s\n", len(result.Artifacts), result.Version, result.CandidateCommit, result.Compiler)
|
fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
|
||||||
for _, artifact := range result.Artifacts {
|
for _, artifact := range result.Artifacts {
|
||||||
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
|
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,113 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
||||||
|
request := releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: invocation.Repository,
|
||||||
|
ModuleDir: settings.ModuleDir,
|
||||||
|
Product: settings.Product,
|
||||||
|
Version: invocation.Version,
|
||||||
|
Remote: settings.Remote,
|
||||||
|
Validation: validationSpec(settings.Validation),
|
||||||
|
Build: configuredBuildSpec(settings.Build),
|
||||||
|
Output: output,
|
||||||
|
}
|
||||||
|
if len(settings.Checks) != 0 {
|
||||||
|
checks := settings.Checks
|
||||||
|
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
|
||||||
|
return cli.RunChecks(ctx, candidate.ModuleDir, checks, map[string]string{
|
||||||
|
"RELEASEKIT_VERSION": candidate.Version,
|
||||||
|
"RELEASEKIT_CANDIDATE": candidate.Commit,
|
||||||
|
"RELEASEKIT_REPOSITORY_DIR": candidate.RepositoryDir,
|
||||||
|
"RELEASEKIT_MODULE_DIR": candidate.ModuleDir,
|
||||||
|
"RELEASEKIT_MODULE_PATH": candidate.ModulePath,
|
||||||
|
"RELEASEKIT_TAG": candidate.Tag,
|
||||||
|
}, output, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec {
|
||||||
|
if settings == nil {
|
||||||
|
return releasekit.ValidationSpec{}
|
||||||
|
}
|
||||||
|
result := releasekit.ValidationSpec{
|
||||||
|
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||||
|
GoFormat: settings.GoFormat,
|
||||||
|
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||||
|
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||||
|
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||||
|
},
|
||||||
|
Go: releasekit.GoValidationSpec{
|
||||||
|
Race: settings.Go.Race,
|
||||||
|
FullVet: settings.Go.FullVet,
|
||||||
|
CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if settings.Markdown != nil {
|
||||||
|
result.Markdown = &releasekit.MarkdownValidationSpec{
|
||||||
|
EnforceCheckoutBoundary: settings.Markdown.EnforceCheckoutBoundary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings.FileSize != nil {
|
||||||
|
var limitBytes *int64
|
||||||
|
if settings.FileSize.LimitBytes != nil {
|
||||||
|
limit := *settings.FileSize.LimitBytes
|
||||||
|
limitBytes = &limit
|
||||||
|
}
|
||||||
|
result.FileSize = &releasekit.FileSizeValidationSpec{
|
||||||
|
LimitBytes: limitBytes,
|
||||||
|
Exclusions: append([]string(nil), settings.FileSize.Exclusions...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, target := range settings.Go.CompileTargets {
|
||||||
|
goos, goarch, _ := strings.Cut(target, "/")
|
||||||
|
result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateFailureText(err error) string {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkExpectedModule(ctx context.Context, repositoryDir, expectedModule, profileModule string) error {
|
||||||
|
if expectedModule == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, expectedModule, commit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("normalize expected module %q: %w", expectedModule, err)
|
||||||
|
}
|
||||||
|
selected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, profileModule, commit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("normalize profile module %q: %w", profileModule, err)
|
||||||
|
}
|
||||||
|
if expected.ModuleSubdir != selected.ModuleSubdir {
|
||||||
|
return fmt.Errorf("expected module %q resolves to %q, but the profile selects %q", expectedModule, expected.ModuleSubdir, selected.ModuleSubdir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedModuleLabel(moduleDir string) string {
|
||||||
|
if moduleDir == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return moduleDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringsPreservingEmpty(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string{}, values...)
|
||||||
|
}
|
||||||
|
|
||||||
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,9 +268,10 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
||||||
RepositoryDir: invocation.Repository,
|
RepositoryDir: invocation.Repository,
|
||||||
Version: invocation.Version,
|
Tag: invocation.ExpectedTag,
|
||||||
Remote: invocation.Remote,
|
Remote: invocation.Remote,
|
||||||
ExpectedCommit: invocation.Commit,
|
ExpectedCommit: invocation.Commit,
|
||||||
|
VerifyOnly: invocation.Prepared,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err)
|
fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err)
|
||||||
@@ -178,12 +286,19 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
|||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
destination, status := deliveryDestination(settings, stderr)
|
destination, status := deliveryDestination(settings, stderr)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
||||||
if err := releasekit.PrepareBuildDependencies(ctx, invocation.Repository); err != nil {
|
if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
|
||||||
|
RepositoryDir: invocation.Repository,
|
||||||
|
ModuleDir: settings.ModuleDir,
|
||||||
|
}); err != nil {
|
||||||
fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err)
|
fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -232,10 +347,17 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
}
|
}
|
||||||
if hasTargets {
|
if hasTargets {
|
||||||
if invocation.AssetsDir != "" {
|
if invocation.AssetsDir != "" {
|
||||||
assetsDir := invocation.AssetsDir
|
commit, err := git.CaptureHead(ctx, invocation.Repository)
|
||||||
if !filepath.IsAbs(assetsDir) {
|
if err != nil {
|
||||||
assetsDir = filepath.Join(invocation.Repository, assetsDir)
|
fmt.Fprintf(stderr, "capture asset source commit: %v\n", err)
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
selection, err := releaseidentity.ResolveCommittedSelection(ctx, invocation.Repository, settings.ModuleDir, commit)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
assetsDir := artifact.OutputDirectory(selection.ModuleDir, invocation.AssetsDir)
|
||||||
receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName))
|
receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "load verified build receipt: %v\n", err)
|
fmt.Fprintf(stderr, "load verified build receipt: %v\n", err)
|
||||||
@@ -251,15 +373,17 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
|
|
||||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||||
RepositoryDir: invocation.Repository,
|
RepositoryDir: invocation.Repository,
|
||||||
|
ModuleDir: settings.ModuleDir,
|
||||||
Product: settings.Product,
|
Product: settings.Product,
|
||||||
Version: invocation.Version,
|
Version: invocation.Version,
|
||||||
|
ExpectedTag: invocation.ExpectedTag,
|
||||||
Destination: destination,
|
Destination: destination,
|
||||||
Build: buildSpec,
|
Build: buildSpec,
|
||||||
Prebuilt: prebuilt,
|
Prebuilt: prebuilt,
|
||||||
Output: stderr,
|
Output: stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)
|
fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
|
||||||
writeDeliveryAssets(stderr, result)
|
writeDeliveryAssets(stderr, result)
|
||||||
writeDeliveryRecovery(stderr, result)
|
writeDeliveryRecovery(stderr, result)
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
@@ -270,7 +394,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "delivered %s at %s (state %s)\n", result.Version, result.CandidateCommit, result.State)
|
fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
|
||||||
if result.ReleaseURL != "" {
|
if result.ReleaseURL != "" {
|
||||||
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
|
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
|
||||||
}
|
}
|
||||||
@@ -292,13 +416,13 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
|
|||||||
func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
|
func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
|
||||||
switch result.State {
|
switch result.State {
|
||||||
case releasekit.DeliveryNotAttempted:
|
case releasekit.DeliveryNotAttempted:
|
||||||
fmt.Fprintln(output, "recovery: fix the local or preflight failure before retrying; no remote write was attempted")
|
fmt.Fprintf(output, "recovery: fix the local or preflight failure for module %s version %s tag %s before retrying; no remote write was attempted\n", result.ModulePath, result.Version, result.Tag)
|
||||||
case releasekit.DeliveryPartial:
|
case releasekit.DeliveryPartial:
|
||||||
fmt.Fprintln(output, "recovery: fix the reported cause, then retry this version")
|
fmt.Fprintf(output, "recovery: fix the reported cause, then retry module %s version %s tag %s\n", result.ModulePath, result.Version, result.Tag)
|
||||||
case releasekit.DeliveryConflict:
|
case releasekit.DeliveryConflict:
|
||||||
fmt.Fprintln(output, "recovery: investigate conflicting hosted content; use a new version if the source or published contents need correction")
|
fmt.Fprintf(output, "recovery: investigate conflicting hosted content for module %s version %s tag %s; use a new version if the source or published contents need correction\n", result.ModulePath, result.Version, result.Tag)
|
||||||
case releasekit.DeliveryUnknown:
|
case releasekit.DeliveryUnknown:
|
||||||
fmt.Fprintln(output, "recovery: resolve the remote state before retrying")
|
fmt.Fprintf(output, "recovery: resolve the remote state for module %s version %s tag %s before retrying\n", result.ModulePath, result.Version, result.Tag)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -309,7 +433,8 @@ func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
|
|||||||
|
|
||||||
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
|
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
|
||||||
result := &releasekit.BuildResult{
|
result := &releasekit.BuildResult{
|
||||||
RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified,
|
RepositoryDir: receipt.RepositoryDir, ModuleDir: receipt.ModuleDir, ModulePath: receipt.ModulePath, Tag: receipt.Tag,
|
||||||
|
CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified,
|
||||||
Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
|
Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
|
||||||
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
|
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
|
||||||
}
|
}
|
||||||
@@ -343,7 +468,11 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
|
|||||||
|
|
||||||
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
|
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
|
||||||
receipt := cli.BuildReceipt{
|
receipt := cli.BuildReceipt{
|
||||||
|
SchemaVersion: cli.BuildReceiptSchemaVersion,
|
||||||
RepositoryDir: result.RepositoryDir,
|
RepositoryDir: result.RepositoryDir,
|
||||||
|
ModuleDir: result.ModuleDir,
|
||||||
|
ModulePath: result.ModulePath,
|
||||||
|
Tag: result.Tag,
|
||||||
CandidateCommit: result.CandidateCommit,
|
CandidateCommit: result.CandidateCommit,
|
||||||
SourceVerified: result.SourceVerified,
|
SourceVerified: result.SourceVerified,
|
||||||
Version: result.Version,
|
Version: result.Version,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
247
cmd/releasekit/multi_module_example_test.go
Normal file
247
cmd/releasekit/multi_module_example_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMultiModuleSelectorMapsSelectorsAndTags(t *testing.T) {
|
||||||
|
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||||
|
helper := filepath.Join(example, "select-module.sh")
|
||||||
|
for _, test := range []struct {
|
||||||
|
mode string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{mode: "selector", value: "root", want: "root\tv[01].*\t.\treleasekit-root.json"},
|
||||||
|
{mode: "selector", value: "ckconfig", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"},
|
||||||
|
{mode: "tag", value: "integrations/ckconfig/v1.2.3", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"},
|
||||||
|
{mode: "tag", value: "integrations/core/v2.3.4", want: "core-v2\tintegrations/core/v2.*\tintegrations/core/v2\treleasekit-core-v2.json"},
|
||||||
|
{mode: "tag", value: "v1.2.3", want: "root\tv[01].*\t.\treleasekit-root.json"},
|
||||||
|
} {
|
||||||
|
t.Run(test.mode+" "+test.value, func(t *testing.T) {
|
||||||
|
got := runModuleSelector(t, helper, test.mode, test.value, "")
|
||||||
|
if strings.TrimSpace(got) != test.want {
|
||||||
|
t.Fatalf("selector output = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mapPath := filepath.Join(t.TempDir(), "release modules.tsv")
|
||||||
|
if err := os.WriteFile(mapPath, []byte("one\tfeature/*\tmodules/one\tprofiles/one.json\ntwo\tfeature/v1.*\tmodules/two\tprofiles/two.json\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if output := runModuleSelectorError(t, helper, "selector", "absent", mapPath); !strings.Contains(output, "no module map row") {
|
||||||
|
t.Fatalf("unknown selector error = %q", output)
|
||||||
|
}
|
||||||
|
if output := runModuleSelectorError(t, helper, "tag", "feature/v1.2.3", mapPath); !strings.Contains(output, "ambiguous") {
|
||||||
|
t.Fatalf("ambiguous event error = %q", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(mapPath, []byte("space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := runModuleSelector(t, helper, "tag", "feature/v1.2.3", mapPath)
|
||||||
|
want := "space\tfeature/*\tintegrations/ck config\tprofiles/core release.json"
|
||||||
|
if strings.TrimSpace(got) != want {
|
||||||
|
t.Fatalf("paths with spaces = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
|
||||||
|
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
copyExampleTree(t, example, repository)
|
||||||
|
|
||||||
|
rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n"
|
||||||
|
selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", rootModule)
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule)
|
||||||
|
spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.299\n"
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule)
|
||||||
|
writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "prepare multi-module launcher fixture")
|
||||||
|
|
||||||
|
mapPath := filepath.Join(t.TempDir(), "release modules.tsv")
|
||||||
|
if err := os.WriteFile(mapPath, []byte("with space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shimDir := t.TempDir()
|
||||||
|
goLog := filepath.Join(t.TempDir(), "go commands")
|
||||||
|
argsLog := filepath.Join(t.TempDir(), "tool arguments")
|
||||||
|
shim := `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
|
||||||
|
modfile=
|
||||||
|
output=
|
||||||
|
previous=
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
|
if [ "$previous" = -o ]; then output=$argument; fi
|
||||||
|
previous=$argument
|
||||||
|
done
|
||||||
|
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
|
||||||
|
if [ "$1" = build ]; then
|
||||||
|
cat > "$output" <<'TOOL'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
|
||||||
|
TOOL
|
||||||
|
chmod +x "$output"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_MODULE_MAP", mapPath)
|
||||||
|
t.Setenv("RELEASEKIT_TEST_GO_LOG", goLog)
|
||||||
|
t.Setenv("RELEASEKIT_TEST_TOOL_ARGS", argsLog)
|
||||||
|
launcher := filepath.Join(repository, "release.sh")
|
||||||
|
command := exec.Command(launcher, "with space", "v1.2.4")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
t.Fatalf("launcher error: %v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
goCommands, err := os.ReadFile(goLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantModule := filepath.Join(repository, "integrations", "ck config")
|
||||||
|
if !strings.Contains(string(goCommands), "cwd="+wantModule) || !strings.Contains(string(goCommands), "v0.2.299") || strings.Contains(string(goCommands), "v0.2.197") {
|
||||||
|
t.Fatalf("tool bootstrap did not use selected module metadata: %s", goCommands)
|
||||||
|
}
|
||||||
|
toolArgs, err := os.ReadFile(argsLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"<publish>", "<--repo>", "<" + repository + ">", "<--config>", "<profiles/core release.json>", "<--expected-module>", "<integrations/ck config>", "<v1.2.4>"} {
|
||||||
|
if !strings.Contains(string(toolArgs), expected) {
|
||||||
|
t.Errorf("publication arguments %q omit %q", toolArgs, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(goCommands), repository+"/.releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("temporary tool metadata was placed inside the repository: %s", goCommands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
||||||
|
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
copyExampleTree(t, example, repository)
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n")
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n")
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
|
||||||
|
|
||||||
|
proxy := writeReleaseKitModuleProxy(t)
|
||||||
|
moduleCache := t.TempDir()
|
||||||
|
t.Setenv("GOMODCACHE", moduleCache)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = filepath.WalkDir(moduleCache, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err == nil && entry.IsDir() {
|
||||||
|
_ = os.Chmod(path, 0o700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy))
|
||||||
|
t.Setenv("GOSUMDB", "off")
|
||||||
|
t.Setenv("GOTOOLCHAIN", "local")
|
||||||
|
moduleBefore, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launcher error: %v output=%q", err, output)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{
|
||||||
|
"tool-version=v0.2.198 helper=tool-only dependency",
|
||||||
|
`"publish"`, `"--repo"`, `"` + repository + `"`,
|
||||||
|
`"--config"`, `"releasekit-ckconfig.json"`,
|
||||||
|
`"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(output), expected) {
|
||||||
|
t.Errorf("launcher output %q omits %q", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(output), "tool-version=v0.2.197") {
|
||||||
|
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output)
|
||||||
|
}
|
||||||
|
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
||||||
|
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
|
||||||
|
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command("sh", helper, mode, value)
|
||||||
|
command.Env = os.Environ()
|
||||||
|
if mapPath != "" {
|
||||||
|
command.Env = append(command.Env, "RELEASEKIT_MODULE_MAP="+mapPath)
|
||||||
|
}
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("selector %s %q: %v: %s", mode, value, err, output)
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runModuleSelectorError(t *testing.T, helper, mode, value, mapPath string) string {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command("sh", helper, mode, value)
|
||||||
|
command.Env = append(os.Environ(), "RELEASEKIT_MODULE_MAP="+mapPath)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("selector %s %q unexpectedly succeeded: %s", mode, value, output)
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyExampleTree(t *testing.T, source, destination string) {
|
||||||
|
t.Helper()
|
||||||
|
err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(source, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if relative == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := filepath.Join(destination, relative)
|
||||||
|
if entry.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o700)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if err := os.WriteFile(target, contents, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chmod(target, mode)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(fmt.Errorf("copy multi-module example: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
439
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
439
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||||
|
root := cliRepositoryRoot(t)
|
||||||
|
example := filepath.Join(root, "examples", "multi-module")
|
||||||
|
contents, err := os.ReadFile(filepath.Join(example, "woodpecker.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
|
for _, eventTag := range []string{"refs/tags/v[01].*", "refs/tags/integrations/ckconfig/v*", "refs/tags/integrations/core/v2.*"} {
|
||||||
|
if !strings.Contains(string(contents), eventTag) {
|
||||||
|
t.Errorf("workflow tag filters omit %q", eventTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolBinary := buildWorkflowCLI(t, root)
|
||||||
|
proxy := writeWorkflowToolProxy(t)
|
||||||
|
|
||||||
|
t.Run("nested page delivery retries from tagged mapping", func(t *testing.T) {
|
||||||
|
const tag = "integrations/ckconfig/v1.2.3"
|
||||||
|
delivery := newMappedDeliveryFixture(t, tag)
|
||||||
|
repository := newWorkflowRepository(t, example)
|
||||||
|
writeMappedProfile(t, repository, "releasekit-ckconfig.json", cli.Settings{
|
||||||
|
Product: "Example Consumer Integration", ModuleDir: "integrations/ckconfig",
|
||||||
|
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Consumer Integration v1.2.3\n\nTagged integration note.\n")
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.197"))
|
||||||
|
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||||
|
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||||
|
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||||
|
env["CI_COMMIT_SHA"] = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+tag)
|
||||||
|
if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") {
|
||||||
|
t.Fatalf("tagged page delivery output = %q", output)
|
||||||
|
}
|
||||||
|
if delivery.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag {
|
||||||
|
t.Fatalf("page delivery fixture = %#v", delivery.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
cliGitRun(t, repository, "checkout", "main")
|
||||||
|
writeRepositoryFile(t, repository, "release-modules.tsv", "# changed after release\nroot\tv[01].*\t.\treleasekit-root.json\n")
|
||||||
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.198"))
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "change default branch release selection")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "main")
|
||||||
|
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197") || strings.Contains(output, "tool-version=v0.2.198") {
|
||||||
|
t.Fatalf("tag retry did not use its committed tool version: %q", output)
|
||||||
|
}
|
||||||
|
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
||||||
|
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery.snapshot())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("binary delivery from physical major module", func(t *testing.T) {
|
||||||
|
const tag = "integrations/core/v2.1.0"
|
||||||
|
delivery := newMappedDeliveryFixture(t, tag)
|
||||||
|
repository := newWorkflowRepository(t, example)
|
||||||
|
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||||
|
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/amd64"}},
|
||||||
|
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
|
||||||
|
prepareWorkflowModuleSum(t, repository, "integrations/core/v2", proxy)
|
||||||
|
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||||
|
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||||
|
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
|
||||||
|
t.Fatalf("binary delivery output = %q", output)
|
||||||
|
}
|
||||||
|
observed := delivery.snapshot()
|
||||||
|
if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag {
|
||||||
|
t.Fatalf("binary delivery fixture = %#v", observed)
|
||||||
|
}
|
||||||
|
if _, ok := observed.assets["core-v2.1.0-linux-amd64"]; !ok {
|
||||||
|
t.Fatalf("binary asset missing: %#v", observed.assets)
|
||||||
|
}
|
||||||
|
if _, ok := observed.assets["SHA256SUMS"]; !ok {
|
||||||
|
t.Fatalf("checksum asset missing: %#v", observed.assets)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("binary delivery from virtual major module", func(t *testing.T) {
|
||||||
|
const tag = "integrations/core/v2.1.0"
|
||||||
|
delivery := newMappedDeliveryFixture(t, tag)
|
||||||
|
repository := newWorkflowRepository(t, example)
|
||||||
|
writeRepositoryFile(t, repository, "release-modules.tsv", strings.ReplaceAll(readWorkflowFile(t, repository, "release-modules.tsv"), "integrations/core/v2\treleasekit-core-v2.json", "integrations/core\treleasekit-core-v2.json"))
|
||||||
|
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||||
|
Product: "Example Consumer Core v2", ModuleDir: "integrations/core",
|
||||||
|
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, "integrations/core/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/core/v2", "v0.2.197"))
|
||||||
|
writeRepositoryFile(t, repository, "integrations/core/main.go", readWorkflowFile(t, example, "integrations/core/v2/main.go"))
|
||||||
|
writeRepositoryFile(t, repository, "integrations/core/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
|
||||||
|
prepareWorkflowModuleSum(t, repository, "integrations/core", proxy)
|
||||||
|
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||||
|
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||||
|
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
|
||||||
|
t.Fatalf("virtual-major delivery output = %q", output)
|
||||||
|
}
|
||||||
|
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
||||||
|
t.Fatalf("virtual-major delivery fixture = %#v", delivery.snapshot())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inconsistent mapping fails exact event tag check", func(t *testing.T) {
|
||||||
|
const tag = "integrations/ckconfig/v2.1.0"
|
||||||
|
delivery := newMappedDeliveryFixture(t, tag)
|
||||||
|
repository := newWorkflowRepository(t, example)
|
||||||
|
mapping := readWorkflowFile(t, repository, "release-modules.tsv")
|
||||||
|
mapping = strings.Replace(mapping,
|
||||||
|
"ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json",
|
||||||
|
"ckconfig\tintegrations/ckconfig/v*\tintegrations/core/v2\treleasekit-core-v2.json", 1)
|
||||||
|
writeRepositoryFile(t, repository, "release-modules.tsv", mapping)
|
||||||
|
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||||
|
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
|
||||||
|
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nMismatched mapped note.\n")
|
||||||
|
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||||
|
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||||
|
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||||
|
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||||
|
t.Fatalf("inconsistent mapping succeeded: %s", output)
|
||||||
|
} else if !strings.Contains(output, tag) || !strings.Contains(output, "integrations/core/v2.1.0") {
|
||||||
|
t.Fatalf("inconsistent mapping did not fail the exact tag comparison: %s", output)
|
||||||
|
}
|
||||||
|
if delivery.snapshot().requests != 0 {
|
||||||
|
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery.snapshot())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type mappedDeliveryFixture struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data mappedDeliveryData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *mappedDeliveryFixture) update(change func(*mappedDeliveryData)) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
change(&state.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *mappedDeliveryFixture) snapshot() mappedDeliveryData {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
result := state.data
|
||||||
|
|
||||||
|
result.assets = make(map[string][]byte, len(state.data.assets))
|
||||||
|
for key, value := range state.data.assets {
|
||||||
|
result.assets[key] = append([]byte(nil), value...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type mappedDeliveryData struct {
|
||||||
|
URL string
|
||||||
|
tag string
|
||||||
|
commit string
|
||||||
|
page bool
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
creates int
|
||||||
|
uploads int
|
||||||
|
requests int
|
||||||
|
assets map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||||
|
t.Helper()
|
||||||
|
state := &mappedDeliveryFixture{data: mappedDeliveryData{tag: tag, assets: make(map[string][]byte)}}
|
||||||
|
data := &state.data
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
data.requests++
|
||||||
|
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||||
|
t.Errorf("delivery authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
apiRoot := "/api/v1/repos/owner/example/"
|
||||||
|
escapedTag := url.PathEscape(tag)
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case apiRoot + "tags/" + escapedTag:
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
|
||||||
|
case apiRoot + "releases/tags/" + escapedTag:
|
||||||
|
if !data.page {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": data.name, "body": data.body, "draft": false, "prerelease": false})
|
||||||
|
case apiRoot + "releases":
|
||||||
|
var payload struct {
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
Commit string `json:"target_commitish"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Errorf("decode release create: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Tag != tag || payload.Commit != data.commit {
|
||||||
|
t.Errorf("release payload = %#v", payload)
|
||||||
|
}
|
||||||
|
data.page = true
|
||||||
|
data.name = payload.Name
|
||||||
|
data.body = payload.Body
|
||||||
|
data.creates++
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": payload.Name, "body": payload.Body, "draft": false, "prerelease": false})
|
||||||
|
case apiRoot + "releases/10/assets":
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
listing := make([]map[string]any, 0, len(data.assets))
|
||||||
|
for name, content := range data.assets {
|
||||||
|
listing = append(listing, map[string]any{"id": len(listing) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(listing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
t.Errorf("parse asset upload: %v", err)
|
||||||
|
http.Error(w, "multipart", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
file, _, err := r.FormFile("attachment")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read asset %q: %v", name, err)
|
||||||
|
http.Error(w, "attachment", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
_ = file.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read asset bytes: %v", err)
|
||||||
|
}
|
||||||
|
data.assets[name] = content
|
||||||
|
data.uploads++
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": data.uploads, "name": name, "size": len(content)})
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||||
|
if content, ok := data.assets[name]; ok {
|
||||||
|
_, _ = w.Write(content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("unexpected delivery request %s %s", r.Method, r.URL.EscapedPath())
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
data.URL = "http://" + server.Listener.Addr().String()
|
||||||
|
server.Start()
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverURL(r *http.Request) string {
|
||||||
|
return "http://" + r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWorkflowCLI(t *testing.T, root string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "releasekit")
|
||||||
|
command := exec.Command("go", "build", "-o", path, "./cmd/releasekit")
|
||||||
|
command.Dir = root
|
||||||
|
command.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off")
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build workflow CLI: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWorkflowToolProxy(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
const helper = "example.com/releasekit-helper"
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
|
||||||
|
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
||||||
|
main := fmt.Sprintf(`package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
helper "%s"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("tool-version=%s helper=" + helper.Version)
|
||||||
|
command := exec.Command(os.Getenv("RELEASEKIT_TEST_BINARY"), os.Args[1:]...)
|
||||||
|
command.Env = os.Environ()
|
||||||
|
command.Stdout = os.Stdout
|
||||||
|
command.Stderr = os.Stderr
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
if exit, ok := err.(*exec.ExitError); ok { os.Exit(exit.ExitCode()) }
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, helper, version)
|
||||||
|
files := map[string]string{
|
||||||
|
"cmd/releasekit/main.go": main,
|
||||||
|
"buildinfo/version.go": "package buildinfo\nimport \"gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo\"\nfunc Version() string { return runtimeinfo.Version() }\n",
|
||||||
|
"internal/runtimeinfo/version.go": "package runtimeinfo\nvar injectedVersion string\nfunc Version() string { if injectedVersion != \"\" { return injectedVersion }; return \"dev\" }\n",
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files)
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, helper, "v1.0.0", "module "+helper+"\n\ngo 1.25.0\n", map[string]string{"helper.go": "package helper\nconst Version = \"tagged dependency\"\n"})
|
||||||
|
return proxyDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWorkflowRepository(t *testing.T, example string) string {
|
||||||
|
t.Helper()
|
||||||
|
repository := filepath.Join(t.TempDir(), "multi module checkout")
|
||||||
|
if err := os.Mkdir(repository, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
copyExampleTree(t, example, repository)
|
||||||
|
for _, selection := range []struct{ directory, module string }{
|
||||||
|
{".", "example.com/multi-module-consumer"},
|
||||||
|
{"integrations/ckconfig", "example.com/multi-module-consumer/integrations/ckconfig"},
|
||||||
|
{"integrations/core/v2", "example.com/multi-module-consumer/integrations/core/v2"},
|
||||||
|
} {
|
||||||
|
writeRepositoryFile(t, repository, selection.directory+"/go.mod", workflowModuleFile(selection.module, "v0.2.197"))
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "init", "--initial-branch=main")
|
||||||
|
cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test")
|
||||||
|
cliGitRun(t, repository, "config", "user.email", "releasekit@example.test")
|
||||||
|
cliGitRun(t, repository, "config", "commit.gpgsign", "false")
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowModuleFile(module, version string) string {
|
||||||
|
return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit %s\n", module, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowDeliverySettings(baseURL string) *cli.DeliverySettings {
|
||||||
|
return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, name, string(contents)+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagAndPushWorkflowRepository(t *testing.T, repository, tag string) string {
|
||||||
|
t.Helper()
|
||||||
|
cliGitRun(t, repository, "add", "--all")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery")
|
||||||
|
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
cliGitRun(t, repository, "tag", "-a", tag, "-m", "mapped release")
|
||||||
|
remote := filepath.Join(filepath.Dir(repository), "origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+tag)
|
||||||
|
return commit
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiModuleWorkflowEnvironment(t *testing.T, repository, commit, tag, binary, proxy string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
environment := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
environment["CI_COMMIT_TAG"] = tag
|
||||||
|
environment["RELEASEKIT_TEST_BINARY"] = binary
|
||||||
|
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
|
||||||
|
cache := environment["GOMODCACHE"]
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err == nil && entry.IsDir() {
|
||||||
|
_ = os.Chmod(path, 0o700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string) {
|
||||||
|
t.Helper()
|
||||||
|
cache := t.TempDir()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err == nil && entry.IsDir() {
|
||||||
|
_ = os.Chmod(path, 0o700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
command := exec.Command("go", "mod", "download", "gitea.maximumdirect.net/eric/ReleaseKit@v0.2.197")
|
||||||
|
command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||||
|
command.Env = append(os.Environ(), "GOMODCACHE="+cache, "GOPROXY=file://"+filepath.ToSlash(proxy), "GOSUMDB=off", "GOWORK=off", "GOTOOLCHAIN=local", "GOFLAGS=-modcacherw")
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("prepare tagged module sums: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWorkflowFile(t *testing.T, repository, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(contents)
|
||||||
|
}
|
||||||
@@ -47,27 +47,17 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
|||||||
if err := os.WriteFile(notePath, []byte(note), 0o600); err != nil {
|
if err := os.WriteFile(notePath, []byte(note), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ignore, err := os.OpenFile(filepath.Join(repository, ".gitignore"), os.O_APPEND|os.O_WRONLY, 0)
|
cliGitRun(t, repository, "add", "docs/releases/"+version+".md")
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ignore.WriteString("\n/.releasekit/\n"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ignore.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cliGitRun(t, repository, "add", "docs/releases/"+version+".md", ".gitignore")
|
|
||||||
cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery")
|
cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery")
|
||||||
|
|
||||||
state := &deliveryServerState{product: "ReleaseKit"}
|
state := &deliveryServerState{data: deliveryServerData{product: "ReleaseKit"}}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "ReleaseKit",
|
Product: "ReleaseKit",
|
||||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
})
|
})
|
||||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3")
|
cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3")
|
||||||
|
|
||||||
remote := filepath.Join(t.TempDir(), "origin.git")
|
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||||
@@ -78,58 +68,48 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
|||||||
cliGitRun(t, repository, "remote", "set-url", "origin", remote)
|
cliGitRun(t, repository, "remote", "set-url", "origin", remote)
|
||||||
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+version)
|
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+version)
|
||||||
|
|
||||||
tool := filepath.Join(repository, ".releasekit", "bin", "releasekit")
|
|
||||||
template, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
|
template, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
buildBlock, err := extractWoodpeckerCommand(string(template), "build-tool")
|
block, err := extractWoodpeckerCommand(string(template), "deliver")
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
deliveryBlock, err := extractWoodpeckerCommand(string(template), "deliver-page")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
"CI_WORKSPACE": repository,
|
"CI_WORKSPACE": repository,
|
||||||
"CI_COMMIT_SHA": state.commit,
|
"CI_COMMIT_SHA": cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version),
|
||||||
"CI_COMMIT_TAG": version,
|
"CI_COMMIT_TAG": version,
|
||||||
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
||||||
|
"GOTOOLCHAIN": "local",
|
||||||
"GOPROXY": "off",
|
"GOPROXY": "off",
|
||||||
"GOSUMDB": "off",
|
"GOSUMDB": "off",
|
||||||
}
|
}
|
||||||
runWoodpeckerBlock(t, strings.ReplaceAll(buildBlock, "$${", "${"), repository, env)
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != state.commit {
|
|
||||||
t.Fatalf("template checkout = %s, want %s", got, state.commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.remoteCommit = strings.Repeat("f", 40)
|
state.update(func(state *deliveryServerData) { state.remoteCommit = strings.Repeat("f", 40) })
|
||||||
failure := exec.Command(tool, "deliver", "--repo", repository, version)
|
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||||
failure.Dir = repository
|
|
||||||
failure.Env = append(os.Environ(), "RELEASEKIT_GITEA_TOKEN=fixture-token")
|
|
||||||
if output, err := failure.CombinedOutput(); err == nil {
|
|
||||||
t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output)
|
t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output)
|
||||||
}
|
}
|
||||||
if state.releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.commit {
|
if state.snapshot().releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.snapshot().commit {
|
||||||
t.Fatal("delivery failure changed hosted release state or source tag")
|
t.Fatal("delivery failure changed hosted release state or source tag")
|
||||||
}
|
}
|
||||||
state.remoteCommit = ""
|
state.update(func(state *deliveryServerData) { state.remoteCommit = "" })
|
||||||
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
output := runWoodpeckerBlock(t, strings.ReplaceAll(deliveryBlock, "$${", "${"), repository, env)
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
if !strings.Contains(output, "state complete") {
|
if !strings.Contains(output, "state complete") {
|
||||||
t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
|
t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 {
|
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 || state.snapshot().assetListReads != 0 {
|
||||||
t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads)
|
t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads, state.snapshot().assetListReads)
|
||||||
}
|
}
|
||||||
if state.noteBody != note {
|
if state.snapshot().noteBody != note {
|
||||||
t.Fatalf("hosted note body = %q, want complete note %q", state.noteBody, note)
|
t.Fatalf("hosted note body = %q, want complete note %q", state.snapshot().noteBody, note)
|
||||||
}
|
}
|
||||||
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit {
|
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.snapshot().commit {
|
||||||
t.Fatalf("source tag moved after delivery: %s, want %s", got, state.commit)
|
t.Fatalf("source tag moved after delivery: %s, want %s", got, state.snapshot().commit)
|
||||||
}
|
}
|
||||||
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
|
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
|
||||||
t.Fatalf("tagged checkout became dirty after delivery: %s", status)
|
t.Fatalf("tagged checkout became dirty after delivery: %s", status)
|
||||||
|
|||||||
34
cmd/releasekit/repository_test.go
Normal file
34
cmd/releasekit/repository_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeCheckFromChildUsesRootProfileAndSuite(t *testing.T) {
|
||||||
|
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) { t.Fatal(\"root suite failure\") }\n")
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", `{"product":"Example Product"}`)
|
||||||
|
writeRepositoryFile(t, repository, "apps/widget/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
|
||||||
|
writeRepositoryFile(t, repository, "apps/widget/widget_test.go", "package widget\nimport \"testing\"\nfunc TestWidget(t *testing.T) {}\n")
|
||||||
|
cliGitRun(t, repository, "add", ".")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "Add failing root and passing child")
|
||||||
|
command := exec.Command(binary, "check", "v1.2.3")
|
||||||
|
command.Dir = filepath.Join(repository, "apps", "widget")
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 || !strings.Contains(string(output), "root suite failure") || !strings.Contains(string(output), "module=example.com/cli-candidate") {
|
||||||
|
t.Fatalf("child invocation error = %v, output = %s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeBuildClassifiesInvalidVersionBeforeSourceAccess(t *testing.T) {
|
||||||
|
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
|
||||||
|
command := exec.Command(binary, "build", "--repo", filepath.Join(t.TempDir(), "absent"), "bad-version")
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 || !strings.Contains(string(output), "stable vMAJOR.MINOR.PATCH") {
|
||||||
|
t.Fatalf("invalid build error = %v, output = %s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
@@ -19,30 +25,15 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
pin, err := woodpeckerMatrixValue(string(contents), "RELEASEKIT_VERSION")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
command := strings.ReplaceAll(block, "${RELEASEKIT_VERSION}", pin)
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
command = strings.ReplaceAll(command, "$${", "${")
|
|
||||||
|
|
||||||
executable := filepath.Join(t.TempDir(), "releasekit")
|
|
||||||
build := exec.Command("go", "build", "-o", executable, "./cmd/releasekit")
|
|
||||||
build.Dir = root
|
|
||||||
if output, err := build.CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("build local ReleaseKit executable: %v\n%s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
module := "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@" + pin
|
|
||||||
t.Run("source-only", func(t *testing.T) {
|
t.Run("source-only", func(t *testing.T) {
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire example.com/unavailable/module v1.2.3\n")
|
selectReleaseKitModule(t, repository, root, false)
|
||||||
cliGitRun(t, repository, "add", "go.mod")
|
|
||||||
cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency")
|
|
||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -51,26 +42,24 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
})
|
})
|
||||||
commit := pushTaggedCLIRepository(t, repository)
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
state.commit = commit
|
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||||
|
|
||||||
shimDir, goLog := installGoBootstrapShim(t)
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog)
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
output := runWoodpeckerBlock(t, command, repository, env)
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
if !strings.Contains(output, "state complete") {
|
if !strings.Contains(output, "state complete") {
|
||||||
t.Fatalf("delivery attempt %d output=%q", attempt+1, output)
|
t.Fatalf("delivery output=%q", output)
|
||||||
}
|
}
|
||||||
|
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||||
|
t.Fatalf("source-only calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
|
||||||
}
|
}
|
||||||
if state.releaseCreates != 1 || state.assetUploads != 0 {
|
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||||
t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
|
||||||
}
|
|
||||||
if log, err := os.ReadFile(goLog); err != nil || strings.Contains(string(log), "mod download") {
|
|
||||||
t.Fatalf("source-only command downloaded application dependencies: log=%q error=%v", log, err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("binary", func(t *testing.T) {
|
t.Run("binary", func(t *testing.T) {
|
||||||
repository := newDeliveryBuildRepository(t)
|
repository := newDeliveryBuildRepository(t)
|
||||||
|
selectReleaseKitModule(t, repository, root, true)
|
||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -84,10 +73,12 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
})
|
})
|
||||||
commit := pushTaggedCLIRepository(t, repository)
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
state.commit = commit
|
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||||
|
|
||||||
shimDir, goLog := installGoBootstrapShim(t)
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog)
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
output := runWoodpeckerBlock(t, command, repository, env)
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
if !strings.Contains(output, "state complete") {
|
if !strings.Contains(output, "state complete") {
|
||||||
@@ -97,21 +88,266 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
t.Fatalf("second delivery did not reuse matching assets: %q", output)
|
t.Fatalf("second delivery did not reuse matching assets: %q", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if state.releaseCreates != 1 || state.assetUploads != 2 {
|
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||||
t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
t.Fatalf("binary calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
|
||||||
}
|
}
|
||||||
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||||
t.Fatalf("binary asset was not delivered: %#v", state.assets)
|
t.Fatalf("binary asset was not delivered: %#v", state.snapshot().assets)
|
||||||
}
|
}
|
||||||
if _, ok := state.assets["SHA256SUMS"]; !ok {
|
if _, ok := state.snapshot().assets["SHA256SUMS"]; !ok {
|
||||||
t.Fatalf("checksum manifest was not delivered: %#v", state.assets)
|
t.Fatalf("checksum manifest was not delivered: %#v", state.snapshot().assets)
|
||||||
}
|
}
|
||||||
if log, err := os.ReadFile(goLog); err != nil || !strings.Contains(string(log), "mod download") {
|
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
|
||||||
t.Fatalf("binary command did not prepare application dependencies: log=%q error=%v", log, err)
|
})
|
||||||
|
|
||||||
|
t.Run("wrong tag stops before Go", func(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
selectReleaseKitModule(t, repository, root, false)
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "unrelated event commit")
|
||||||
|
unrelated := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||||
|
env := templateEnvironment(repository, unrelated, goDir, goLog)
|
||||||
|
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||||
|
t.Fatalf("wrong tag succeeded: %s", output)
|
||||||
|
} else if !strings.Contains(output, commit) || !strings.Contains(output, unrelated) {
|
||||||
|
t.Fatalf("tag mismatch omitted commit identities: %s", output)
|
||||||
|
}
|
||||||
|
assertNoCommands(t, goLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dirty module stops before Go", func(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
selectReleaseKitModule(t, repository, root, false)
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n")
|
||||||
|
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||||
|
t.Fatalf("dirty module succeeded: %s", output)
|
||||||
|
}
|
||||||
|
assertNoCommands(t, goLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("selected executable receives event arguments and its status", func(t *testing.T) {
|
||||||
|
for _, event := range []struct {
|
||||||
|
name, tag string
|
||||||
|
tagObject bool
|
||||||
|
}{
|
||||||
|
{"lightweight commit", "v1.2.3", false},
|
||||||
|
{"annotated commit", "v1.2.4", false},
|
||||||
|
{"annotated tag object", "v1.2.4", true},
|
||||||
|
} {
|
||||||
|
t.Run(event.name, func(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
selectReleaseKitModule(t, repository, root, false)
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
if event.tag == "v1.2.4" {
|
||||||
|
cliGitRun(t, repository, "tag", "-a", event.tag, "-m", "annotated release")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/"+event.tag)
|
||||||
|
}
|
||||||
|
eventID := commit
|
||||||
|
if event.tagObject {
|
||||||
|
eventID = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+event.tag)
|
||||||
|
}
|
||||||
|
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||||
|
toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
|
||||||
|
env := templateEnvironment(repository, strings.ToUpper(eventID), goDir, goLog)
|
||||||
|
env["CI_COMMIT_TAG"] = event.tag
|
||||||
|
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
|
||||||
|
env["RELEASEKIT_TOOL_EXIT"] = "37"
|
||||||
|
output, err := executeWoodpeckerBlock(command, repository, env)
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 37 {
|
||||||
|
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output)
|
||||||
|
}
|
||||||
|
args := readWoodpeckerFixtureFile(t, toolArgs)
|
||||||
|
for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--prepared>", "<" + event.tag + ">"} {
|
||||||
|
if !strings.Contains(args, expected) {
|
||||||
|
t.Errorf("selected executable arguments %q omit %q", args, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(args), "<--commit>\n<"+commit+">") {
|
||||||
|
t.Errorf("selected executable did not receive source commit %s: %q", commit, args)
|
||||||
|
}
|
||||||
|
assertGoSelection(t, repository, goLog)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
|
proxy := writeReleaseKitModuleProxy(t)
|
||||||
|
|
||||||
|
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
|
||||||
|
oldCommit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.198\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "upgrade the default branch tool")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", filepath.Join(filepath.Dir(repository), "origin.git"))
|
||||||
|
remote := filepath.Join(filepath.Dir(repository), "origin.git")
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/heads/main", "refs/tags/v1.2.3")
|
||||||
|
cliGitRun(t, repository, "checkout", "--detach", oldCommit)
|
||||||
|
if mainModule := cliGitOutput(t, repository, "show", "main:go.mod"); !strings.Contains(mainModule, module+" v0.2.198") {
|
||||||
|
t.Fatalf("default branch module = %q, want upgraded tool selection", mainModule)
|
||||||
|
}
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
commit := oldCommit
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
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)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "helper=tool-only dependency") || strings.Contains(output, "tool-version=v0.2.198") {
|
||||||
|
t.Fatalf("retry selected the wrong tool version: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||||
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "select a versioned tool replacement")
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
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)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.198") || !strings.Contains(output, "helper=tool-only dependency") {
|
||||||
|
t.Fatalf("versioned replacement was not selected: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||||
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
|
||||||
|
root := cliRepositoryRoot(t)
|
||||||
|
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const dependency = "example.com/releasekit-helper"
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
writeModuleProxyVersion(t, proxyDir, dependency, "v1.0.0", "module "+dependency+"\n\ngo 1.25.0\n", map[string]string{
|
||||||
|
"helper.go": "package helper\n\nconst Message = \"prepared tool dependency\"\n",
|
||||||
|
})
|
||||||
|
var proxyRequests atomic.Int64
|
||||||
|
files := http.FileServer(http.Dir(proxyDir))
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyRequests.Add(1)
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
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{}
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
t.Fatalf("wrong commit unexpectedly bootstrapped the tool: %s", output)
|
||||||
|
}
|
||||||
|
assertNoCommands(t, goLog)
|
||||||
|
if proxyRequests.Load() != 0 {
|
||||||
|
t.Fatal("wrong commit reached tool dependency preparation")
|
||||||
|
}
|
||||||
|
env["CI_COMMIT_SHA"] = commit
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "self-bootstrap prepared tool dependency") || !strings.Contains(output, `"`+commit+`"`) {
|
||||||
|
t.Fatalf("self-bootstrap output = %q", output)
|
||||||
|
}
|
||||||
|
if proxyRequests.Load() == 0 {
|
||||||
|
t.Fatal("self-bootstrap did not prepare its dependency from the local proxy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, `"ci-deliver"`) || !strings.Contains(output, `"`+repository+`"`) || !strings.Contains(output, `"--prepared"`) || !strings.Contains(output, `"v1.2.3"`) {
|
||||||
|
t.Fatalf("self-bootstrapped command arguments = %q", output)
|
||||||
|
}
|
||||||
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") {
|
||||||
|
t.Fatalf("self-bootstrap Go command = %q", log)
|
||||||
|
}
|
||||||
|
var executable string
|
||||||
|
for index, field := range strings.Fields(log) {
|
||||||
|
if field == "-o" && index+1 < len(strings.Fields(log)) {
|
||||||
|
executable = strings.Fields(log)[index+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if executable == "" {
|
||||||
|
t.Fatalf("self-bootstrap output path missing from Go command: %q", log)
|
||||||
|
}
|
||||||
|
if relative, err := filepath.Rel(repository, executable); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
|
||||||
|
t.Fatalf("self-bootstrap executable %q is not outside source %q: relative=%q error=%v", executable, repository, relative, err)
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("self-bootstrap changed the tagged source: %q", status)
|
||||||
|
}
|
||||||
|
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
|
||||||
|
t.Fatalf("self-bootstrap checkout branch = %q, want detached HEAD", branch)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-source.") {
|
||||||
|
t.Fatalf("self-bootstrap temporary directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cliRepositoryRoot(t *testing.T) string {
|
func cliRepositoryRoot(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, sourceFile, _, ok := runtime.Caller(0)
|
_, sourceFile, _, ok := runtime.Caller(0)
|
||||||
@@ -156,34 +392,22 @@ func extractWoodpeckerCommand(contents, stepName string) (string, error) {
|
|||||||
return "", fmt.Errorf("step %q was not found", stepName)
|
return "", fmt.Errorf("step %q was not found", stepName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func woodpeckerMatrixValue(contents, key string) (string, error) {
|
|
||||||
for _, line := range strings.Split(contents, "\n") {
|
|
||||||
field, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
|
||||||
if ok && field == key {
|
|
||||||
return strings.TrimSpace(value), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("matrix value %q was not found", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func installGoBootstrapShim(t *testing.T) (string, string) {
|
func installGoBootstrapShim(t *testing.T) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
shimDir := t.TempDir()
|
shimDir := t.TempDir()
|
||||||
goLog := filepath.Join(shimDir, "go-commands")
|
goLog := filepath.Join(shimDir, "go-commands")
|
||||||
shim := `#!/bin/sh
|
shim := `#!/bin/sh
|
||||||
if [ "$1" = "run" ]; then
|
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
shift
|
"$RELEASEKIT_REAL_GO" "$@"
|
||||||
[ "$1" = "$RELEASEKIT_EXPECTED_MODULE" ] || { echo "unexpected module: $1" >&2; exit 90; }
|
status=$?
|
||||||
shift
|
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
|
||||||
[ "$1" = "ci-deliver" ] || { echo "unexpected command: $1" >&2; exit 91; }
|
modfile=
|
||||||
[ "$2" = "--repo" ] && [ "$3" = "$CI_WORKSPACE" ] || { echo "unexpected repository arguments" >&2; exit 92; }
|
for argument in "$@"; do
|
||||||
[ "$4" = "--commit" ] && [ "$5" = "$CI_COMMIT_SHA" ] || { echo "unexpected commit arguments" >&2; exit 93; }
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
[ "$6" = "$CI_COMMIT_TAG" ] && [ "$#" = 6 ] || { echo "unexpected version argument" >&2; exit 94; }
|
done
|
||||||
printf '%s\n' "run $RELEASEKIT_EXPECTED_MODULE" >> "$RELEASEKIT_GO_LOG"
|
cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG"
|
||||||
exec "$RELEASEKIT_LOCAL_EXECUTABLE" "$@"
|
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
|
exit "$status"
|
||||||
exec "$RELEASEKIT_REAL_GO" "$@"
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -191,15 +415,72 @@ exec "$RELEASEKIT_REAL_GO" "$@"
|
|||||||
return shimDir, goLog
|
return shimDir, goLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func templateEnvironment(repository, commit, executable, module, shimDir, goLog string) map[string]string {
|
func installControlledGoBootstrapShim(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
shimDir := t.TempDir()
|
||||||
|
goLog := filepath.Join(shimDir, "go-commands")
|
||||||
|
shim := `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
|
modfile=
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$modfile" ] && [ -f "$modfile" ] || { echo 'alternate module file is missing' >&2; exit 89; }
|
||||||
|
[ -f "${modfile%.mod}.sum" ] || { echo 'alternate checksum file is missing' >&2; exit 88; }
|
||||||
|
grep -F "$RELEASEKIT_MODULE v0.2.0" "$modfile" >/dev/null || { echo 'module selection was not copied' >&2; exit 87; }
|
||||||
|
case "$1" in
|
||||||
|
list)
|
||||||
|
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
output=
|
||||||
|
previous=
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [ "$previous" = "-o" ]; then output=$argument; break; fi
|
||||||
|
previous=$argument
|
||||||
|
done
|
||||||
|
[ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; }
|
||||||
|
cat > "$output" <<'TOOL'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
|
||||||
|
exit "${RELEASEKIT_TOOL_EXIT:-0}"
|
||||||
|
TOOL
|
||||||
|
chmod +x "$output"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unexpected Go command: $1" >&2
|
||||||
|
exit 91
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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")
|
realGo, _ := exec.LookPath("go")
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"CI_WORKSPACE": repository,
|
"CI_WORKSPACE": repository,
|
||||||
"CI_COMMIT_SHA": commit,
|
"CI_COMMIT_SHA": commit,
|
||||||
"CI_COMMIT_TAG": "v1.2.3",
|
"CI_COMMIT_TAG": "v1.2.3",
|
||||||
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
||||||
"RELEASEKIT_EXPECTED_MODULE": module,
|
"RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit",
|
||||||
"RELEASEKIT_LOCAL_EXECUTABLE": executable,
|
|
||||||
"RELEASEKIT_REAL_GO": realGo,
|
"RELEASEKIT_REAL_GO": realGo,
|
||||||
"RELEASEKIT_GO_LOG": goLog,
|
"RELEASEKIT_GO_LOG": goLog,
|
||||||
"GOTOOLCHAIN": "local",
|
"GOTOOLCHAIN": "local",
|
||||||
@@ -212,6 +493,14 @@ func templateEnvironment(repository, commit, executable, module, shimDir, goLog
|
|||||||
|
|
||||||
func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string {
|
func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
output, err := executeWoodpeckerBlock(block, repository, environment)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeWoodpeckerBlock(block, repository string, environment map[string]string) (string, error) {
|
||||||
command := exec.Command("sh", "-e", "-c", block)
|
command := exec.Command("sh", "-e", "-c", block)
|
||||||
command.Dir = repository
|
command.Dir = repository
|
||||||
command.Env = os.Environ()
|
command.Env = os.Environ()
|
||||||
@@ -219,10 +508,183 @@ func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[
|
|||||||
command.Env = setEnvironment(command.Env, key, value)
|
command.Env = setEnvironment(command.Env, key, value)
|
||||||
}
|
}
|
||||||
output, err := command.CombinedOutput()
|
output, err := command.CombinedOutput()
|
||||||
if err != nil {
|
return string(output), err
|
||||||
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output)
|
}
|
||||||
|
|
||||||
|
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.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 {
|
||||||
|
writeRepositoryFile(t, repository, "go.sum", "")
|
||||||
|
cliGitRun(t, repository, "add", "go.sum")
|
||||||
}
|
}
|
||||||
return string(output)
|
cliGitRun(t, repository, "commit", "-m", "select ReleaseKit from module configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReleaseKitModuleProxy(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
const toolDependency = "example.com/releasekit-helper"
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
||||||
|
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
||||||
|
files := map[string]string{
|
||||||
|
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n",
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, toolDependency, "v1.0.0", "module "+toolDependency+"\n\ngo 1.25.0\n", map[string]string{"tooldep.go": "package tooldep\n\nconst Version = \"tool-only dependency\"\n"})
|
||||||
|
return proxyDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeModuleProxyVersion(t *testing.T, proxyDir, module, version, moduleFile string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
var escaped strings.Builder
|
||||||
|
for _, character := range module {
|
||||||
|
if character >= 'A' && character <= 'Z' {
|
||||||
|
escaped.WriteByte('!')
|
||||||
|
character += 'a' - 'A'
|
||||||
|
}
|
||||||
|
escaped.WriteRune(character)
|
||||||
|
}
|
||||||
|
moduleVersions := filepath.Join(proxyDir, filepath.FromSlash(escaped.String()), "@v")
|
||||||
|
if err := os.MkdirAll(moduleVersions, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(moduleVersions, "list")); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, "list"), []byte(version+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listPath := filepath.Join(moduleVersions, "list")
|
||||||
|
list := readWoodpeckerFixtureFile(t, listPath)
|
||||||
|
if !strings.Contains(list, version+"\n") {
|
||||||
|
if err := os.WriteFile(listPath, []byte(list+version+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, version+".mod"), []byte(moduleFile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := fmt.Sprintf("{\"Version\":%q,\"Time\":\"2026-10-01T00:00:00Z\"}\n", version)
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, version+".info"), []byte(info), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive, err := os.Create(filepath.Join(moduleVersions, version+".zip"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := zip.NewWriter(archive)
|
||||||
|
prefix := module + "@" + version + "/"
|
||||||
|
files["go.mod"] = moduleFile
|
||||||
|
for name, contents := range files {
|
||||||
|
file, err := writer.Create(prefix + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(contents)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string, sumBefore ...string) {
|
||||||
|
t.Helper()
|
||||||
|
assertGoSelection(t, repository, goLog)
|
||||||
|
if got := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")); got != moduleBefore {
|
||||||
|
t.Fatalf("source go.mod changed during bootstrap: got %q, want %q", got, moduleBefore)
|
||||||
|
}
|
||||||
|
sumPath := filepath.Join(repository, "go.sum")
|
||||||
|
if len(sumBefore) == 0 {
|
||||||
|
if _, err := os.Stat(sumPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("source go.sum state changed: stat error = %v", err)
|
||||||
|
}
|
||||||
|
} else if got := readWoodpeckerFixtureFile(t, sumPath); got != sumBefore[0] {
|
||||||
|
t.Fatalf("source go.sum changed during bootstrap: got %q, want %q", got, sumBefore[0])
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("bootstrap changed tagged source: %q", status)
|
||||||
|
}
|
||||||
|
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
|
||||||
|
t.Fatalf("checkout branch = %q, want detached HEAD", branch)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGoSelection(t *testing.T, repository, goLog string) {
|
||||||
|
t.Helper()
|
||||||
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
|
list := "list -mod=mod -modfile="
|
||||||
|
build := "build -mod=mod -modfile="
|
||||||
|
module := "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
||||||
|
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
||||||
|
}
|
||||||
|
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") {
|
||||||
|
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log)
|
||||||
|
}
|
||||||
|
if strings.Index(log, list) > strings.Index(log, build) {
|
||||||
|
t.Fatalf("Go build ran before module selection: %q", log)
|
||||||
|
}
|
||||||
|
var modfile string
|
||||||
|
for _, field := range strings.Fields(log) {
|
||||||
|
if strings.HasPrefix(field, "-modfile=") {
|
||||||
|
modfile = strings.TrimPrefix(field, "-modfile=")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modfile == "" {
|
||||||
|
t.Fatalf("Go commands did not receive an alternate module file: %q", log)
|
||||||
|
}
|
||||||
|
if relative, err := filepath.Rel(repository, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
|
||||||
|
t.Fatalf("alternate module file %q is not outside source %q: relative=%q error=%v", modfile, repository, relative, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoCommands(t *testing.T, goLog string) {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(goLog)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(contents) != 0 {
|
||||||
|
t.Fatalf("Go ran before tag/source verification: %q", contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertToolOnlyChecksums(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
sums := readWoodpeckerFixtureFile(t, path)
|
||||||
|
if !strings.Contains(sums, "example.com/releasekit-helper v1.0.0 h1:") {
|
||||||
|
t.Fatalf("temporary module sum file omitted the tool-only dependency checksum: %q", sums)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWoodpeckerFixtureFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEnvironment(environment []string, key, value string) []string {
|
func setEnvironment(environment []string, key, value string) []string {
|
||||||
|
|||||||
23
delivery.go
23
delivery.go
@@ -45,9 +45,15 @@ type DeliveryAsset struct {
|
|||||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
// page-only delivery when both are nil. Build with no targets, or an empty
|
||||||
// Prebuilt result, also performs page-only delivery.
|
// Prebuilt result, also performs page-only delivery.
|
||||||
type DeliveryRequest struct {
|
type DeliveryRequest struct {
|
||||||
RepositoryDir string
|
RepositoryDir string // directory within the tagged source Git checkout.
|
||||||
|
// ModuleDir selects a repository-relative module directory; empty selects
|
||||||
|
// the repository root.
|
||||||
|
ModuleDir string
|
||||||
Product string
|
Product string
|
||||||
Version string
|
Version string
|
||||||
|
// ExpectedTag optionally requires the full CI event tag to match the
|
||||||
|
// selected module's derived tag before building or making hosted requests.
|
||||||
|
ExpectedTag string
|
||||||
Destination GiteaDestination
|
Destination GiteaDestination
|
||||||
// Build selects binaries to build in temporary storage outside the checkout.
|
// Build selects binaries to build in temporary storage outside the checkout.
|
||||||
Build *BuildSpec
|
Build *BuildSpec
|
||||||
@@ -62,7 +68,11 @@ type DeliveryRequest struct {
|
|||||||
// current observations: an asset appears in at most one slice, and early
|
// current observations: an asset appears in at most one slice, and early
|
||||||
// failures may leave some declared assets unreported.
|
// failures may leave some declared assets unreported.
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
Version string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
|
ModulePath string
|
||||||
|
Tag string // complete derived Git tag.
|
||||||
|
Version string // selected stable semantic version.
|
||||||
CandidateCommit string
|
CandidateCommit string
|
||||||
ReleaseURL string
|
ReleaseURL string
|
||||||
State DeliveryState
|
State DeliveryState
|
||||||
@@ -83,6 +93,9 @@ type DeliveryResult struct {
|
|||||||
// requests are compiled into temporary storage that Deliver removes on return.
|
// requests are compiled into temporary storage that Deliver removes on return.
|
||||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
||||||
// validate source identity and artifact contents before hosted writes.
|
// validate source identity and artifact contents before hosted writes.
|
||||||
|
// Module selection and declaration links resolve from the captured commit.
|
||||||
|
// Page-only delivery can use a sparse checkout with available committed objects;
|
||||||
|
// managed builds require their selected source files in the working tree.
|
||||||
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
||||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||||
destination := gitea.Destination{
|
destination := gitea.Destination{
|
||||||
@@ -95,13 +108,19 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
|||||||
}
|
}
|
||||||
internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
|
internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
|
||||||
RepositoryDir: request.RepositoryDir,
|
RepositoryDir: request.RepositoryDir,
|
||||||
|
ModuleDir: request.ModuleDir,
|
||||||
Product: request.Product,
|
Product: request.Product,
|
||||||
Version: request.Version,
|
Version: request.Version,
|
||||||
|
ExpectedTag: request.ExpectedTag,
|
||||||
Destination: destination,
|
Destination: destination,
|
||||||
Build: internalBuildSpec(request.Build),
|
Build: internalBuildSpec(request.Build),
|
||||||
Prebuilt: prebuilt,
|
Prebuilt: prebuilt,
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
})
|
})
|
||||||
|
result.RepositoryDir = internalResult.RepositoryDir
|
||||||
|
result.ModuleDir = internalResult.ModuleDir
|
||||||
|
result.ModulePath = internalResult.ModulePath
|
||||||
|
result.Tag = internalResult.Tag
|
||||||
result.CandidateCommit = internalResult.CandidateCommit
|
result.CandidateCommit = internalResult.CandidateCommit
|
||||||
result.ReleaseURL = internalResult.ReleaseURL
|
result.ReleaseURL = internalResult.ReleaseURL
|
||||||
result.State = DeliveryState(internalResult.State)
|
result.State = DeliveryState(internalResult.State)
|
||||||
|
|||||||
67
delivery_reads_linux_test.go
Normal file
67
delivery_reads_linux_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package releasekit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
||||||
|
repository, build, expected := newDeliveryBenchmarkFixture(t)
|
||||||
|
watcher, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer syscall.Close(watcher)
|
||||||
|
// Close events separate successive opens so inotify cannot coalesce them.
|
||||||
|
if _, err := syscall.InotifyAddWatch(watcher, build.Artifacts[0].Path, syscall.IN_OPEN|syscall.IN_CLOSE_NOWRITE); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
|
||||||
|
state.reset(false)
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
||||||
|
state.baseURL = "http://" + server.Listener.Addr().String()
|
||||||
|
server.Start()
|
||||||
|
defer server.Close()
|
||||||
|
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
|
||||||
|
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
|
||||||
|
})
|
||||||
|
if err != nil || result.State != releasekit.DeliveryComplete || len(result.Uploaded) != len(expected) {
|
||||||
|
t.Fatalf("delivery = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
state.verifyUploads(t)
|
||||||
|
opens := 0
|
||||||
|
var events [256]byte
|
||||||
|
for {
|
||||||
|
n, err := syscall.Read(watcher, events[:])
|
||||||
|
if errors.Is(err, syscall.EAGAIN) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if errors.Is(err, syscall.EINTR) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for offset := 0; offset < n; {
|
||||||
|
mask := binary.NativeEndian.Uint32(events[offset+4:])
|
||||||
|
if mask&syscall.IN_Q_OVERFLOW != 0 {
|
||||||
|
t.Fatal("artifact event queue overflowed")
|
||||||
|
}
|
||||||
|
if mask&syscall.IN_OPEN != 0 {
|
||||||
|
opens++
|
||||||
|
}
|
||||||
|
nameLength := binary.NativeEndian.Uint32(events[offset+12:])
|
||||||
|
offset += syscall.SizeofInotifyEvent + int(nameLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opens != 2 {
|
||||||
|
t.Fatalf("local binary opened %d times; want one verification read and one upload read", opens)
|
||||||
|
}
|
||||||
|
}
|
||||||
309
delivery_test.go
309
delivery_test.go
@@ -8,10 +8,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
@@ -40,11 +42,12 @@ func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
||||||
}
|
}
|
||||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit {
|
observed := remote.snapshot()
|
||||||
|
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
|
||||||
t.Fatalf("delivery result = %#v", result)
|
t.Fatalf("delivery result = %#v", result)
|
||||||
}
|
}
|
||||||
if len(result.Uploaded) != 2 || len(remote.assets) != 2 {
|
if len(result.Uploaded) != 2 || len(observed.assets) != 2 {
|
||||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets))
|
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
|
||||||
}
|
}
|
||||||
if !strings.Contains(progress.String(), "checking fixture version output") {
|
if !strings.Contains(progress.String(), "checking fixture version output") {
|
||||||
t.Fatalf("build progress = %q", progress.String())
|
t.Fatalf("build progress = %q", progress.String())
|
||||||
@@ -78,7 +81,8 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
||||||
}
|
}
|
||||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit {
|
observed := remote.snapshot()
|
||||||
|
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
|
||||||
t.Fatalf("delivery result = %#v", result)
|
t.Fatalf("delivery result = %#v", result)
|
||||||
}
|
}
|
||||||
wantAssets := []string{
|
wantAssets := []string{
|
||||||
@@ -86,12 +90,12 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
|
|||||||
"fixture-v1.2.3-linux-arm64",
|
"fixture-v1.2.3-linux-arm64",
|
||||||
"SHA256SUMS",
|
"SHA256SUMS",
|
||||||
}
|
}
|
||||||
if len(result.Uploaded) != len(wantAssets) || len(remote.assets) != len(wantAssets) {
|
if len(result.Uploaded) != len(wantAssets) || len(observed.assets) != len(wantAssets) {
|
||||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets))
|
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
|
||||||
}
|
}
|
||||||
for _, name := range wantAssets {
|
for _, name := range wantAssets {
|
||||||
if _, ok := remote.assets[name]; !ok {
|
if _, ok := observed.assets[name]; !ok {
|
||||||
t.Errorf("hosted assets omit %q: %v", name, mapKeys(remote.assets))
|
t.Errorf("hosted assets omit %q: %v", name, mapKeys(observed.assets))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(progress.String(), "checking fixture version output") {
|
if !strings.Contains(progress.String(), "checking fixture version output") {
|
||||||
@@ -134,6 +138,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.snapshot().requests != 0 {
|
||||||
|
t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -146,6 +194,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
prebuilt := test.prebuilt
|
||||||
|
if prebuilt != nil {
|
||||||
|
built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() for page-only prebuilt identity = %#v, %v", built, err)
|
||||||
|
}
|
||||||
|
prebuilt = &built
|
||||||
|
}
|
||||||
temporaryRoot := t.TempDir()
|
temporaryRoot := t.TempDir()
|
||||||
t.Setenv("TMPDIR", temporaryRoot)
|
t.Setenv("TMPDIR", temporaryRoot)
|
||||||
server, remote := newFacadeDeliveryServer(t, repository)
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
@@ -158,14 +214,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
|||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
Destination: facadeDeliveryDestination(server.URL),
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
Build: test.build,
|
Build: test.build,
|
||||||
Prebuilt: test.prebuilt,
|
Prebuilt: prebuilt,
|
||||||
Output: &progress,
|
Output: &progress,
|
||||||
})
|
})
|
||||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||||
t.Fatalf("Deliver() = %#v, %v", result, err)
|
t.Fatalf("Deliver() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if len(remote.assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
|
if len(remote.snapshot().assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
|
||||||
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.assets))
|
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.snapshot().assets))
|
||||||
}
|
}
|
||||||
if progress.Len() != 0 {
|
if progress.Len() != 0 {
|
||||||
t.Fatalf("page-only delivery emitted build progress: %q", progress.String())
|
t.Fatalf("page-only delivery emitted build progress: %q", progress.String())
|
||||||
@@ -177,6 +233,108 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||||
|
repository := newTwoModuleDeliveryConsumer(t)
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
wrongTag, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Fixture",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
ExpectedTag: "integrations/wrong/v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
})
|
||||||
|
if err == nil || wrongTag.Tag != "v1.2.3" || remote.snapshot().requests != 0 {
|
||||||
|
t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.snapshot().requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Fixture",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
ExpectedTag: "v1.2.3",
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
})
|
||||||
|
if err != nil || rootResult.State != releasekit.DeliveryComplete || rootResult.Tag != "v1.2.3" {
|
||||||
|
t.Fatalf("root delivery = %#v, %v", rootResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedTag := "integrations/ckconfig/v1.2.3"
|
||||||
|
remote.update(func(remote *facadeDeliveryData) {
|
||||||
|
remote.tag = nestedTag
|
||||||
|
remote.noteBody = "# Fixture v1.2.3\n\nNested delivery fixture.\n"
|
||||||
|
})
|
||||||
|
nestedResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: "integrations/ckconfig",
|
||||||
|
Product: "Fixture",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
ExpectedTag: nestedTag,
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
})
|
||||||
|
if err != nil || nestedResult.State != releasekit.DeliveryComplete || nestedResult.Tag != nestedTag || nestedResult.ModulePath != "example.com/ckconfig" {
|
||||||
|
t.Fatalf("nested delivery = %#v, %v", nestedResult, err)
|
||||||
|
}
|
||||||
|
if len(remote.snapshot().pages) != 2 || !remote.snapshot().pages["v1.2.3"] || !remote.snapshot().pages[nestedTag] {
|
||||||
|
t.Fatalf("created hosted pages = %#v", remote.snapshot().pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests(t *testing.T) {
|
||||||
|
repository := newTwoModuleDeliveryConsumer(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(), "root-artifacts"),
|
||||||
|
})
|
||||||
|
if err != nil || !prebuilt.SourceVerified {
|
||||||
|
t.Fatalf("root Build() = %#v, %v", prebuilt, err)
|
||||||
|
}
|
||||||
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
|
defer server.Close()
|
||||||
|
remote.update(func(remote *facadeDeliveryData) { remote.tag = "integrations/ckconfig/v1.2.3" })
|
||||||
|
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: "integrations/ckconfig",
|
||||||
|
Product: "Fixture",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
ExpectedTag: remote.snapshot().tag,
|
||||||
|
Destination: facadeDeliveryDestination(server.URL),
|
||||||
|
Prebuilt: &prebuilt,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.snapshot().tag {
|
||||||
|
t.Fatalf("nested delivery with root artifacts = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if remote.snapshot().requests != 0 {
|
||||||
|
t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.snapshot().requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTwoModuleDeliveryConsumer(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
writeNestedBuildConsumer(t, repository, "integrations/ckconfig", "example.com/ckconfig", `package main
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||||
|
)
|
||||||
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
||||||
|
`)
|
||||||
|
writeBuildFixture(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Fixture v1.2.3\n\nNested delivery fixture.\n")
|
||||||
|
buildGit(t, repository, "add", "--all")
|
||||||
|
buildGit(t, repository, "commit", "-m", "add nested release module")
|
||||||
|
buildGit(t, repository, "tag", "-f", "v1.2.3")
|
||||||
|
buildGit(t, repository, "tag", "integrations/ckconfig/v1.2.3")
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) {
|
func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
writeBuildFixture(t, repository, "local-change.txt", "dirty source")
|
writeBuildFixture(t, repository, "local-change.txt", "dirty source")
|
||||||
@@ -198,19 +356,20 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T)
|
|||||||
},
|
},
|
||||||
Output: &progress,
|
Output: &progress,
|
||||||
})
|
})
|
||||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err)
|
||||||
}
|
}
|
||||||
if progress.Len() != 0 {
|
if progress.Len() != 0 {
|
||||||
t.Fatalf("build started before source eligibility passed: %q", progress.String())
|
t.Fatalf("build started before source eligibility passed: %q", progress.String())
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
|
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
|
tagCommit := buildGit(t, repository, "rev-parse", "HEAD")
|
||||||
writeBuildFixture(t, repository, "later-change.txt", "new commit")
|
writeBuildFixture(t, repository, "later-change.txt", "new commit")
|
||||||
buildGit(t, repository, "add", "--all")
|
buildGit(t, repository, "add", "--all")
|
||||||
buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
|
buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
|
||||||
@@ -232,14 +391,14 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T
|
|||||||
},
|
},
|
||||||
Output: &progress,
|
Output: &progress,
|
||||||
})
|
})
|
||||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == remote.commit {
|
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == tagCommit {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err)
|
||||||
}
|
}
|
||||||
if progress.Len() != 0 {
|
if progress.Len() != 0 {
|
||||||
t.Fatalf("build started before tag/HEAD agreement: %q", progress.String())
|
t.Fatalf("build started before tag/HEAD agreement: %q", progress.String())
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,14 +422,14 @@ func TestDeliverRejectsInvalidCommittedNoteBeforeBuildOrHostedWrites(t *testing.
|
|||||||
},
|
},
|
||||||
Output: &progress,
|
Output: &progress,
|
||||||
})
|
})
|
||||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err)
|
||||||
}
|
}
|
||||||
if progress.Len() != 0 {
|
if progress.Len() != 0 {
|
||||||
t.Fatalf("build started before committed-note validation: %q", progress.String())
|
t.Fatalf("build started before committed-note validation: %q", progress.String())
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,8 +463,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing
|
|||||||
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
||||||
@@ -330,11 +489,11 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te
|
|||||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
||||||
@@ -366,8 +525,8 @@ func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) {
|
|||||||
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
||||||
@@ -390,8 +549,8 @@ func TestDeliverRejectsBothBuildSourcesBeforeHostedWrites(t *testing.T) {
|
|||||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") {
|
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err)
|
||||||
}
|
}
|
||||||
if remote.requests != 0 {
|
if remote.snapshot().requests != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,9 +587,41 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type facadeDeliveryServer struct {
|
type facadeDeliveryServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data facadeDeliveryData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *facadeDeliveryServer) update(change func(*facadeDeliveryData)) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
change(&state.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
result := state.data
|
||||||
|
|
||||||
|
result.assets = make(map[string][]byte, len(state.data.assets))
|
||||||
|
for key, value := range state.data.assets {
|
||||||
|
result.assets[key] = append([]byte(nil), value...)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.pages = make(map[string]bool, len(state.data.pages))
|
||||||
|
for key, value := range state.data.pages {
|
||||||
|
result.pages[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDeliveryData struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
commit string
|
commit string
|
||||||
page bool
|
tag string
|
||||||
|
version string
|
||||||
|
noteBody string
|
||||||
|
pages map[string]bool
|
||||||
requests int
|
requests int
|
||||||
assets map[string][]byte
|
assets map[string][]byte
|
||||||
url string
|
url string
|
||||||
@@ -438,24 +629,28 @@ type facadeDeliveryServer struct {
|
|||||||
|
|
||||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)}
|
state := &facadeDeliveryServer{data: facadeDeliveryData{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), tag: "v1.2.3", version: "v1.2.3", noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n", pages: make(map[string]bool), assets: make(map[string][]byte)}}
|
||||||
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
|
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
||||||
state.url = server.URL
|
state.data.url = "http://" + server.Listener.Addr().String()
|
||||||
|
server.Start()
|
||||||
return server, state
|
return server, state
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
data := &state.data
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
state.requests++
|
data.requests++
|
||||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||||
state.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.EscapedPath() {
|
||||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
|
||||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
|
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
|
||||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
|
||||||
if !state.page {
|
if !data.pages[data.tag] {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -468,20 +663,20 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
state.t.Errorf("decode release payload: %v", err)
|
data.t.Errorf("decode release payload: %v", err)
|
||||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" {
|
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||||
state.t.Errorf("release payload = %#v", payload)
|
data.t.Errorf("release payload = %#v", payload)
|
||||||
}
|
}
|
||||||
state.page = true
|
data.pages[data.tag] = true
|
||||||
state.writeRelease(w)
|
state.writeRelease(w)
|
||||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
list := make([]map[string]any, 0, len(state.assets))
|
list := make([]map[string]any, 0, len(data.assets))
|
||||||
for name, content := range state.assets {
|
for name, content := range data.assets {
|
||||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": state.url + "/files/" + name})
|
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": data.url + "/files/" + name})
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(list)
|
_ = json.NewEncoder(w).Encode(list)
|
||||||
return
|
return
|
||||||
@@ -489,22 +684,22 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
name := r.URL.Query().Get("name")
|
name := r.URL.Query().Get("name")
|
||||||
file, _, err := r.FormFile("attachment")
|
file, _, err := r.FormFile("attachment")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.t.Errorf("read upload %q: %v", name, err)
|
data.t.Errorf("read upload %q: %v", name, err)
|
||||||
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
|
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content, err := io.ReadAll(file)
|
content, err := io.ReadAll(file)
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.t.Errorf("read upload %q: %v", name, err)
|
data.t.Errorf("read upload %q: %v", name, err)
|
||||||
}
|
}
|
||||||
state.assets[name] = content
|
data.assets[name] = content
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(state.assets), "name": name, "size": len(content)})
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(data.assets), "name": name, "size": len(content)})
|
||||||
default:
|
default:
|
||||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||||
content, ok := state.assets[name]
|
content, ok := data.assets[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -512,17 +707,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
_, _ = w.Write(content)
|
_, _ = w.Write(content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
|
data.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
||||||
|
// The request handler holds state.mu.
|
||||||
|
data := &state.data
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3",
|
"id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag,
|
||||||
"target_commitish": state.commit, "name": "Fixture v1.2.3",
|
"target_commitish": data.commit, "name": "Fixture " + data.version,
|
||||||
"body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false,
|
"body": data.noteBody, "draft": false, "prerelease": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
docs/adr/0003-declarative-validation.md
Normal file
76
docs/adr/0003-declarative-validation.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR 0003: Declare optional validation through a shared specification
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-07
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Consumers have different release-validation needs. Their additional checks
|
||||||
|
include reusable mechanics such as formatting inspection and Go toolchain
|
||||||
|
invocations, alongside assertions about product behavior. Maintaining the
|
||||||
|
reusable mechanics in each application duplicates code and coordination work.
|
||||||
|
|
||||||
|
[ADR 0001](0001-public-facade.md) establishes facade ownership of public
|
||||||
|
contracts. [ADR 0002](0002-routine-releases.md) prioritizes routine releases
|
||||||
|
with little application-owned code or recurring developer effort. Optional
|
||||||
|
validation needs a common selection and execution model that supports these
|
||||||
|
decisions as shared capabilities grow.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Consumers select shared validation through a typed, facade-owned specification
|
||||||
|
and equivalent release-profile settings. Related settings belong together.
|
||||||
|
Omitted optional settings preserve existing default validation behavior.
|
||||||
|
|
||||||
|
`Check` and `Publish` use one internal validation path bound to the captured
|
||||||
|
candidate. ReleaseKit owns execution, ordering, coordination between selected
|
||||||
|
checks, and propagation of failures and cancellation. The CLI maps profile
|
||||||
|
settings to the facade and owns presentation.
|
||||||
|
|
||||||
|
Shared validation exposes consistent diagnostics through facade-owned errors.
|
||||||
|
Findings identify the check and provide actionable detail where applicable.
|
||||||
|
Error wrapping preserves underlying causes and cancellation semantics.
|
||||||
|
Validation failures and publication outcomes retain their distinct meanings.
|
||||||
|
|
||||||
|
The existing `AdditionalChecks` callback and profile `checks` support
|
||||||
|
product-specific assertions. Publication guards, artifact builds, and delivery
|
||||||
|
retain their separate configuration and responsibilities. Validation execution
|
||||||
|
and coordination remain internal implementation responsibilities.
|
||||||
|
|
||||||
|
Evaluate optional capabilities by the application maintenance they remove,
|
||||||
|
their clarity of scope, configuration burden, and maintenance cost. A
|
||||||
|
capability can serve a small subset of consumers when those benefits justify
|
||||||
|
shared support. Consumers select the checks appropriate to their products.
|
||||||
|
|
||||||
|
GoDoc owns exact public API contracts, and the configuration reference owns
|
||||||
|
profile settings. Roadmaps own feature scope and implementation sequencing.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Consumer-owned commands preserve local control and leave applications
|
||||||
|
maintaining reusable inspection, invocation, and coordination code.
|
||||||
|
- Independently configured check and publication paths allow separate
|
||||||
|
validation choices and require consumers to keep those choices aligned.
|
||||||
|
- A public validator-registration framework supports custom composition and
|
||||||
|
expands the public lifecycle and ordering contracts. The existing
|
||||||
|
additional-check extension point supports current product-specific needs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Applications can replace repeated release mechanics with small declarations.
|
||||||
|
Go callers and CLI users receive equivalent selected validation through the
|
||||||
|
same release operations. Existing consumers retain their default behavior and
|
||||||
|
additional-check integration as optional capabilities are introduced.
|
||||||
|
|
||||||
|
ReleaseKit assumes responsibility for maintaining shared checks, coordinating
|
||||||
|
their interactions, and presenting useful diagnostics. Public selections and
|
||||||
|
error contracts carry compatibility obligations. Verification must cover
|
||||||
|
default behavior, equivalent API and profile selections, and failure propagation.
|
||||||
|
|
||||||
|
Internal execution can evolve within the facade boundaries. Each new capability
|
||||||
|
requires a clear scope and evidence that shared ownership reduces consumer work.
|
||||||
68
docs/adr/0004-module-selected-tooling.md
Normal file
68
docs/adr/0004-module-selected-tooling.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0004: Select release tooling from the released application's module configuration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-07
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications can declare ReleaseKit as a Go module dependency and use that
|
||||||
|
selection to build their local release tool. A separate CI version pin creates
|
||||||
|
a second declaration that maintainers must coordinate during upgrades. The two
|
||||||
|
paths can then use different ReleaseKit versions for the same release.
|
||||||
|
|
||||||
|
Hosted delivery runs after source publication and may be retried for an older
|
||||||
|
release. It needs a stable source for tool-version selection associated with
|
||||||
|
that release. [ADR 0002](0002-routine-releases.md) prioritizes reducing repeated
|
||||||
|
configuration and routine release decisions.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
For applications that declare ReleaseKit as a module dependency, the standard
|
||||||
|
integration selects the tool through the application's module configuration.
|
||||||
|
Local publication uses the candidate's module configuration. CI delivery uses
|
||||||
|
the released source's module configuration. Both paths follow the same
|
||||||
|
module-based selection approach.
|
||||||
|
|
||||||
|
CI verifies the triggering tag and commit and checks out that source before
|
||||||
|
resolving and building the release tool. Delivery retries use the same released
|
||||||
|
source for tool selection. The application maintains its ReleaseKit dependency
|
||||||
|
as the shared version declaration for these operations.
|
||||||
|
|
||||||
|
Tool bootstrap and dependency preparation remain separate from offline
|
||||||
|
validation and builds. The CI integration supplies the event, environment, and
|
||||||
|
credentials; the application profile supplies product and delivery settings.
|
||||||
|
Source publication and hosted delivery retain their separate outcomes.
|
||||||
|
|
||||||
|
Consumers using a separately installed tool and ReleaseKit's own source
|
||||||
|
bootstrap remain supported use cases. Adoption guidance describes the
|
||||||
|
appropriate bootstrap for each integration.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- An independent CI version pin makes tool selection explicit in the workflow
|
||||||
|
and requires maintainers to coordinate it with the application's dependency.
|
||||||
|
- Selecting from the current default branch follows ongoing dependency
|
||||||
|
updates and allows delivery retries for an older release to select a newer
|
||||||
|
tool.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Applications using the standard module-based integration update one dependency
|
||||||
|
declaration for local publication and CI delivery. Tool selection follows the
|
||||||
|
released source when delivery is retried, preserving the release's dependency
|
||||||
|
choice as the application evolves.
|
||||||
|
|
||||||
|
CI must establish the released checkout before tool bootstrap. Its environment
|
||||||
|
must provide a compatible Go toolchain and access to the dependencies needed
|
||||||
|
to build the selected tool. Retrying delivery for an older release retains
|
||||||
|
those preparation requirements.
|
||||||
|
|
||||||
|
Maintained templates and adoption guidance must preserve this selection order.
|
||||||
|
Verification should exercise a dependency-version change and selection from
|
||||||
|
the triggering release's source. Exact commands and bootstrap mechanics belong
|
||||||
|
in the maintained integration guidance and examples.
|
||||||
111
docs/adr/0005-module-release-scope.md
Normal file
111
docs/adr/0005-module-release-scope.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ADR 0005: Select a Go module as the unit of release
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-09
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A Go repository can contain independently versioned modules. CommandKit plans
|
||||||
|
to release its core and optional integrations from one repository. Each module
|
||||||
|
needs its own validation, release note, semantic version, and Git tag.
|
||||||
|
|
||||||
|
ReleaseKit currently couples the semantic version to the Git tag and uses the
|
||||||
|
repository directory for Go validation and release-note discovery. Supporting
|
||||||
|
independent modules through consumer-owned publication code would duplicate
|
||||||
|
ReleaseKit's responsibilities.
|
||||||
|
|
||||||
|
[ADR 0002](0002-routine-releases.md) prioritizes routine releases with little
|
||||||
|
application-owned code or recurring decision-making.
|
||||||
|
[ADR 0003](0003-declarative-validation.md) establishes shared declarative
|
||||||
|
validation. Module selection should give those checks a coherent release scope.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Each check or publication invocation selects one Go module within a Git
|
||||||
|
repository. Omitted module selection means the repository-root module. The
|
||||||
|
public Go API and CLI/profile interface express the same selection. Consumers
|
||||||
|
can use small per-module profiles through a shared launcher.
|
||||||
|
|
||||||
|
ReleaseKit distinguishes the repository directory, selected module directory,
|
||||||
|
semantic version, and complete Git tag. It derives the tag using Go's module
|
||||||
|
tag conventions and the selected module's committed declaration. Derivation
|
||||||
|
accounts for major-version directories. For example, a module in
|
||||||
|
`integrations/ckconfig` at `v0.2.0` uses the tag
|
||||||
|
`integrations/ckconfig/v0.2.0`. A module declared with a `/v2` suffix and stored
|
||||||
|
in `integrations/ckconfig/v2` uses `integrations/ckconfig/v2.0.0` for `v2.0.0`.
|
||||||
|
|
||||||
|
The semantic version identifies the module release. The complete tag identifies
|
||||||
|
its source in the shared repository and governs uniqueness, tag creation,
|
||||||
|
publication, source links, and recovery. Candidate evidence, publication
|
||||||
|
results, and CLI output identify the selected module and both version and tag,
|
||||||
|
alongside the existing commit and publication evidence. Failure outcomes retain
|
||||||
|
the resolved identity for recovery.
|
||||||
|
|
||||||
|
Release notes belong at `<module-directory>/docs/releases/<version>.md` and
|
||||||
|
identify the product and semantic version in their title. ReleaseKit reads the
|
||||||
|
selected note from the captured candidate. Root-module releases retain the
|
||||||
|
existing note layout and unprefixed tags. Modules can publish equal semantic
|
||||||
|
versions through their distinct notes and tags.
|
||||||
|
|
||||||
|
Mandatory and optional release validation share the selected module's scope.
|
||||||
|
Go tests, selected vet, and package compilation execute in that module with
|
||||||
|
the existing offline environment. File checks inspect the module's committed
|
||||||
|
contents and relevant release materials. Independently released nested modules
|
||||||
|
are excluded from their parent's validation scope. Selecting the root module
|
||||||
|
follows the same rule.
|
||||||
|
|
||||||
|
A check may inspect referenced material outside the selected module when needed
|
||||||
|
to validate an in-scope item. Resolving a Markdown link to shared repository
|
||||||
|
documentation does not add that destination's documents to the validation input
|
||||||
|
set. The checkout remains the boundary for checks of outside-checkout links.
|
||||||
|
Shared release materials follow established conventions and documented
|
||||||
|
consumer requirements.
|
||||||
|
|
||||||
|
Product-specific checks receive the selected module context and repository
|
||||||
|
context for deliberate composition checks. ReleaseKit applies module selection
|
||||||
|
consistently across its shared checks, avoiding separate scope settings for
|
||||||
|
each check.
|
||||||
|
|
||||||
|
Publication guards cover the shared repository: cleanliness, branch, candidate
|
||||||
|
commit, authoritative endpoint, and the complete selected tag. The standard
|
||||||
|
suite runs once for the selected candidate and module. Successful source-tag
|
||||||
|
publication remains the completion point, with existing failure and uncertain
|
||||||
|
publication outcomes preserved. Consumers prepare released sibling dependencies
|
||||||
|
and choose their publication order.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Consumer-owned module publication gives each application responsibility for
|
||||||
|
tag conventions, validation, and recovery, duplicating shared mechanics.
|
||||||
|
- Module-local Go execution with repository-wide optional validation lets
|
||||||
|
unrelated modules block a selected release and gives checks inconsistent
|
||||||
|
scopes.
|
||||||
|
- Independent scope settings for each check require consumers to coordinate
|
||||||
|
multiple declarations of the same release boundary.
|
||||||
|
- Coordinated multi-module publication adds release-set and dependency-ordering
|
||||||
|
responsibilities beyond the demonstrated need for individual releases.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers describe the module and product being released while ReleaseKit owns
|
||||||
|
the common procedure. Existing single-module repositories retain their default
|
||||||
|
behavior. Root modules containing independently released nested modules gain
|
||||||
|
module-scoped validation; release notes and migration guidance must explain
|
||||||
|
that change.
|
||||||
|
|
||||||
|
The shared candidate and publication paths must carry module scope and separate
|
||||||
|
version and tag identity throughout validation, diagnostics, and recovery.
|
||||||
|
Verification must cover root defaults, nested-module failures, equal versions
|
||||||
|
across modules, major-version directories, validation boundaries, and exact-tag
|
||||||
|
publication outcomes.
|
||||||
|
|
||||||
|
GoDoc and consumer documentation will own exact API and scope contracts.
|
||||||
|
Configuration and CLI documentation will own profile fields, commands, and
|
||||||
|
output. Implementation planning will define interactions with artifact builds
|
||||||
|
and hosted delivery. ReleaseKit's maintainer release policy continues to govern
|
||||||
|
ReleaseKit's own releases; consumers document their adopted module conventions.
|
||||||
171
docs/cli.md
171
docs/cli.md
@@ -21,34 +21,121 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
|
|||||||
```
|
```
|
||||||
|
|
||||||
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
|
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
|
||||||
version. The repository defaults to the current directory, and the profile
|
version. The repository argument defaults to the current directory. ReleaseKit
|
||||||
defaults to `<repository>/releasekit.json`. A successful command prints the
|
resolves its containing Git checkout root before selecting the module or profile;
|
||||||
captured local commit and version. It checks the committed release note, local
|
the profile defaults to `<repository-root>/releasekit.json`. A successful command prints the
|
||||||
Git state, runs the standard offline Go test suite, and then runs any configured
|
captured module, version, full tag, and commit. It checks the committed release note and
|
||||||
additional checks in profile order. It does not inspect remote availability or
|
local Git state, runs the standard offline Go test suite (with `-race` when
|
||||||
publish a tag.
|
`validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`
|
||||||
|
pass after successful tests when `validation.go.full_vet` is true. It then runs
|
||||||
|
selected package compilation and committed Go formatting, followed by
|
||||||
|
configured additional checks in profile order. It does not inspect remote
|
||||||
|
availability or publish a tag.
|
||||||
|
|
||||||
|
The profile's optional `module_dir` selects a repository-relative physical
|
||||||
|
module directory; omission selects the root module. Validation and configured
|
||||||
|
product checks use that module selection. Additional check commands run with
|
||||||
|
the selected module as their working directory and receive
|
||||||
|
`RELEASEKIT_VERSION`, `RELEASEKIT_CANDIDATE`, `RELEASEKIT_REPOSITORY_DIR`,
|
||||||
|
`RELEASEKIT_MODULE_DIR`, `RELEASEKIT_MODULE_PATH`, and `RELEASEKIT_TAG` in their
|
||||||
|
environment. These values are added to each child environment without changing
|
||||||
|
the ReleaseKit process environment.
|
||||||
|
|
||||||
|
When `validation.go.compile_targets` is selected, `check` compiles all packages
|
||||||
|
for each configured `GOOS/GOARCH` pair after tests and full vet, using static
|
||||||
|
mode with cgo disabled. This check produces no artifacts and does not execute
|
||||||
|
target binaries. Compilation failures use the `package-compile` identity and
|
||||||
|
show the selected target. When `validation.go_format` is true, `check` examines
|
||||||
|
regular committed Go files with the Go formatter after tests, vet, and package
|
||||||
|
compilation. It uses the selected module and shared-file inputs, including
|
||||||
|
vendored source, and ignores the worktree
|
||||||
|
and symlink blobs, and reports unformatted paths as `go-format` without
|
||||||
|
modifying files. When `validation.module_distribution.workspace_files` is true,
|
||||||
|
`check` reports committed `go.work` and `go.work.sum` blobs in those inputs,
|
||||||
|
including symlink entries, as `workspace-files`. Automatic file inputs stop at
|
||||||
|
independent nested modules. This inspection is independent of
|
||||||
|
`GOWORK` and ignores uncommitted files. Any artifact build selected by the
|
||||||
|
separate `build` setting runs after shared validation and configured checks.
|
||||||
|
When `validation.markdown` is a non-null object, `check` checks committed local
|
||||||
|
Markdown destinations and supported heading fragments after file-size
|
||||||
|
inspection. An empty object enables the check; omission or `null` disables it.
|
||||||
|
Outside-checkout links are written as out-of-scope observations to standard
|
||||||
|
error by default. Setting `validation.markdown.enforce_checkout_boundary` to
|
||||||
|
true makes those links fail; submodule-content observations remain
|
||||||
|
nonfailures. Failures use the `markdown-links` identity with the source location
|
||||||
|
and target.
|
||||||
|
|
||||||
`check` writes errors and diagnostics to standard error. It returns status 0
|
`check` writes errors and diagnostics to standard error. It returns status 0
|
||||||
after local validation, 1 after a candidate validation failure, and 2 for usage,
|
after local validation, 1 after a candidate validation failure, and 2 for usage,
|
||||||
profile, or invalid-version errors. Unsupported argument sequences print usage
|
profile, or invalid-version errors. Shared validation failures identify the
|
||||||
to standard error.
|
failed check and retain useful findings and cause context in one diagnostic;
|
||||||
|
separate full-vet failures use the `go-vet` identity.
|
||||||
|
Unsupported argument sequences print usage to standard error.
|
||||||
|
|
||||||
Publish a source tag:
|
Publish a source tag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION
|
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
`publish` reads the same product, remote, and additional-check settings as
|
`publish` reads the same product, remote, validation, and additional-check
|
||||||
`check`. It checks the authoritative remote and validates the candidate: the
|
settings as `check`. It checks the authoritative remote and validates the
|
||||||
standard offline Go test suite runs first, followed by configured checks in
|
candidate: the single standard offline Go test suite runs first, with `-race`
|
||||||
profile order. It then creates an annotated local tag and pushes only its exact
|
when `validation.go.race` is true. When `validation.go.full_vet` is true,
|
||||||
tag object. It prints the confirmed version, candidate commit, and tag object
|
test-time vet is disabled and `go vet -mod=readonly ./...` runs after successful
|
||||||
|
tests. Selected package compilation and committed Go formatting follow. The
|
||||||
|
selected file checks then run in order: workspace files, local replacements,
|
||||||
|
file size, and Markdown. Configured checks run afterward in profile order. It
|
||||||
|
then creates an annotated local tag
|
||||||
|
and pushes only its exact full tag object. It prints the confirmed semantic
|
||||||
|
version, full tag, candidate commit, and tag object
|
||||||
on success. Validation
|
on success. Validation
|
||||||
progress and failed publication state, including the captured publication
|
progress and failed publication state, including the captured publication
|
||||||
endpoint for recovery, are written to standard error. A signal
|
endpoint for recovery, are written to standard error. A signal
|
||||||
cancels the active operation; if it arrives during a push, the library's
|
cancels the active operation; if it arrives during a push, the library's
|
||||||
bounded reconciliation determines whether the exact tag was accepted.
|
bounded reconciliation determines whether the exact tag was accepted.
|
||||||
|
Shared validation failures use the same check and finding presentation as
|
||||||
|
`check`, alongside publication state and phase.
|
||||||
|
|
||||||
|
When `validation.go.compile_targets` is selected, package compilation runs
|
||||||
|
after tests and full vet and before configured checks. Each failure is reported
|
||||||
|
as `package-compile` with its selected target; this does not create build
|
||||||
|
artifacts or execute target binaries. When `validation.go_format` is true,
|
||||||
|
committed Go formatting runs after package compilation and before configured
|
||||||
|
checks. The selected workspace-file check then reports committed go.work and
|
||||||
|
go.work.sum paths as `workspace-files`. Formatting failures are reported as
|
||||||
|
`go-format` with affected paths and preserve parser or source-inspection causes.
|
||||||
|
|
||||||
|
When `validation.module_distribution.local_replacements` is true, the command
|
||||||
|
parses committed `go.mod` declarations in the selected module after
|
||||||
|
workspace-file inspection. Vendored contents remain in scope, while
|
||||||
|
automatic inputs stop at independently released nested modules. Explicit shared
|
||||||
|
files join the file-check inputs without expanding Go scope. Filesystem replacements use
|
||||||
|
the `local-replacements` identity and include the declaration path, source line,
|
||||||
|
and target; versioned replacements are permitted. Committed module symlinks are
|
||||||
|
resolved within the candidate, regardless of `GOWORK`.
|
||||||
|
When `validation.file_size` is a non-null object, the command checks each
|
||||||
|
committed blob after module-distribution checks. An omitted or null object
|
||||||
|
disables the check; an empty object enables the 1,000,000 decimal-byte default.
|
||||||
|
Oversized files use the `file-size` identity and report their path and measured
|
||||||
|
size against the limit. See the [configuration reference](config.md) for
|
||||||
|
literal exclusions and the current-candidate scope.
|
||||||
|
When `validation.markdown` is a non-null object, `publish` checks committed
|
||||||
|
Markdown destinations after file-size inspection and before configured checks.
|
||||||
|
It reports outside-checkout links to standard error unless boundary enforcement
|
||||||
|
is selected; submodule contents remain out of scope. Markdown failures stop
|
||||||
|
publication before product checks or tag creation.
|
||||||
|
|
||||||
|
`--expected-module DIR` optionally checks a launcher's selected physical module
|
||||||
|
against the profile's `module_dir` before candidate work. Both paths are
|
||||||
|
normalized relative to the repository; the profile remains the source of module
|
||||||
|
selection. The root selection is `.`.
|
||||||
|
|
||||||
|
The optional profile field `expected_push_url` applies only to `publish`. A
|
||||||
|
nonempty value must exactly match the effective push URL resolved by Git;
|
||||||
|
mismatches fail during preflight before Go validation or tag creation. Git URL
|
||||||
|
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
||||||
|
differ. `check` ignores this field.
|
||||||
|
|
||||||
The command returns status 0 for confirmed publication, 1 for a known
|
The command returns status 0 for confirmed publication, 1 for a known
|
||||||
publication failure or conflict, 2 for usage, profile, or invalid-version
|
publication failure or conflict, 2 for usage, profile, or invalid-version
|
||||||
@@ -67,8 +154,8 @@ go run ./cmd/releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSIO
|
|||||||
targets with the requested version injected, checks the native `version`
|
targets with the requested version injected, checks the native `version`
|
||||||
invocation, and writes binaries plus the private `.releasekit-build.json`
|
invocation, and writes binaries plus the private `.releasekit-build.json`
|
||||||
receipt into `dist` by default. `--output` selects a different directory;
|
receipt into `dist` by default. `--output` selects a different directory;
|
||||||
relative paths are resolved from the repository. Selected artifact filenames
|
relative paths are resolved from the selected module directory. Selected
|
||||||
are regenerated while unrelated files are preserved. A complete nonempty build
|
artifact filenames are regenerated while unrelated files are preserved. A complete nonempty build
|
||||||
writes a receipt only after compilation and smoke checking succeed. On partial
|
writes a receipt only after compilation and smoke checking succeed. On partial
|
||||||
failure, standard error lists completed outputs and prior receipts in that
|
failure, standard error lists completed outputs and prior receipts in that
|
||||||
output directory are invalidated; no new receipt is written. An
|
output directory are invalidated; no new receipt is written. An
|
||||||
@@ -101,7 +188,9 @@ the tagged source into temporary storage, removes it when the operation ends,
|
|||||||
and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is
|
and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is
|
||||||
supplied, that directory must contain the private `.releasekit-build.json`
|
supplied, that directory must contain the private `.releasekit-build.json`
|
||||||
receipt and matching unchanged artifacts; this keeps the caller's original
|
receipt and matching unchanged artifacts; this keeps the caller's original
|
||||||
bytes available for retries. Profiles without targets use page-only delivery
|
bytes available for retries. Relative `--assets` paths use the selected module
|
||||||
|
directory, matching `build --output`; absolute paths retain their supplied base.
|
||||||
|
Profiles without targets use page-only delivery
|
||||||
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
||||||
checks or publishes a tag.
|
checks or publishes a tag.
|
||||||
|
|
||||||
@@ -129,26 +218,46 @@ known missing assets are added. See the
|
|||||||
Prepare a tagged CI checkout and deliver it:
|
Prepare a tagged CI checkout and deliver it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION
|
releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG
|
||||||
```
|
```
|
||||||
|
|
||||||
`ci-deliver` fetches `VERSION` from the selected Git remote (default `origin`),
|
`TAG` is the complete Git tag name, including any module path prefix. Its last
|
||||||
requires the tag's peeled commit to match the required full `--commit` object
|
path component must be a stable semantic version; other commands continue to
|
||||||
ID, and checks out that commit detached. It then loads the profile from the
|
take a semantic `VERSION`. By default, `ci-deliver` fetches the exact `TAG` from
|
||||||
prepared checkout, validates delivery settings and the configured token, and
|
the selected Git remote (default `origin`), requires its peeled commit to match
|
||||||
downloads application dependencies only when the profile selects build targets.
|
the required full `--commit` object ID, checks out that commit detached, then
|
||||||
Binary profiles are built and delivered through the same facade path as
|
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
||||||
`deliver`; source-only profiles skip dependency downloads and binary builds.
|
checks read-only against the existing checkout and does not fetch or check out.
|
||||||
|
In either mode, it loads the profile from the verified source. `--expected-module`
|
||||||
|
optionally checks that the profile selects the module chosen by a launcher;
|
||||||
|
module selection itself remains profile-owned. The command validates delivery
|
||||||
|
settings and the configured token, then downloads application dependencies only
|
||||||
|
when the profile selects build targets. Binary profiles are built and delivered
|
||||||
|
through the same facade path as `deliver`; source-only profiles skip dependency
|
||||||
|
downloads and binary builds.
|
||||||
`--config` follows the existing profile path rules: relative paths are resolved
|
`--config` follows the existing profile path rules: relative paths are resolved
|
||||||
from `--repo`, and absolute paths are used as-is. This command explicitly
|
from `--repo`, and absolute paths are used as-is. The default mode prepares and
|
||||||
prepares and changes the selected checkout. Ordinary `deliver` continues to
|
changes the selected checkout; `--prepared` reuses a verified checkout.
|
||||||
operate on already prepared source without fetching, checking out, or
|
Ordinary `deliver` continues to operate on already prepared source without
|
||||||
downloading dependencies.
|
fetching, checking out, or downloading dependencies.
|
||||||
|
|
||||||
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the
|
In Woodpecker, pass `CI_WORKSPACE`, the source commit resolved from
|
||||||
repository, expected commit, and version arguments. The CLI does not read
|
`CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the repository, expected commit, and full
|
||||||
|
tag arguments. The event ID may identify an annotated tag object; see the
|
||||||
|
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
|
||||||
|
and workflow preparation. The CLI does not read
|
||||||
provider-specific environment variable names. Checkout and dependency
|
provider-specific environment variable names. Checkout and dependency
|
||||||
preparation failures return status 1; usage, profile, credential, and invalid
|
preparation failures return status 1; usage, profile, credential, and invalid
|
||||||
version errors return status 2. Final delivery uses the same statuses as
|
version errors return status 2. Final delivery uses the same statuses as
|
||||||
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
||||||
and 3 for uncertain remote state.
|
and 3 for uncertain remote state.
|
||||||
|
|
||||||
|
The maintained module-based tag workflow verifies and checks out the event tag
|
||||||
|
before resolving the consumer module. It builds the ReleaseKit version already
|
||||||
|
selected by that tagged `go.mod`, using temporary alternate module files outside
|
||||||
|
the source checkout. This tool bootstrap is separate from `ci-deliver`'s
|
||||||
|
application dependency preparation for binary profiles. ReleaseKit's own
|
||||||
|
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
|
||||||
|
ReleaseKit module dependency can use the separately installed-tool command.
|
||||||
|
See the [delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for
|
||||||
|
compatible Go environments, retries, and preparation details.
|
||||||
|
|||||||
197
docs/config.md
197
docs/config.md
@@ -1,19 +1,30 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default.
|
ReleaseKit reads a JSON profile from `<repository-root>/releasekit.json` by default.
|
||||||
The repository directory defaults to the current directory. A relative profile
|
The repository argument defaults to the current directory; the CLI resolves its
|
||||||
path is resolved from that repository directory; an absolute path is used as-is.
|
containing Git checkout root. A relative profile path is resolved from that root;
|
||||||
|
an absolute path is used as-is.
|
||||||
|
|
||||||
The current profile fields are:
|
The current profile fields are:
|
||||||
|
|
||||||
| Field | Required | Behavior |
|
| Field | Required | Behavior |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `product` | Yes | Product name used by release operations. |
|
| `product` | Yes | Product name used by release operations. |
|
||||||
|
| `module_dir` | No | Repository-relative physical Go module directory with its own `go.mod`; omission selects `.` (the repository root). |
|
||||||
| `remote` | No | Selected Git remote; defaults to `origin`. |
|
| `remote` | No | Selected Git remote; defaults to `origin`. |
|
||||||
|
| `expected_push_url` | No | Exact effective push URL required by `publish`; ignored by local `check`. |
|
||||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
||||||
|
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
||||||
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
||||||
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
|
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
|
||||||
|
|
||||||
|
`module_dir` names the selected module's physical location in the repository;
|
||||||
|
ReleaseKit reads its declared import path from that module's own `go.mod`. The
|
||||||
|
physical directory and declared path are distinct: a declared `/v2` module can
|
||||||
|
live either at `integrations/core` or `integrations/core/v2`, while both use the
|
||||||
|
same full release tag. Root-only profiles can omit `module_dir` and do not need
|
||||||
|
a module map.
|
||||||
|
|
||||||
Each check requires a nonempty `args` array whose first element names the
|
Each check requires a nonempty `args` array whose first element names the
|
||||||
executable. Arguments are passed directly to the child process; ReleaseKit does
|
executable. Arguments are passed directly to the child process; ReleaseKit does
|
||||||
not parse a shell command string. Select a shell explicitly when a check needs
|
not parse a shell command string. Select a shell explicitly when a check needs
|
||||||
@@ -25,6 +36,111 @@ Unknown fields are rejected so a setting cannot be silently ignored. The
|
|||||||
target requires the package and binary name; each target is written as one
|
target requires the package and binary name; each target is written as one
|
||||||
`GOOS/GOARCH` pair. An omitted or empty target list means no binary work.
|
`GOOS/GOARCH` pair. An omitted or empty target list means no binary work.
|
||||||
|
|
||||||
|
The optional `validation` object selects shared checks implemented by ReleaseKit.
|
||||||
|
`validation.shared_files` is a committed file list resolved from the repository
|
||||||
|
root. Omission or `null` uses `COPYRIGHT`, `LICENSE`, and
|
||||||
|
`docs/oss/attribution.md` when present for enabled file checks; `[]` selects no
|
||||||
|
shared files. An explicit list replaces those defaults. Each explicit file must
|
||||||
|
exist in the candidate, even when no file checks are enabled. Paths are literal,
|
||||||
|
repository-relative files with no glob semantics and may select a file outside
|
||||||
|
the chosen module. ReleaseKit deduplicates them with module inputs by path. This
|
||||||
|
field does not enable any checks or expand Go test, vet, or package-compilation
|
||||||
|
scope. Automatic file inputs stop at independent nested-module boundaries;
|
||||||
|
vendored files remain included. Reference inspection can read destinations
|
||||||
|
elsewhere in the captured repository without adding them to those inputs.
|
||||||
|
`validation.go.race` enables the race detector for the single standard Go test
|
||||||
|
suite. `validation.go.full_vet` runs a separate native `go vet` pass after that
|
||||||
|
suite succeeds; ReleaseKit disables test-time vet for that suite to avoid
|
||||||
|
running vet twice. `validation.go.compile_targets` is an ordered array of
|
||||||
|
`GOOS/GOARCH` pairs. After tests and selected full vet pass, ReleaseKit compiles
|
||||||
|
all packages for each target with cgo disabled. Each pair must include both
|
||||||
|
components; the selected Go toolchain reports unsupported complete targets.
|
||||||
|
This source compatibility check does not name or produce binaries, run a target
|
||||||
|
executable, or create an artifact manifest. Omitted or empty `compile_targets`
|
||||||
|
selects no package-compilation pass. When `full_vet` is omitted or false, the
|
||||||
|
test command keeps its existing vet behavior. These settings are independent.
|
||||||
|
`validation.go_format` checks regular committed Go blobs with the Go formatter
|
||||||
|
used to build ReleaseKit. It uses the selected file inputs, including vendor and
|
||||||
|
explicit shared files, and ignores
|
||||||
|
symlink blobs and the worktree, and reports every selected path whose bytes
|
||||||
|
change under formatting. It never writes formatted bytes back. Omitted or false
|
||||||
|
disables the check. It runs after tests, selected full vet, and selected package
|
||||||
|
compilation, before configured product checks.
|
||||||
|
|
||||||
|
When `validation.markdown` is an object, ReleaseKit checks local destinations
|
||||||
|
and supported heading fragments in committed regular Markdown files. Omission
|
||||||
|
or `null` disables the check; `{}` enables it. Schemed and protocol-relative
|
||||||
|
links are skipped, URI query components do not affect path checks, and other
|
||||||
|
existing files or directories receive existence checks only. Missing local
|
||||||
|
destinations, symlink cycles, and missing Markdown headings fail with the
|
||||||
|
`markdown-links` identity. Outside-checkout destinations are reported as out
|
||||||
|
of scope by default. Set
|
||||||
|
`validation.markdown.enforce_checkout_boundary` to `true` to fail those links;
|
||||||
|
submodule contents remain out of scope under either setting.
|
||||||
|
|
||||||
|
The parser recognizes inline links, images, reference links, and CommonMark
|
||||||
|
autolinks; link-like text in code is ignored, and raw HTML destinations are
|
||||||
|
outside this check. For linked images, the image source and enclosing link
|
||||||
|
target are checked. Image labels supply alternative text.
|
||||||
|
Relative paths resolve from the source document in the
|
||||||
|
captured commit. Query components are ignored for file existence, and percent
|
||||||
|
escapes in local paths and fragments are decoded once. Fragments are compared
|
||||||
|
in lowercase with anchors derived from visible ATX or Setext heading text.
|
||||||
|
Anchors keep Unicode letters and numbers, underscores, and hyphens; spaces
|
||||||
|
become hyphens, and repeated headings receive numeric suffixes.
|
||||||
|
|
||||||
|
When `validation.module_distribution.workspace_files` is true, it reports
|
||||||
|
committed `go.work` and `go.work.sum` files in the selected module inputs,
|
||||||
|
including nested paths and symlinks. It checks the captured candidate regardless
|
||||||
|
of `GOWORK`; it does not select or change the workspace used by Go commands,
|
||||||
|
and vendored contents remain in scope. Omitted or false disables it. When
|
||||||
|
`validation.module_distribution.local_replacements` is true, it parses the
|
||||||
|
selected module's committed `go.mod` declarations. Vendored contents remain in
|
||||||
|
scope; an explicit shared-file selection can include an individual declaration
|
||||||
|
from another module. It reports
|
||||||
|
`replace` directives whose replacement has no version. These filesystem
|
||||||
|
replacements include relative and absolute paths; versioned module replacements
|
||||||
|
are permitted. Committed `go.mod` symlinks are resolved within the candidate,
|
||||||
|
and unavailable selected contents or parse failures are reported as inspection
|
||||||
|
errors. This selection is independent of `workspace_files` and Go's `GOWORK`
|
||||||
|
setting. Omitted or false disables it.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product": "Example",
|
||||||
|
"validation": {
|
||||||
|
"go": {
|
||||||
|
"race": true,
|
||||||
|
"full_vet": true,
|
||||||
|
"compile_targets": ["linux/amd64", "linux/arm64"]
|
||||||
|
},
|
||||||
|
"go_format": true,
|
||||||
|
"markdown": {},
|
||||||
|
"module_distribution": {
|
||||||
|
"workspace_files": true,
|
||||||
|
"local_replacements": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `validation.file_size` is an object, ReleaseKit compares each committed
|
||||||
|
blob's size with `limit_bytes`. The limit uses decimal bytes and rejects only
|
||||||
|
sizes strictly greater than the limit. An omitted or null object disables the
|
||||||
|
check; `{}` enables it with a default of 1,000,000 bytes. A missing or null
|
||||||
|
`limit_bytes` selects that default, zero is a valid limit, and negative limits
|
||||||
|
are invalid.
|
||||||
|
|
||||||
|
`validation.file_size.exclusions` contains literal paths relative to the
|
||||||
|
selected module. Directory exclusions include committed descendants, using path
|
||||||
|
boundaries (`assets` does not exclude `assets-old`). Ordinary relative path
|
||||||
|
normalization applies; metacharacters are literal and unmatched paths have no
|
||||||
|
effect. The check measures regular and symlink blobs, including small binaries;
|
||||||
|
symlink size is the committed link text. Gitlinks have no measured size. Only
|
||||||
|
the captured candidate is checked, one offending file is reported at a time,
|
||||||
|
and historical or aggregate repository size is not measured.
|
||||||
|
|
||||||
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
||||||
`token_env`. Its values are required when running `deliver`, but credentials are
|
`token_env`. Its values are required when running `deliver`, but credentials are
|
||||||
not required for `check`, `publish`, or `build`. `token_env` names the
|
not required for `check`, `publish`, or `build`. `token_env` names the
|
||||||
@@ -32,12 +148,44 @@ environment variable containing the Gitea token; it defaults to
|
|||||||
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
|
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
|
||||||
passes its value explicitly to the library operation.
|
passes its value explicitly to the library operation.
|
||||||
|
|
||||||
The `check` and `publish` commands always run
|
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
||||||
`go test -mod=readonly -count=1 ./...` with Go module and toolchain network
|
effective push URL during `publish`, before candidate validation. It applies
|
||||||
access disabled. They then run each configured `checks` command once, in order,
|
to the push destination when fetch and push URLs differ, and Git URL rewrites
|
||||||
as an additional product check. Selected-target builds run after both. Do not
|
such as `insteadOf` are resolved before comparison. A mismatch fails preflight;
|
||||||
repeat the standard suite in `checks`; doing so runs it twice. The `build`
|
omitting the field keeps the selected remote's existing behavior. `check` is
|
||||||
command uses the selected build settings and writes binaries to `dist` unless
|
local-only and ignores this publication expectation.
|
||||||
|
|
||||||
|
The `check` and `publish` commands run one
|
||||||
|
`go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network
|
||||||
|
access disabled, adding `-race` when `validation.go.race` is true. When
|
||||||
|
`validation.go.full_vet` is true, they add `-vet=off` to that test command and
|
||||||
|
run native `go vet -mod=readonly ./...` after successful tests. They then run
|
||||||
|
`go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
|
||||||
|
`validation.go.compile_targets` entry, in profile order. Compile failures use
|
||||||
|
the `package-compile` identity and include the selected target. When selected,
|
||||||
|
`validation.go_format` checks regular committed Go source with go/format after
|
||||||
|
package compilation; unformatted paths use the `go-format` identity. When
|
||||||
|
`validation.module_distribution.workspace_files` is true, shared validation
|
||||||
|
also reports committed go.work and go.work.sum blobs after Go formatting, using
|
||||||
|
the workspace-files identity. Uncommitted workspace files are not inspected.
|
||||||
|
When `validation.module_distribution.local_replacements` is true, shared
|
||||||
|
validation parses committed `go.mod` files after workspace inspection and reports
|
||||||
|
filesystem replacements as `local-replacements`, with path, directive line, and
|
||||||
|
target. Versioned replacements are permitted; parse and inspection failures
|
||||||
|
preserve their causes.
|
||||||
|
When `validation.file_size` is selected, shared validation checks committed
|
||||||
|
blob sizes after module-distribution checks and reports the first oversized
|
||||||
|
path with the `file-size` identity and measured and limit byte counts.
|
||||||
|
When `validation.markdown` is selected, Markdown checking follows file-size
|
||||||
|
inspection and runs before configured product checks. Out-of-scope observations
|
||||||
|
are written to the command's standard error and do not fail validation unless
|
||||||
|
the checkout boundary is enforced. Submodule observations remain nonfailures.
|
||||||
|
Configured `checks` commands run afterward, once each in order, as additional
|
||||||
|
product checks. Artifact builds selected by the separate `build` setting run
|
||||||
|
after shared validation and product checks.
|
||||||
|
|
||||||
|
Do not repeat the standard suite or vet in `checks`; doing so repeats work. The
|
||||||
|
`build` command uses the selected build settings and writes binaries to `dist` unless
|
||||||
`--output` selects another path.
|
`--output` selects another path.
|
||||||
The `deliver` command uses the delivery destination and token. By default it
|
The `deliver` command uses the delivery destination and token. By default it
|
||||||
passes selected targets to the delivery facade, which builds tagged-source
|
passes selected targets to the delivery facade, which builds tagged-source
|
||||||
@@ -48,16 +196,31 @@ retained for retries or when build and delivery credentials need separate
|
|||||||
invocations. Ordinary `deliver` does not download application dependencies or
|
invocations. Ordinary `deliver` does not download application dependencies or
|
||||||
change the checkout.
|
change the checkout.
|
||||||
|
|
||||||
`ci-deliver` uses the same profile after preparing the selected tag in the
|
`ci-deliver` takes a full Git `TAG`; its final path component is the stable
|
||||||
checkout. It checks out the tag only after its peeled commit matches the
|
semantic version and the complete value is checked against the profile's
|
||||||
provider-supplied commit ID, then loads the profile and validates delivery
|
derived tag. By default it fetches the selected tag, confirms its peeled commit
|
||||||
settings and credentials. It runs `go mod download` only when the profile has
|
matches the provider-supplied commit ID, checks it out detached, and verifies
|
||||||
selected build targets; source-only delivery does not need application
|
the tag, HEAD, and clean worktree. With `--prepared`, it performs the same
|
||||||
dependencies. This preparation may access the network and may update
|
checks against an existing checkout without fetching or checking out. It then
|
||||||
`go.sum`. The command takes CI values as arguments, so profile settings remain
|
loads the profile from that source and validates delivery settings and
|
||||||
independent of Woodpecker environment variable names. See the
|
credentials. An optional `--expected-module` checks a launcher's module
|
||||||
|
selection against the profile. It runs `go mod download` only when the profile
|
||||||
|
has selected build targets; source-only delivery does not need application
|
||||||
|
dependencies. Default preparation may access the network and may update
|
||||||
|
`go.sum`. For binary tagged delivery, prepare and commit that module's sums
|
||||||
|
before tagging so dependency preparation leaves the tagged checkout clean. The
|
||||||
|
command takes CI values as arguments, so profile settings remain independent of
|
||||||
|
Woodpecker environment variable names. See the
|
||||||
[CLI contract](cli.md) for the invocation and exit statuses.
|
[CLI contract](cli.md) for the invocation and exit statuses.
|
||||||
|
|
||||||
|
This application dependency preparation is separate from bootstrapping the
|
||||||
|
ReleaseKit executable. The maintained module-based tag workflow verifies the
|
||||||
|
tag first, then uses temporary copies of that tag's module files to build the
|
||||||
|
ReleaseKit version selected by the consumer's `go.mod`. The
|
||||||
|
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) describes that
|
||||||
|
bootstrap, the self-bootstrapped source workflow, and the separately installed
|
||||||
|
tool option.
|
||||||
|
|
||||||
ReleaseKit's source-only profile is
|
ReleaseKit's source-only profile is
|
||||||
[releasekit.json](../releasekit.json).
|
[releasekit.json](../releasekit.json).
|
||||||
See the [source-only profile](../examples/releasekit.json) and
|
See the [source-only profile](../examples/releasekit.json) and
|
||||||
|
|||||||
66
docs/consumers/adoption.md
Normal file
66
docs/consumers/adoption.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 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
|
||||||
|
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
||||||
|
go get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
|
go mod tidy
|
||||||
|
go tool releasekit version
|
||||||
|
```
|
||||||
|
|
||||||
|
Select a release that implements the API, profile, and workflow you are adopting.
|
||||||
|
The module-release interface requires a release containing that feature;
|
||||||
|
pre-feature versions through v0.3.0 use their corresponding historical interface.
|
||||||
|
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.
|
||||||
|
|
||||||
|
The Go tool dependency feature requires Go 1.24 or later. The current ReleaseKit
|
||||||
|
module declares a Go 1.26.0 minimum, so use Go 1.26.0 or later for the current
|
||||||
|
release. Tool dependencies 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
|
||||||
|
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
||||||
|
go get "gitea.maximumdirect.net/eric/ReleaseKit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
|
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.
|
||||||
|
|
||||||
|
Upgrade the module-selected tool and workflow together. For a tag created before
|
||||||
|
that upgrade, retry with the workflow matching the tool recorded at that tag.
|
||||||
|
Keep its immutable module files and tag intact. See the
|
||||||
|
[delivery guide](delivery.md#woodpecker-tag-workflows) for tagged-source bootstrap
|
||||||
|
and the [example's source verification](../../examples/multi-module/README.md#local-source-verification)
|
||||||
|
for testing an unreleased checkout in a disposable consumer repository.
|
||||||
@@ -36,19 +36,24 @@ func build(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Build` records the repository's current `HEAD`, injects the selected stable
|
`Build` resolves `ModuleDir` and the selected module's working-tree declaration
|
||||||
version into `buildinfo.Version`, and returns the compiler identity, normalized
|
once, records the repository's current `HEAD`, injects the selected stable
|
||||||
targets, artifact paths, filenames, and SHA-256 digests. Names follow
|
version into `buildinfo.Version`, and returns repository/module identity, the
|
||||||
|
derived tag, compiler identity, normalized targets, artifact paths, filenames,
|
||||||
|
and SHA-256 digests. It does not require a release note, unused tag, clean
|
||||||
|
`main`, or publication. Names follow
|
||||||
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
|
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
|
||||||
target pair is built once, at its first position. Local builds may use modified
|
target pair is built once, at its first position. Local builds may use modified
|
||||||
source. `SourceVerified` is true only after a complete build observes clean
|
source. `SourceVerified` is true only after a complete build observes clean
|
||||||
source and the same HEAD before and after compilation; binary delivery requires
|
source and the same HEAD before and after compilation; binary delivery requires
|
||||||
that fact. This observation is not a source snapshot or attestation guarantee.
|
that fact. This observation is not a source snapshot or attestation guarantee.
|
||||||
|
|
||||||
The default output directory is `<repository>/dist`; a relative `OutputDir` is
|
Go commands run from `ModuleDir`, so `MainPackage` is interpreted within that
|
||||||
resolved from the repository. Selected artifact filenames are regenerated. Other files in the output
|
module. The default output directory is `<module>/dist`; a relative `OutputDir`
|
||||||
directory are left in place. A failed later build or native version check returns the binaries already
|
is resolved from the module, while an absolute path is used as supplied.
|
||||||
generated in `BuildResult.Artifacts`.
|
Selected artifact filenames are regenerated. Other files in the output
|
||||||
|
directory are left in place. A failed later build or native version check
|
||||||
|
returns the binaries already generated in `BuildResult.Artifacts`.
|
||||||
|
|
||||||
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||||
does not download a different toolchain automatically. It disables workspace
|
does not download a different toolchain automatically. It disables workspace
|
||||||
@@ -72,10 +77,14 @@ The [`build` command](../cli.md) reads the same build specification from the
|
|||||||
profile and writes a private receipt alongside its artifacts. `check` and
|
profile and writes a private receipt alongside its artifacts. `check` and
|
||||||
`publish` run selected builds after the standard suite and any additional
|
`publish` run selected builds after the standard suite and any additional
|
||||||
checks using temporary output outside the worktree. Candidate checks do not
|
checks using temporary output outside the worktree. Candidate checks do not
|
||||||
write a receipt or leave build files in the repository.
|
write a receipt or leave build files in the repository. Disposable native
|
||||||
|
version-check output also remains outside the whole repository when `TMPDIR`
|
||||||
|
points inside the source tree.
|
||||||
|
|
||||||
For delivery from a tagged checkout, `deliver` builds selected tagged-source
|
For delivery from a tagged checkout, `deliver` builds selected tagged-source
|
||||||
targets into temporary storage by default. Use `--assets DIR` to deliver a
|
targets into temporary storage by default. Use `--assets DIR` to deliver a
|
||||||
separate build output; ReleaseKit requires its receipt and verifies the source,
|
separate build output; ReleaseKit requires the current module-bound receipt and
|
||||||
build settings, and artifact hashes before the first Gitea write. See the
|
checks its source identity and normalized build settings. It verifies artifact
|
||||||
|
bytes and sizes during delivery before the first Gitea write. Older receipts
|
||||||
|
must be regenerated with the updated `build` command. See the
|
||||||
[delivery consumer guide](delivery.md) for page-only delivery and retry states.
|
[delivery consumer guide](delivery.md) for page-only delivery and retry states.
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
|
|||||||
RepositoryDir: "/work/project",
|
RepositoryDir: "/work/project",
|
||||||
Product: "Example",
|
Product: "Example",
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
|
Validation: releasekit.ValidationSpec{
|
||||||
|
Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
|
||||||
|
},
|
||||||
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
|
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
|
||||||
return runProjectChecks(ctx, candidate.RepositoryDir)
|
return runProjectChecks(ctx, candidate.RepositoryDir)
|
||||||
},
|
},
|
||||||
@@ -18,20 +21,108 @@ if err != nil {
|
|||||||
fmt.Println(result.Version, result.Commit)
|
fmt.Println(result.Version, result.Commit)
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository must have a clean index and worktree on `main`, and the selected
|
Set `ReleaseRequest.ModuleDir` to select a nested module using a
|
||||||
version must be stable `vMAJOR.MINOR.PATCH`. The committed
|
repository-relative physical directory; omission or `.` selects the root
|
||||||
`docs/releases/<version>.md` note must have a first Markdown H1 containing the
|
module. ReleaseKit reads that module's committed `go.mod` and returns its
|
||||||
product name and exact version as complete title elements. No local tag with
|
physical `ModuleDir`, declared `ModulePath`, semantic `Version`, and complete
|
||||||
that version may already exist.
|
`Tag` in the candidate and result. A physical `/v2` directory is removed from
|
||||||
|
the tag prefix only when the declared module path also ends in `/v2`. For
|
||||||
|
example, both `integrations/core/v2` and `integrations/core` can declare
|
||||||
|
`example.com/project/integrations/core/v2` and use the tag
|
||||||
|
`integrations/core/v2.1.0`. The release note lives under the physical module at
|
||||||
|
`<module>/docs/releases/<version>.md`.
|
||||||
|
|
||||||
ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available
|
The repository must have a clean index and worktree on `main`, and the selected
|
||||||
compatible local Go toolchain, then calls `AdditionalChecks` once when supplied.
|
version must be stable `vMAJOR.MINOR.PATCH`. The module-local committed
|
||||||
It does not download a different toolchain automatically. The standard suite
|
`<module>/docs/releases/<version>.md` note must have a first Markdown H1 containing the
|
||||||
uses the Go host target, disables workspace and module network access, and
|
product name and exact version as complete title elements. No local tag with
|
||||||
ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective
|
the derived complete tag may already exist. Different modules may use the same
|
||||||
cache and native cgo/C-tool settings are preserved, including values from Go's
|
semantic version with distinct notes and tags.
|
||||||
persistent environment file. The check revalidates the branch, commit, and
|
|
||||||
cleanliness after validation.
|
ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite from the
|
||||||
|
selected module directory with the available compatible local Go toolchain.
|
||||||
|
Set `Validation.Go.Race` to run that
|
||||||
|
suite with `-race`; set `Validation.Go.FullVet` to run
|
||||||
|
`go vet -mod=readonly ./...` after successful tests, with test-time vet disabled
|
||||||
|
for the suite. Set `Validation.Go.CompileTargets` to compile packages for each
|
||||||
|
complete target pair after tests and vet. Package compilation uses
|
||||||
|
`CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its
|
||||||
|
failures use the `package-compile` identity and include the target. The zero
|
||||||
|
values retain the ordinary native test behavior and select no compilation.
|
||||||
|
Set `Validation.GoFormat` to check committed regular Go files using the Go
|
||||||
|
formatter built into ReleaseKit. It uses the selected file inputs, including
|
||||||
|
vendor and explicit shared files, and
|
||||||
|
ignores symlink blobs and worktree changes, and reports unformatted paths as
|
||||||
|
`go-format` without rewriting files. Its zero value disables the check; it runs
|
||||||
|
after tests, selected vet, and package compilation, before `AdditionalChecks`.
|
||||||
|
|
||||||
|
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
|
||||||
|
`go.work` and `go.work.sum` files in the selected module inputs, including
|
||||||
|
symlink entries. It examines the captured candidate even when
|
||||||
|
`GOWORK=off`, ignores uncommitted files, and does not change Go test workspace
|
||||||
|
selection; vendored contents remain in scope. The zero value disables the check;
|
||||||
|
failures use `workspace-files`.
|
||||||
|
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
|
||||||
|
the selected module's committed `go.mod` declarations. Vendored contents remain
|
||||||
|
in scope; automatic inputs stop at independently released nested modules. An
|
||||||
|
explicit shared-file selection can include another module's declaration. It reports
|
||||||
|
filesystem `replace` directives with their source path, line, and target as
|
||||||
|
`local-replacements`; versioned replacements are
|
||||||
|
permitted. Committed module symlinks are resolved within the candidate. Parse
|
||||||
|
and inspection errors retain their causes, and the rule applies even when
|
||||||
|
`GOWORK=off`. Its zero value disables the check.
|
||||||
|
Set `Validation.FileSize` to check each committed blob against a byte limit.
|
||||||
|
Nil disables the check; an empty object enables the default of 1,000,000
|
||||||
|
decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects
|
||||||
|
negative values. `Exclusions` contains literal files or directories relative to
|
||||||
|
the selected module; excluded directories include descendants. Symlink blobs are
|
||||||
|
measured by link-text bytes, and gitlinks are omitted. The check applies to the
|
||||||
|
captured candidate and reports one oversized file with its measurement as
|
||||||
|
`file-size`; it does not measure historical or aggregate size.
|
||||||
|
Set `Validation.Markdown` to check committed local Markdown destinations and
|
||||||
|
supported heading fragments. Nil disables the check; an empty object enables
|
||||||
|
it with outside-checkout destinations reported as out of scope. Set
|
||||||
|
`EnforceCheckoutBoundary` to fail outside-checkout links. Submodule contents
|
||||||
|
remain out of scope under either setting. The checker runs after file-size
|
||||||
|
inspection and before `AdditionalChecks`; its nonfailure observations use the
|
||||||
|
request's output writer. Markdown failures use the `markdown-links` identity
|
||||||
|
and include the source location and destination.
|
||||||
|
|
||||||
|
Use these selections for ReleaseKit-owned validation rather than repeating the
|
||||||
|
standard suite, selected vet/format/distribution checks, or Markdown navigation
|
||||||
|
checks in `AdditionalChecks` or profile `checks`. Keep release-note rules and
|
||||||
|
consumer-specific assertions—such as tests of maintained examples—in those
|
||||||
|
product-owned checks.
|
||||||
|
The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared
|
||||||
|
`ModulePath`, semantic `Version`, and full `Tag`; use its module directory for
|
||||||
|
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
|
||||||
|
shared validation when supplied.
|
||||||
|
|
||||||
|
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
||||||
|
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
||||||
|
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
||||||
|
`docs/oss/attribution.md`, when present; a non-nil empty slice selects none.
|
||||||
|
Explicit files replace those defaults, must exist even when file checks are
|
||||||
|
disabled, and may be in another module. Combined inputs are deduplicated by
|
||||||
|
repository-relative path.
|
||||||
|
|
||||||
|
Automatic file inputs stop at independent nested-module boundaries, including
|
||||||
|
fixture modules. Vendored files remain included. Shared selection affects file
|
||||||
|
inputs only; Go validation retains the selected module's scope. Reference reads
|
||||||
|
can inspect destinations elsewhere in the captured repository without adding
|
||||||
|
those destinations to the input set.
|
||||||
|
|
||||||
|
It does not download a different toolchain automatically. If race prerequisites
|
||||||
|
are unavailable, the `go-test` check fails rather than being skipped; a selected
|
||||||
|
full-vet failure is reported as `go-vet`. The suite uses the Go host target,
|
||||||
|
disables workspace and module network access, and ignores ambient `GOFLAGS`,
|
||||||
|
experiment, and CPU feature overrides. Effective cache and native cgo/C-tool
|
||||||
|
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
|
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.
|
check whether a remote branch or tag is available and does not create a tag.
|
||||||
@@ -41,10 +132,11 @@ commands from `releasekit.json`.
|
|||||||
## Publishing the source tag
|
## Publishing the source tag
|
||||||
|
|
||||||
`releasekit.Publish` uses the same request and validation order: the standard
|
`releasekit.Publish` uses the same request and validation order: the standard
|
||||||
suite runs first, followed by `AdditionalChecks` when supplied. It then performs
|
suite runs first, followed by `AdditionalChecks` when supplied. It surrounds
|
||||||
authoritative remote checks, annotated-tag creation, and an exact tag push. The
|
validation with authoritative remote guards before annotated-tag creation and
|
||||||
|
an exact tag push. The
|
||||||
selected remote must publish `main` at the captured commit and must not already
|
selected remote must publish `main` at the captured commit and must not already
|
||||||
contain the selected version tag. A successful result includes the endpoint and
|
contain the selected complete tag. A successful result includes the endpoint and
|
||||||
tag object ID. Publication states distinguish an unattempted push, a tag that
|
tag object ID. Publication states distinguish an unattempted push, a tag that
|
||||||
was created locally but not pushed, a successful push, a guard conflict, and an
|
was created locally but not pushed, a successful push, a guard conflict, and an
|
||||||
unresolved push error. See the [publication guide](publish.md) for the complete
|
unresolved push error. See the [publication guide](publish.md) for the complete
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ The operation accepts historical lightweight and annotated tags, including a
|
|||||||
detached checkout, when the checked-out `HEAD` identifies the selected tag
|
detached checkout, when the checked-out `HEAD` identifies the selected tag
|
||||||
commit. It does not require the old remote `main` to match.
|
commit. It does not require the old remote `main` to match.
|
||||||
|
|
||||||
|
Delivery resolves the selected module's declaration and release note from the
|
||||||
|
captured commit. `Version` remains the semantic version; the full tag includes
|
||||||
|
the selected module's repository-relative physical path when it is nested. The
|
||||||
|
profile or API request selects the physical module directory. The optional API
|
||||||
|
`ExpectedTag` checks that a CI event's literal tag agrees with the derived tag
|
||||||
|
before any build or hosted request.
|
||||||
|
|
||||||
## CI preparation
|
## CI preparation
|
||||||
|
|
||||||
Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified
|
Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified
|
||||||
@@ -20,7 +27,7 @@ or hosted content.
|
|||||||
```go
|
```go
|
||||||
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
||||||
RepositoryDir: repositoryDir,
|
RepositoryDir: repositoryDir,
|
||||||
Version: version,
|
Tag: "integrations/example/v1.2.3",
|
||||||
ExpectedCommit: ciCommit,
|
ExpectedCommit: ciCommit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,16 +35,25 @@ if err != nil {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after
|
For binary profiles, call `PrepareBuildDependencies` with a
|
||||||
checkout preparation and before delivery. It runs `go mod download` with
|
`DependencyPreparationRequest` selecting the repository and module after
|
||||||
workspace discovery disabled and the available compatible local Go toolchain.
|
checkout preparation and before delivery. It runs `go mod download` in that
|
||||||
|
module with workspace discovery disabled and the available compatible local Go
|
||||||
|
toolchain.
|
||||||
The operation may use the network and updates the Go module cache while
|
The operation may use the network and updates the Go module cache while
|
||||||
preserving the caller's proxy, private-module, credential, and cache settings.
|
preserving the caller's proxy, private-module, credential, and cache settings.
|
||||||
If checksums are missing, Go may update `go.sum`; see the
|
If checksums are missing, Go may update `go.sum`. For binary CI delivery, prepare
|
||||||
|
and commit the selected module's `go.sum` before tagging: delivery verifies the
|
||||||
|
tagged source remains clean after dependency preparation. See the
|
||||||
[Go Modules Reference](https://go.dev/ref/mod#go-mod-download). Cancellation
|
[Go Modules Reference](https://go.dev/ref/mod#go-mod-download). Cancellation
|
||||||
and command errors are returned. Page-only profiles do not need dependency
|
and command errors are returned. Page-only profiles do not need dependency
|
||||||
preparation.
|
preparation.
|
||||||
|
|
||||||
|
Set `TaggedCheckoutRequest.VerifyOnly` when a bootstrap step already fetched and
|
||||||
|
detached the event tag. This mode checks the exact tag, expected commit, `HEAD`,
|
||||||
|
and clean worktree without fetching or changing the checkout. Both modes require
|
||||||
|
the full `Tag` and expected commit.
|
||||||
|
|
||||||
## Command line
|
## Command line
|
||||||
|
|
||||||
Add a Gitea destination to the profile:
|
Add a Gitea destination to the profile:
|
||||||
@@ -66,9 +82,14 @@ The CLI reads the named token variable only for `deliver`; its default name is
|
|||||||
creating or accepting a hosted release page. The full checked-in note is the
|
creating or accepting a hosted release page. The full checked-in note is the
|
||||||
hosted body. A retry accepts an identical page and matching attachments, adds
|
hosted body. A retry accepts an identical page and matching attachments, adds
|
||||||
only missing assets, and reports conflicts without replacing content.
|
only missing assets, and reports conflicts without replacing content.
|
||||||
|
For nested modules, the note comes from that module's committed
|
||||||
|
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
||||||
|
|
||||||
With no build targets, delivery is page-only: no builder, version smoke check,
|
With no build targets, delivery is page-only: no builder, version smoke check,
|
||||||
receipt, binary asset, or checksum manifest is involved. With selected targets,
|
receipt, binary asset, or checksum manifest is involved. Committed module
|
||||||
|
directories and declaration links can be resolved from a sparse checkout;
|
||||||
|
page-only delivery needs the committed objects and note. Managed builds require
|
||||||
|
the selected source files to be materialized. With selected targets,
|
||||||
one `deliver` invocation builds the tagged source through the facade into
|
one `deliver` invocation builds the tagged source through the facade into
|
||||||
temporary storage and delivers the result. To retain artifacts for a retry or
|
temporary storage and delivers the result. To retain artifacts for a retry or
|
||||||
separate credentials between build and delivery, use two invocations:
|
separate credentials between build and delivery, use two invocations:
|
||||||
@@ -80,10 +101,13 @@ RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3
|
|||||||
|
|
||||||
The build command does not need delivery credentials. The `--assets DIR`
|
The build command does not need delivery credentials. The `--assets DIR`
|
||||||
directory must contain the private `.releasekit-build.json` receipt created by
|
directory must contain the private `.releasekit-build.json` receipt created by
|
||||||
`build`. ReleaseKit checks the receipt's repository, commit, version, resolved
|
`build`. ReleaseKit checks its schema, repository, commit, version, resolved
|
||||||
build settings, and file hashes before making a remote request; caller-owned
|
module directory and path, full tag, normalized build settings, and artifact
|
||||||
files remain available for retries. Ordinary `deliver` does not download
|
declarations. Old receipts must be regenerated with the updated `build`
|
||||||
application dependencies or change the checkout.
|
command. Delivery verifies the artifact bytes and sizes before making a hosted
|
||||||
|
request; caller-owned files remain available for retries.
|
||||||
|
Ordinary `deliver` does not download application dependencies or change the
|
||||||
|
checkout.
|
||||||
|
|
||||||
The result reports `complete`, `partial`, `conflict`, `unknown`, or
|
The result reports `complete`, `partial`, `conflict`, `unknown`, or
|
||||||
`not_attempted`, along with matching, uploaded, pending, conflicting, and
|
`not_attempted`, along with matching, uploaded, pending, conflicting, and
|
||||||
@@ -110,19 +134,23 @@ outside the checkout and removed when delivery returns. `Output` receives build
|
|||||||
progress. With no build, or with an empty target set, delivery is page-only.
|
progress. With no build, or with an empty target set, delivery is page-only.
|
||||||
|
|
||||||
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
||||||
ReleaseKit checks that it identifies the selected repository, tagged commit, and
|
ReleaseKit checks its repository, module directory, declared module path, full
|
||||||
version, that its source was verified clean and unchanged during compilation,
|
tag, semantic version, tagged commit, normalized settings, and artifact evidence
|
||||||
and that the current files still match their recorded digests. The files remain
|
against the selected committed module. Binary results must record verified
|
||||||
caller-owned, so callers can retain the original bytes for retries and are
|
source. The current files are checked against their recorded digests before any
|
||||||
responsible for cleanup. With neither artifact source, an empty managed target
|
hosted request. The files remain caller-owned, so callers can retain the
|
||||||
set, or an empty prebuilt result, delivery is page-only. Do not set both `Build`
|
original bytes for retries and are responsible for cleanup. With neither
|
||||||
and `Prebuilt` in one request.
|
artifact source, an empty managed target set, or a matching empty prebuilt
|
||||||
|
result, delivery is page-only. Do not set both `Build` and `Prebuilt` in one
|
||||||
|
request.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||||
RepositoryDir: "/work/example",
|
RepositoryDir: "/work/example",
|
||||||
|
ModuleDir: "integrations/example",
|
||||||
Product: "Example",
|
Product: "Example",
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
|
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
||||||
Destination: releasekit.GiteaDestination{
|
Destination: releasekit.GiteaDestination{
|
||||||
BaseURL: "https://gitea.example",
|
BaseURL: "https://gitea.example",
|
||||||
Owner: "example",
|
Owner: "example",
|
||||||
@@ -150,40 +178,93 @@ exit codes, and transport behavior.
|
|||||||
|
|
||||||
Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml)
|
Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml)
|
||||||
in the consumer repository's `.woodpecker/` directory. Set `GO_IMAGE` to an
|
in the consumer repository's `.woodpecker/` directory. Set `GO_IMAGE` to an
|
||||||
image with Go 1.25 or newer, Git, and a Go version compatible with the consumer
|
image with Git and a Go toolchain compatible with the consumer module and its
|
||||||
module. Replace `RELEASEKIT_VERSION` with an immutable ReleaseKit revision or
|
selected ReleaseKit version. Choose the patch used for the consumer's release
|
||||||
published version. The example selects the initial `v0.1.0` baseline; update
|
build and validation. The template uses `GOTOOLCHAIN=local`; Go does not download
|
||||||
that pin when adopting a later release. Configure the Woodpecker repository secret
|
a different toolchain. The tag's `go.mod` must select ReleaseKit as a module
|
||||||
`releasekit_gitea_token`; the template maps it to `RELEASEKIT_GITEA_TOKEN` only
|
dependency. For CLI-only consumers, use the versioned Go tool dependency
|
||||||
for the delivery step. The repository needs a `delivery` profile. Page-only and
|
described in the [adoption guide](adoption.md). Ordinary package dependencies
|
||||||
binary behavior both use this template; the profile's build target list selects
|
also work with this template.
|
||||||
whether binaries are built and delivered.
|
|
||||||
|
|
||||||
The command runs versioned source directly with `go run`, passing
|
Select a tool release implementing this workflow's interface before adopting it;
|
||||||
`CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as command arguments.
|
the [adoption guide](adoption.md#ci-adoption) owns coordinated tool/workflow
|
||||||
`ci-deliver` fetches and verifies the event tag against its commit before
|
upgrades. Retries of older tags use the workflow matching their recorded tool.
|
||||||
checking out the commit detached. It then loads the profile from that source,
|
|
||||||
validates the destination and token, and downloads application dependencies
|
Configure the Woodpecker repository secret `releasekit_gitea_token`; the
|
||||||
only for profiles with build targets. The facade builds and delivers those
|
template maps it to `RELEASEKIT_GITEA_TOKEN` for delivery. The repository needs
|
||||||
targets with the note page. Source-only profiles skip dependency downloads and
|
a `delivery` profile. Page-only and binary behavior both use this template; the
|
||||||
binary work. The workflow does not repeat checkout guards or maintain a
|
profile's build target list selects whether application binaries are built and
|
||||||
separate build/receipt/delivery sequence.
|
delivered.
|
||||||
|
|
||||||
|
Before running Go, the template fetches the explicit event tag and independently
|
||||||
|
resolves both the tag and `CI_COMMIT_SHA` to source commits. The event ID may
|
||||||
|
identify an annotated tag object or a commit. The template requires the resolved
|
||||||
|
commits to match, checks out that commit detached, and confirms the source tree
|
||||||
|
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
||||||
|
directory outside the checkout. `go list -m` confirms that the module selects
|
||||||
|
ReleaseKit; Go uses
|
||||||
|
an alternate `-modfile` to prepare dependencies and build the selected
|
||||||
|
`cmd/releasekit` package without changing the tagged module files. The template
|
||||||
|
invokes that executable with `CI_WORKSPACE`, the resolved event commit, and
|
||||||
|
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
|
||||||
|
|
||||||
|
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
||||||
|
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
||||||
|
After the workflow verifies and checks out the event tag, the resulting CLI uses
|
||||||
|
`ci-deliver --prepared` to repeat the tag, commit, HEAD, and cleanliness checks
|
||||||
|
without another fetch or checkout. It loads the tagged profile and downloads
|
||||||
|
application dependencies only when build targets are selected; it builds and
|
||||||
|
delivers those targets with the note page. Source-only profiles skip application
|
||||||
|
dependency downloads and binary work. Dependency preparation for binaries may
|
||||||
|
use the network. Prepare and commit the selected module's `go.sum` before
|
||||||
|
tagging; an uncommitted change makes the later delivery source check fail. A
|
||||||
|
retry still uses the same immutable tag and that tag's module settings, even
|
||||||
|
after the default branch selects a newer ReleaseKit version.
|
||||||
|
|
||||||
|
The root-only [`tag-delivery.yml`](../../examples/woodpecker/tag-delivery.yml)
|
||||||
|
keeps the small default profile and root-module bootstrap. For consumers that
|
||||||
|
release several modules from one repository, copy the contents of the maintained
|
||||||
|
[multi-module example](../../examples/multi-module/) to the consumer repository
|
||||||
|
root and use its [`woodpecker.yml`](../../examples/multi-module/woodpecker.yml),
|
||||||
|
[`release-modules.tsv`](../../examples/multi-module/release-modules.tsv), and
|
||||||
|
shared selector. The event tag chooses one row from the tagged checkout; the
|
||||||
|
profile and physical module must agree, and ReleaseKit verifies the derived tag.
|
||||||
|
The example covers source-only integration pages and a binary core module.
|
||||||
|
|
||||||
|
If the application does not declare ReleaseKit as a module dependency, install
|
||||||
|
the executable separately and invoke the same `ci-deliver` command. For
|
||||||
|
ReleaseKit's own source workflow, the maintained
|
||||||
|
[source-delivery template](../../examples/woodpecker/releasekit-source-delivery.yml)
|
||||||
|
checks out the tag and self-bootstraps `./cmd/releasekit` into temporary storage.
|
||||||
|
Its readonly tool build uses committed module metadata and can fetch missing
|
||||||
|
dependencies through normal Go proxy, private-module, checksum, and cache
|
||||||
|
settings. Tool bootstrap follows tag verification; offline candidate validation
|
||||||
|
and application artifact compilation retain their separate environments.
|
||||||
|
|
||||||
The normal Go module and build caches are sufficient. Cache mounts may be added
|
The normal Go module and build caches are sufficient. Cache mounts may be added
|
||||||
as deployment configuration when they help the runner; they are not needed by
|
as deployment configuration when they help the runner; they are not needed by
|
||||||
the template. A locally retained executable can be installed with
|
the template. A separately installed executable can be installed with
|
||||||
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
|
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
|
||||||
with the same arguments:
|
with the same arguments. With the event object available in the CI checkout,
|
||||||
|
resolve it to its source commit before invocation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG"
|
expected_commit=$(git -C "$CI_WORKSPACE" rev-parse --verify "${CI_COMMIT_SHA}^{commit}")
|
||||||
|
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$expected_commit" "$CI_COMMIT_TAG"
|
||||||
```
|
```
|
||||||
|
|
||||||
Upgrade by changing the selected immutable
|
The module-based workflow follows the ReleaseKit version selected by the tagged
|
||||||
`RELEASEKIT_VERSION` (or the `@PIN` used by `go install`) and validating the
|
consumer's `go.mod`, so retries use that source's original selection. Update the
|
||||||
workflow against a tag fixture. The command and profile behavior are documented
|
consumer module configuration when adopting a newer ReleaseKit version and
|
||||||
|
verify it with a tag fixture. The command and profile behavior are documented
|
||||||
in the [CLI contract](../cli.md) and [configuration reference](../config.md).
|
in the [CLI contract](../cli.md) and [configuration reference](../config.md).
|
||||||
|
|
||||||
|
Update copied workflow YAML when adopting corrected examples. A dependency
|
||||||
|
upgrade alone leaves that copy unchanged. An older tagged workflow that passes
|
||||||
|
a raw annotated tag object ID still fails on retry. Recover delivery from the
|
||||||
|
original tagged source with the resolved event commit through the command above,
|
||||||
|
preserving the published tag and existing content.
|
||||||
|
|
||||||
Woodpecker runs delivery after the tag event, independently from the source
|
Woodpecker runs delivery after the tag event, independently from the source
|
||||||
publication result. Read its workflow status as delivery status: a delivery
|
publication result. Read its workflow status as delivery status: a delivery
|
||||||
failure does not undo source publication. A failed, partial, or uncertain run
|
failure does not undo source publication. A failed, partial, or uncertain run
|
||||||
@@ -193,3 +274,48 @@ move the tag to make a retry pass. See
|
|||||||
for runner-provided tag and commit variables and
|
for runner-provided tag and commit variables and
|
||||||
[workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) for tag
|
[workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) for tag
|
||||||
events, sequential steps, and the shared workspace.
|
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.
|
||||||
|
|||||||
@@ -6,26 +6,107 @@ annotated source tag:
|
|||||||
```go
|
```go
|
||||||
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
|
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
|
||||||
RepositoryDir: "/work/project",
|
RepositoryDir: "/work/project",
|
||||||
|
ModuleDir: "integrations/example",
|
||||||
Product: "Example",
|
Product: "Example",
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
|
ExpectedPushURL: "ssh://git.example/owner/example.git",
|
||||||
|
Validation: releasekit.ValidationSpec{
|
||||||
|
Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
|
||||||
|
},
|
||||||
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
|
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
|
||||||
return runProjectChecks(ctx, candidate.RepositoryDir)
|
return runProjectChecks(ctx, candidate.ModuleDir)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(result.Version, result.CandidateCommit, result.TagObject)
|
fmt.Println(result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.TagObject)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ModuleDir` is a repository-relative physical module directory; omit it or use
|
||||||
|
`.` for a root-module release. ReleaseKit reads the selected module's committed
|
||||||
|
declaration and derives the complete Git tag from its declared module path and
|
||||||
|
physical location. `Version` remains the stable semantic version. The note is
|
||||||
|
`<module>/docs/releases/<version>.md`; an on-disk `/v2` directory is part of
|
||||||
|
the note path but is removed from the tag prefix only when the module path ends
|
||||||
|
in `/v2`. `PublicationResult` returns the absolute `ModuleDir`, declared
|
||||||
|
`ModulePath`, complete `Tag`, and existing commit and tag-object evidence.
|
||||||
|
|
||||||
|
The optional `ValidationSpec.SharedFiles` selects additional committed files
|
||||||
|
for enabled file checks. Its nil and empty-slice behavior is described in the
|
||||||
|
[candidate check guide](check.md#local-candidate-check).
|
||||||
|
|
||||||
Publication captures a clean candidate on `main`, validates its committed
|
Publication captures a clean candidate on `main`, validates its committed
|
||||||
release note, and checks the selected remote before running candidate checks.
|
release note, and checks the selected remote before running candidate checks.
|
||||||
It always runs `go test -mod=readonly -count=1 ./...` with the available
|
Set `ExpectedPushURL` to require the effective push destination to match an
|
||||||
compatible local Go toolchain. It then calls `AdditionalChecks` once when
|
exact string. Git URL rewrites are resolved first, and the push URL is compared
|
||||||
supplied. The standard suite cannot be replaced; move product-specific behavior
|
when fetch and push URLs differ. A mismatch fails preflight before Go validation,
|
||||||
into `AdditionalChecks` and remove repeated standard-suite commands from custom
|
callbacks, or tag creation; detect it with
|
||||||
checks. A failed suite or additional check prevents tag creation, and wrapped
|
`errors.Is(err, releasekit.ErrUnexpectedPushURL)`. `Check` ignores this
|
||||||
errors preserve their causes.
|
publication-only field.
|
||||||
|
It runs one `go test -mod=readonly -count=1 ./...` suite with the available
|
||||||
|
compatible local Go toolchain. Set `Validation.Go.Race` to run that suite with
|
||||||
|
`-race`. Set `Validation.Go.FullVet` to run `go vet -mod=readonly ./...` after
|
||||||
|
successful tests, with test-time vet disabled for that suite. The zero values
|
||||||
|
keep ordinary test-time vet behavior. It then calls `AdditionalChecks` once
|
||||||
|
when supplied. The standard suite cannot be replaced;
|
||||||
|
move product-specific behavior into `AdditionalChecks` and remove repeated
|
||||||
|
standard-suite commands from custom checks. A failed suite or additional check
|
||||||
|
prevents tag creation, and wrapped errors preserve their causes.
|
||||||
|
If race prerequisites are unavailable, the selected suite fails as `go-test`
|
||||||
|
instead of silently running without the race detector. Full-vet failures are
|
||||||
|
reported as `go-vet` and stop publication before tag creation.
|
||||||
|
|
||||||
|
Set `Validation.Go.CompileTargets` to compile packages for each complete target
|
||||||
|
pair after tests and vet. This uses `CGO_ENABLED=0`, produces no artifact, and
|
||||||
|
does not execute target binaries. A failure is reported as `package-compile`
|
||||||
|
with the selected target and prevents tag creation.
|
||||||
|
|
||||||
|
Set `Validation.GoFormat` to check committed regular Go source using the Go
|
||||||
|
formatter built into ReleaseKit. It uses the selected file inputs, including
|
||||||
|
vendor and explicit shared files, and ignores
|
||||||
|
worktree changes and symlink blobs, and reports unformatted paths as
|
||||||
|
`go-format` without changing files. The default is disabled. The check follows
|
||||||
|
tests, selected vet, and package compilation, and a failure stops publication
|
||||||
|
before product checks or tag creation.
|
||||||
|
|
||||||
|
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
|
||||||
|
`go.work` and `go.work.sum` blobs in the selected inputs, including symlink entries.
|
||||||
|
The rule reads the captured candidate regardless of `GOWORK`; uncommitted files
|
||||||
|
are ignored, and Go workspace selection and vendor behavior are unchanged. Its
|
||||||
|
zero value disables the check. Findings use `workspace-files` and stop
|
||||||
|
publication before product checks or tag creation.
|
||||||
|
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
|
||||||
|
selected module's committed `go.mod` declarations. Vendored contents remain in
|
||||||
|
scope; automatic inputs stop at independently released nested modules. Explicit
|
||||||
|
shared files can include another module's declaration. Filesystem
|
||||||
|
`replace` directives are reported with their source path, line, and target as
|
||||||
|
`local-replacements`; versioned replacements are
|
||||||
|
permitted. Committed module symlinks are resolved within the candidate, and
|
||||||
|
parse or inspection errors retain their causes. This check is independent of
|
||||||
|
`GOWORK`; its zero value disables it, and a failure stops publication before
|
||||||
|
product checks or tag creation.
|
||||||
|
Set `Validation.FileSize` to check each committed blob against a byte limit.
|
||||||
|
Nil disables the check; an empty object enables the default of 1,000,000
|
||||||
|
decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects
|
||||||
|
negative values. `Exclusions` contains literal files or directories relative to
|
||||||
|
the selected module, including committed descendants of excluded directories.
|
||||||
|
Symlink blob size is its link text; gitlinks are omitted. This check measures only the
|
||||||
|
captured candidate and reports one oversized file with its measurement as
|
||||||
|
`file-size`; it stops publication before product checks or tag creation.
|
||||||
|
|
||||||
|
Set `Validation.Markdown` to check committed local Markdown destinations and
|
||||||
|
supported heading fragments during publication. Nil disables the check; an
|
||||||
|
empty object enables it with outside-checkout links reported as out of scope.
|
||||||
|
Set `EnforceCheckoutBoundary` to fail those links. Submodule contents remain
|
||||||
|
out of scope under either setting. Observations use the request's output writer;
|
||||||
|
failures use `markdown-links` and stop publication before product checks or tag
|
||||||
|
creation.
|
||||||
|
|
||||||
|
ReleaseKit-owned selections replace duplicate generic suite, vet, formatting,
|
||||||
|
distribution, and Markdown checks in configured product checks. Keep
|
||||||
|
release-note rules and consumer-specific assertions, such as maintained-example
|
||||||
|
tests, in `AdditionalChecks` or profile `checks`.
|
||||||
|
|
||||||
If `Build` selects targets, ReleaseKit verifies them after the standard suite
|
If `Build` selects targets, ReleaseKit verifies them after the standard suite
|
||||||
and additional checks, before creating the tag. The operation then rechecks the
|
and additional checks, before creating the tag. The operation then rechecks the
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
ReleaseKit contains a Go module with the documented root `releasekit` facade,
|
ReleaseKit contains a Go module with the documented root `releasekit` facade,
|
||||||
adopted policies, and design documents. See the [README](../README.md) for
|
adopted policies, and design documents. See the [README](../README.md) for
|
||||||
orientation and the [product roadmap](roadmap/releasekit.md) for proposed
|
orientation.
|
||||||
capabilities and implementation sequencing.
|
|
||||||
|
|
||||||
Read the [agent policy](policy/agents.md) and inspect the worktree before editing.
|
Read the [agent policy](policy/agents.md) and inspect the worktree before editing.
|
||||||
Preserve unrelated changes and follow explicit instructions about commits.
|
Preserve unrelated changes and follow explicit instructions about commits.
|
||||||
@@ -55,8 +54,7 @@ go run ./internal/apicheck
|
|||||||
See the [candidate check guide](consumers/check.md), [source publication guide](consumers/publish.md),
|
See the [candidate check guide](consumers/check.md), [source publication guide](consumers/publish.md),
|
||||||
[build consumer guide](consumers/build.md),
|
[build consumer guide](consumers/build.md),
|
||||||
and [internal package inventory](internal/overview.md) for implemented behavior
|
and [internal package inventory](internal/overview.md) for implemented behavior
|
||||||
and capability ownership. The [product roadmap](roadmap/releasekit.md) records
|
and capability ownership.
|
||||||
remaining adoption and deployment work.
|
|
||||||
|
|
||||||
For prose changes, review the diff and check local links, fragment destinations,
|
For prose changes, review the diff and check local links, fragment destinations,
|
||||||
provenance, template residue, and consistency with current repository contents.
|
provenance, template residue, and consistency with current repository contents.
|
||||||
@@ -77,3 +75,17 @@ validation. The [maintainer release procedure](release.md) owns preparation,
|
|||||||
publication, and recovery. Hosted note-page delivery is a separate prepared
|
publication, and recovery. Hosted note-page delivery is a separate prepared
|
||||||
workflow and does not change the successful-tag-push completion point. ReleaseKit
|
workflow and does not change the successful-tag-push completion point. ReleaseKit
|
||||||
does not declare binary deliverables.
|
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).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Gitea release pages
|
# Gitea release pages
|
||||||
|
|
||||||
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
|
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
|
||||||
source, find or create its stable release page, inspect existing assets, and
|
source, find or create the selected module's release page, inspect existing assets, and
|
||||||
upload missing assets through the internal Gitea client. Page-only and
|
upload missing assets through the internal Gitea client. Page-only and
|
||||||
binary-producing profiles use the same delivery path. The
|
binary-producing profiles use the same delivery path. The
|
||||||
[consumer delivery guide](../consumers/delivery.md) covers caller workflows;
|
[consumer delivery guide](../consumers/delivery.md) covers caller workflows;
|
||||||
@@ -13,16 +13,30 @@ the `Authorization: token …` request header and is not included in errors or
|
|||||||
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
||||||
a query, or a fragment.
|
a query, or a fragment.
|
||||||
|
|
||||||
For version `vMAJOR.MINOR.PATCH`, the client first reads
|
The selected module derives a complete tag from its semantic version, declared
|
||||||
|
module path, and physical repository-relative directory. For example, version
|
||||||
|
`v1.2.3` in `integrations/ckconfig` uses
|
||||||
|
`integrations/ckconfig/v1.2.3`; a root module uses `v1.2.3`. A `/v2` module
|
||||||
|
stored at `integrations/core/v2` uses `integrations/core/v2.1.0`; the same
|
||||||
|
declared module stored at `integrations/core` derives the same tag. ReleaseKit
|
||||||
|
removes a final physical `/v2` directory from the tag prefix only when the
|
||||||
|
module declaration has that `/v2` suffix. A module-local note stays at
|
||||||
|
`<module>/docs/releases/<version>.md`, including a physical `/v2` directory.
|
||||||
|
The default shared validation convention uses repository-root `COPYRIGHT`,
|
||||||
|
`LICENSE`, and `docs/oss/attribution.md` when present. Consumers own their license
|
||||||
|
layout and release-note links. Use the complete derived tag as the immutable
|
||||||
|
source ref and the actual repository-relative file paths in those links.
|
||||||
|
Shared-file selection controls validation inputs; delivery preserves the
|
||||||
|
committed note and its footer links. The client first reads
|
||||||
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
||||||
SHA to equal the captured source commit. It then reads
|
SHA to equal the captured source commit. It then reads
|
||||||
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
||||||
must have that tag, the complete committed release note as its body, and stable
|
must have that tag, the complete committed release note as its body, and stable
|
||||||
non-draft state. Its existing title is retained. ReleaseKit does not edit a
|
non-draft state. Its existing title is retained. ReleaseKit does not edit a
|
||||||
conflicting page.
|
conflicting page. Slash-containing tags are escaped as one URL path segment.
|
||||||
|
|
||||||
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
|
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
|
||||||
with the selected tag and commit, the release note's H1 as title, the complete
|
with the selected full tag and commit, the release note's H1 as title, the complete
|
||||||
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
||||||
response, or malformed create response (including inconsistent decoded fields)
|
response, or malformed create response (including inconsistent decoded fields)
|
||||||
triggers one lookup. An exact page found
|
triggers one lookup. An exact page found
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
# Go command environments
|
# Go command environments
|
||||||
|
|
||||||
`internal/gotool` resolves effective Go settings once for a validation or build
|
`internal/gotool` resolves effective Go settings when an operation first needs
|
||||||
operation and applies the shared environment to its Go commands. It uses
|
a Go command and applies the shared environment to its commands. Candidate
|
||||||
|
validation and its build callback share an operation-local owner while the
|
||||||
|
inherited environment and persistent Go settings file remain unchanged.
|
||||||
|
Standalone and delivery builds create their own owners. The package uses
|
||||||
`internal/process` for cancellation, the working directory, output, and wrapped
|
`internal/process` for cancellation, the working directory, output, and wrapped
|
||||||
errors.
|
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
|
## Configuration discovery
|
||||||
|
|
||||||
The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then
|
The owner asks the installed `go` command for `GOHOSTOS`, `GOHOSTARCH`, and the
|
||||||
queries effective operational settings with that host selected. Both queries
|
`GOENV` settings-file path, then queries effective operational settings with
|
||||||
use `GOTOOLCHAIN=local` and controlled `GOFLAGS`. Discovery sets
|
that host selected. Both queries use `GOTOOLCHAIN=local` and controlled
|
||||||
|
`GOFLAGS`. Discovery sets
|
||||||
`GOEXPERIMENT=none` and clears architecture feature variables so an invalid or
|
`GOEXPERIMENT=none` and clears architecture feature variables so an invalid or
|
||||||
nonbaseline tuning value cannot prevent settings from being read. The
|
nonbaseline tuning value cannot prevent settings from being read. The
|
||||||
experiment override is discovery-only.
|
experiment override is discovery-only.
|
||||||
@@ -20,6 +52,13 @@ and native cgo/C-tool configuration into its settings. This captures values
|
|||||||
supplied by either the process environment or the persistent Go environment
|
supplied by either the process environment or the persistent Go environment
|
||||||
file.
|
file.
|
||||||
|
|
||||||
|
The operation-local owner snapshots the inherited environment and selected
|
||||||
|
settings file. It reuses the resolved tool across validation and its following
|
||||||
|
build callback while those inputs match. Subsequent Go acquisition rediscovers
|
||||||
|
settings when a consumer callback changes either input. An unavailable snapshot
|
||||||
|
disables reuse while discovery follows the Go tool's accepted configuration.
|
||||||
|
Ownership is operation-local.
|
||||||
|
|
||||||
Configuration discovery parses JSON from stdout. Stderr is captured separately
|
Configuration discovery parses JSON from stdout. Stderr is captured separately
|
||||||
and included in command-failure diagnostics, so a successful query can emit
|
and included in command-failure diagnostics, so a successful query can emit
|
||||||
warnings while supplying valid configuration.
|
warnings while supplying valid configuration.
|
||||||
@@ -41,6 +80,10 @@ baseline values. Effective `GOCACHE`, `GOCACHEPROG`, `GOMODCACHE`, `GOPATH`,
|
|||||||
`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain
|
`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain
|
||||||
available to child commands.
|
available to child commands.
|
||||||
|
|
||||||
Candidate validation uses native mode for the offline `go test` suite. Builds
|
Candidate validation runs the offline `go test` suite and selected full
|
||||||
use static mode for selected-target compilation and the native version-check
|
`go vet` pass from the selected module directory using native mode.
|
||||||
build. The native version executable itself runs as a normal child process.
|
Package-compilation checks use static mode for each selected target and direct
|
||||||
|
Go's output to the operating system null device, so they leave no binary
|
||||||
|
artifact. Artifact builds also compile selected targets in static mode, then
|
||||||
|
build the native version-check executable, which runs as a normal child
|
||||||
|
process.
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Go declarations and GoDoc remain the owners of exact exported contracts.
|
|||||||
## Module and dependencies
|
## Module and dependencies
|
||||||
|
|
||||||
The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language
|
The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language
|
||||||
version `1.25.0`. `go.mod` has no required third-party modules; packages use the
|
version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
|
||||||
standard library and first-party ReleaseKit packages. `go list ./...` reports
|
validation; public facades do not expose either dependency. `go list ./...`
|
||||||
these implemented packages:
|
reports these implemented packages:
|
||||||
|
|
||||||
| Package | Responsibility |
|
| Package | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -37,17 +37,19 @@ these implemented packages:
|
|||||||
| `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. |
|
| `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. |
|
||||||
| `internal/apicheck` | Compiled-export and import-boundary checks run during development. |
|
| `internal/apicheck` | Compiled-export and import-boundary checks run during development. |
|
||||||
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
|
| `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/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. |
|
||||||
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
|
| `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/delivery` | Captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, 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, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
| `internal/git` | Git command boundary, committed-tree reads and shared reference traversal, checkout-root and commit capture, 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/gitea` | Gitea API transport, full-tag 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/gotool` | Operation-local Go configuration discovery and reuse, 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/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/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
||||||
|
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
|
||||||
|
| `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/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. |
|
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
||||||
|
|
||||||
The `buildinfo` facade depends only on its small runtime implementation. The
|
The `buildinfo` facade depends only on its small runtime implementation. The
|
||||||
@@ -58,6 +60,26 @@ dependency direction with `go run ./internal/apicheck`. These constraints are
|
|||||||
also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
|
also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
|
||||||
[architecture policy](../policy/architecture.md).
|
[architecture policy](../policy/architecture.md).
|
||||||
|
|
||||||
|
Candidate checking captures and validates one clean committed source revision
|
||||||
|
in `internal/candidate`. Its validation bridge calls `internal/validation` for
|
||||||
|
the selected module's default offline Go suite, then runs product-specific
|
||||||
|
checks once. Validation and its post-validation build callback share one local
|
||||||
|
Go settings owner. Each Go acquisition checks inherited and persistent inputs;
|
||||||
|
settings are rediscovered when subsequent Go work needs them. An unavailable
|
||||||
|
persistent-settings snapshot disables reuse. The candidate package owns builds and local-state
|
||||||
|
rechecks; publication owns endpoint guards, tag creation, push, and recovery
|
||||||
|
around that same candidate validation path. `internal/validation.CandidateTree` reads
|
||||||
|
committed blobs lazily and resolves local destinations from committed entries;
|
||||||
|
it does not consult neighboring files or traverse submodule checkouts.
|
||||||
|
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||||
|
candidate-file integration mechanics.
|
||||||
|
|
||||||
|
Delivery verifies all local binary sizes and digests before hosted requests.
|
||||||
|
Its Gitea upload path reuses that verification and streams missing files once
|
||||||
|
for transport. Independent calls to the internal client's `UploadMissing`
|
||||||
|
verify missing local files before writing. Both paths share remote inspection,
|
||||||
|
conflict handling, and reconciliation.
|
||||||
|
|
||||||
## Capability owners and behavioral evidence
|
## Capability owners and behavioral evidence
|
||||||
|
|
||||||
The following tests are representative evidence from the current offline suite,
|
The following tests are representative evidence from the current offline suite,
|
||||||
@@ -66,15 +88,20 @@ not a separate release matrix:
|
|||||||
| Capability | Implementation owner | Existing evidence |
|
| Capability | Implementation owner | Existing evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
||||||
| Candidate capture, standard suite, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate` |
|
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
|
||||||
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
|
| 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` |
|
||||||
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
| 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`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `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` |
|
| 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` |
|
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
|
||||||
| 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`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` |
|
||||||
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
||||||
|
| Shared module identity and tag derivation | `internal/releaseidentity`, `internal/candidate`, `internal/build`, `internal/delivery` | `TestDeriveUsesModuleDeclarationAndPhysicalLayout`, `TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources`, `TestSelectedNestedModuleDoesNotRequireRootDeclaration`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages` |
|
||||||
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
|
| 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`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |
|
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/`, `examples/multi-module/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout`, `TestMultiModuleSelectorMapsSelectorsAndTags`, `TestMultiModuleLauncherUsesSelectedModuleAndProfile`, `TestMultiModuleWoodpeckerDelivery` |
|
||||||
|
|
||||||
Run the complete suite with `go test ./...`. The public API and import boundary
|
Run the complete suite with `go test ./...`. The public API and import boundary
|
||||||
check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise
|
check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise
|
||||||
@@ -86,7 +113,3 @@ The [Gitea integration guide](../integrations/gitea.md) owns the HTTP contract
|
|||||||
and hosted response handling. The [Woodpecker examples](../../examples/woodpecker/)
|
and hosted response handling. The [Woodpecker examples](../../examples/woodpecker/)
|
||||||
are prepared integrations; their YAML is not active in this repository, and no
|
are prepared integrations; their YAML is not active in this repository, and no
|
||||||
live Woodpecker runner, configured secret, or hosted delivery has been used.
|
live Woodpecker runner, configured secret, or hosted delivery has been used.
|
||||||
A read-only PromptKit and Narratio migration review and projected deletion
|
|
||||||
estimates are recorded in the [product roadmap](../roadmap/releasekit.md);
|
|
||||||
actual consumer migrations and measurement of realized savings remain future
|
|
||||||
adoption work.
|
|
||||||
|
|||||||
182
docs/internal/validation.md
Normal file
182
docs/internal/validation.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Shared validation mechanics
|
||||||
|
|
||||||
|
`internal/validation` owns reusable checks for one captured candidate.
|
||||||
|
`internal/candidate` captures the clean source revision and keeps the
|
||||||
|
validation sequence shared by local checking and publication. It calls the
|
||||||
|
shared executor, runs product-specific `AdditionalChecks` once, and owns the
|
||||||
|
post-validation build callback and local-state recheck. `internal/publish`
|
||||||
|
adds its endpoint guards, tag creation, push, and recovery around that same
|
||||||
|
candidate path.
|
||||||
|
|
||||||
|
The executor runs one offline Go suite from the captured module directory,
|
||||||
|
optionally with the race detector when selected. When full vet is selected, it
|
||||||
|
disables test-time vet for that suite and runs native
|
||||||
|
`go vet -mod=readonly ./...` from the same directory after the suite succeeds.
|
||||||
|
It then runs `go build -mod=readonly -o <os.DevNull> ./...` there in static mode
|
||||||
|
for each selected package-compilation target, preserving target order and
|
||||||
|
stopping on the first failure. This compiles source without producing an
|
||||||
|
artifact or executing a target binary. Test, vet, and package-compilation
|
||||||
|
failures retain their `go-test`, `go-vet`, and `package-compile` identities,
|
||||||
|
selected target where applicable, and underlying causes. Its
|
||||||
|
request carries the captured repository directory and commit, output writer,
|
||||||
|
and internal settings. When selected, the Go-format check loads the candidate
|
||||||
|
tree once, reads regular Go blobs from the captured commit, and compares them
|
||||||
|
with go/format output. It uses the selected inputs, including vendor and explicit
|
||||||
|
shared files, and skips
|
||||||
|
symlink blobs, reports all unformatted paths, and stops with a contextual cause
|
||||||
|
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||||
|
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||||
|
contents; the Go-only path does not enumerate the tree.
|
||||||
|
|
||||||
|
## Selected validation inputs
|
||||||
|
|
||||||
|
`CandidateTree` keeps the full committed repository lookup separate from the
|
||||||
|
ordered file input set. The selected module's committed inputs
|
||||||
|
exclude independently released nested modules, while vendored contents stay
|
||||||
|
in scope. Selection indexes boundary directories once and checks each entry's
|
||||||
|
ancestors, so unrelated boundaries are not scanned for each file. The
|
||||||
|
[scope benchmark](../../internal/validation/scope_performance_test.go) varies
|
||||||
|
boundary count while retaining 20,000 input files.
|
||||||
|
Explicit shared-file selections are normalized from repository-root
|
||||||
|
paths, added as individual files, and deduplicated with module inputs by path.
|
||||||
|
File-size exclusions are normalized from the selected module directory. Link
|
||||||
|
resolution can still inspect a committed destination outside the selected input
|
||||||
|
set without adding that destination's content to the checks. The public
|
||||||
|
selection, nil/empty behavior, and profile path bases belong to the
|
||||||
|
[configuration reference](../config.md) and the root facade's GoDoc.
|
||||||
|
|
||||||
|
The workspace-file check uses that same candidate inventory and reports blob
|
||||||
|
entries named go.work or go.work.sum, including nested paths and symlink blobs.
|
||||||
|
It does not read workspace contents or depend on the workspace selected for Go
|
||||||
|
commands.
|
||||||
|
The local-replacement check parses selected go.mod blobs with
|
||||||
|
`golang.org/x/mod/modfile`, including vendor and explicitly shared declarations.
|
||||||
|
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. 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 applies normalized literal file/directory
|
||||||
|
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||||
|
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
||||||
|
omitted. The check returns the first oversized path with `FileSizeMeasurement`.
|
||||||
|
The optional Markdown check follows file-size inspection and uses the same
|
||||||
|
candidate tree. It parses regular Markdown blobs, resolves local destinations,
|
||||||
|
and checks supported anchors; outside-checkout and submodule observations are
|
||||||
|
written to the shared output writer. A nil writer keeps library use quiet.
|
||||||
|
`CandidateTree` enumerates full repository path, type, mode, and object metadata
|
||||||
|
without requiring blob contents or sizes. Checks acquire sizes and bytes for
|
||||||
|
their selected inputs and required references. Unreferenced blobs in excluded
|
||||||
|
modules remain outside those reads. Destinations resolve without consulting
|
||||||
|
the worktree or 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.
|
||||||
|
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` 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
|
||||||
|
observations remain out of scope under either setting. Observations go to the
|
||||||
|
provided writer; a nil writer keeps library use quiet. Public selection is
|
||||||
|
defined in the [configuration reference](../config.md) and GoDoc.
|
||||||
|
|
||||||
|
Committed-tree metadata and blob reads disable lazy fetching and Git transports
|
||||||
|
for those child commands. Missing local objects produce inspection errors;
|
||||||
|
source preparation retains its separate network-capable operations. See Git's
|
||||||
|
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
|
||||||
|
Candidate and delivery release-note reads use the same local object boundary.
|
||||||
|
|
||||||
|
Public validation errors convert their diagnostic fields to the internal
|
||||||
|
failure model for shared formatting. Converted failures retain their original
|
||||||
|
contextual cause and reuse its rendering when it already contains the same
|
||||||
|
diagnostics. The CLI presents the complete error chain, including candidate
|
||||||
|
context around callback-supplied diagnostics.
|
||||||
|
|
||||||
|
## Adding a shared check
|
||||||
|
|
||||||
|
When an optional check is accepted for implementation:
|
||||||
|
|
||||||
|
1. Define its typed Go setting in the root facade and its equivalent CLI-owned
|
||||||
|
JSON field. Specify zero-value and omitted-profile behavior in GoDoc and the
|
||||||
|
configuration reference. Keep future settings in the roadmap until their
|
||||||
|
check is implemented.
|
||||||
|
2. Add the corresponding internal setting and explicit conversions. Use the
|
||||||
|
same root request conversion for `Check` and `Publish`, and the shared CLI
|
||||||
|
request conversion for profile-based candidate commands.
|
||||||
|
3. Implement a plain check function owned by `internal/validation`. Keep its
|
||||||
|
rule semantics in the capability owner and pass only the captured candidate
|
||||||
|
inputs it needs. A file-oriented check can call `Request.ReadCandidateTree`
|
||||||
|
and read only the blobs it inspects.
|
||||||
|
4. Return an internal `*Failure` with the check identity, repository-relative
|
||||||
|
findings, useful locations where available, and the underlying cause. Let
|
||||||
|
the root facade convert those records to its own diagnostic types. Preserve
|
||||||
|
cancellation and process errors through `Unwrap`.
|
||||||
|
5. Add the check to the shared executor at the ordering point selected by its
|
||||||
|
capability. Stop on failure. Keep product assertions in `AdditionalChecks`
|
||||||
|
and profile `checks`; keep artifact building in `internal/build`.
|
||||||
|
6. Cover observable behavior through both the Go facade and profile path when
|
||||||
|
both are supported. Check default behavior, useful findings, cause
|
||||||
|
preservation, cancellation where relevant, and failure stopping. Reuse the
|
||||||
|
existing local Git and CLI fixtures.
|
||||||
|
7. Update only the canonical owners for behavior that has landed: GoDoc for the
|
||||||
|
API, configuration and CLI references for profile behavior, and this
|
||||||
|
inventory for internal ownership. Record proposed checks under
|
||||||
|
`docs/roadmap/`.
|
||||||
|
|
||||||
|
Do not add a public validator registration interface. The public specification
|
||||||
|
selects ReleaseKit-owned checks; `AdditionalChecks` remains the extension point
|
||||||
|
for product behavior.
|
||||||
|
|
||||||
|
## Go command environment reuse
|
||||||
|
|
||||||
|
The current executor uses the [shared Go command environment](gotool.md) for
|
||||||
|
its offline suite, vet pass, and selected-target package compilation.
|
||||||
|
|
||||||
|
Reuse `internal/gotool` for native and static environments and target handling.
|
||||||
|
Keep toolchain and module-cache discovery in `gotool`; do not reproduce its
|
||||||
|
environment logic in a checker. Artifact output names, version injection,
|
||||||
|
native smoke checks, and artifact digests remain owned by `internal/build`.
|
||||||
|
|
||||||
|
## File-check coordination
|
||||||
|
|
||||||
|
File checks consume the captured tree and return findings to the shared error
|
||||||
|
model. They do not read sibling worktrees or follow live symlink targets.
|
||||||
|
`CandidateTree.ResolveDestination` returns data describing entries,
|
||||||
|
directories, missing paths, checkout boundaries, submodule paths, and symlink
|
||||||
|
cycles; the caller selects which observations fail validation or are reported
|
||||||
|
as out of scope.
|
||||||
|
|
||||||
|
The shared Git path resolver walks committed components and symlink targets
|
||||||
|
in order using the tree's indexed metadata and lazy symlink reader. Active
|
||||||
|
symlink expansion detects cycles, and traversal honors cancellation. Absolute
|
||||||
|
destinations and symlink targets within the repository directory map back to
|
||||||
|
candidate-relative paths; existence still comes from committed entries.
|
||||||
|
|
||||||
|
The public selection is owned by the facade and profile;
|
||||||
|
the internal checker uses the committed-file and destination-resolution
|
||||||
|
primitives described here.
|
||||||
@@ -1,9 +1,37 @@
|
|||||||
# Open Source Attribution Notices
|
# Open Source Attribution Notices
|
||||||
|
|
||||||
|
## Go Authors
|
||||||
|
|
||||||
|
Selected module: `golang.org/x/mod` at `v0.40.0`.
|
||||||
|
|
||||||
|
Copyright 2009 The Go Authors.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) | [LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
|
||||||
|
|
||||||
|
## Goldmark
|
||||||
|
|
||||||
|
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6`.
|
||||||
|
|
||||||
|
Copyright (c) 2019 Yusuke Inuzuka.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
License and notices: [MIT](licenses/MIT.txt) |
|
||||||
|
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
|
||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,
|
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,
|
||||||
and the Policy Reading Guide in `docs/development.md`, adapted for this repository.
|
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
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
@@ -13,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) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
[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).
|
||||||
|
|||||||
27
docs/oss/licenses/BSD-3-Clause.txt
Normal file
27
docs/oss/licenses/BSD-3-Clause.txt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright 2009 The Go Authors.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* Neither the name of Google LLC nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
21
docs/oss/licenses/MIT.txt
Normal file
21
docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 Yusuke Inuzuka
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
ReleaseKit is an opinionated Go library and release executable for common
|
ReleaseKit is an opinionated Go library and release executable for common
|
||||||
publication, builds, version injection, and delivery. This policy owns
|
publication, builds, version injection, and delivery. This policy owns
|
||||||
responsibility boundaries, dependency direction, and adopted constraints for
|
responsibility boundaries, dependency direction, and adopted constraints for
|
||||||
development. The [product roadmap](../roadmap/releasekit.md) owns proposed
|
development. Roadmaps own proposed capabilities, implementation status, and
|
||||||
capabilities, implementation status, and sequencing.
|
sequencing.
|
||||||
|
|
||||||
The [internal documentation](../internal/overview.md) owns concrete package
|
The [internal documentation](../internal/overview.md) owns concrete package
|
||||||
inventory and implementation mechanics. Canonical public contracts own exact
|
inventory and implementation mechanics. Canonical public contracts own exact
|
||||||
@@ -106,7 +106,7 @@ Source publication and asynchronous delivery have distinct outcomes. The
|
|||||||
declarations and GoDoc define supported consumer API contracts;
|
declarations and GoDoc define supported consumer API contracts;
|
||||||
[consumer guides](../consumers/) explain their use, and
|
[consumer guides](../consumers/) explain their use, and
|
||||||
[integration guides](../integrations/) define external formats and protocols.
|
[integration guides](../integrations/) define external formats and protocols.
|
||||||
The product roadmap records planned capabilities and implementation status.
|
Roadmaps record planned capabilities and implementation status.
|
||||||
|
|
||||||
## Trust Boundaries And Threat Scope
|
## Trust Boundaries And Threat Scope
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**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)
|
**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
|
ReleaseKit's selected grant is LGPL version 3 or later, recorded in
|
||||||
[COPYRIGHT](../../COPYRIGHT). It applies to the library and release
|
[COPYRIGHT](../../COPYRIGHT). It applies to the library and release
|
||||||
executable. Adapted policy documents, agent orientation, and the policy reading
|
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).
|
[attribution document](../oss/attribution.md).
|
||||||
|
|
||||||
Unless the author specifies otherwise, use **GPL version 3 or later**
|
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;
|
grant. This convention introduces no blanket requirement for per-file headers;
|
||||||
preserve notices or add them where the applicable terms require them.
|
preserve notices or add them where the applicable terms require them.
|
||||||
|
|
||||||
Identify separately licensed documentation or assets in the project grant, with
|
Identify separately licensed documentation or assets in the attribution
|
||||||
links to their attribution and license information. An application's default
|
document, with links to their license information. The project grant uses the
|
||||||
code license does not override the terms of incorporated documentation.
|
upstream template's third-party statement.
|
||||||
|
|
||||||
## Upstream Attribution House Format
|
## Upstream Attribution House Format
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# 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
|
## Purpose
|
||||||
|
|
||||||
@@ -12,13 +12,15 @@ This policy defines a proportionate workflow for validating and publishing an
|
|||||||
intended source revision under a fresh version. Releases should require little
|
intended source revision under a fresh version. Releases should require little
|
||||||
routine intervention, avoid repeated work, and preserve published contents.
|
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
|
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||||
policy and link to it rather than introduce additional release requirements.
|
policy and link to it rather than introduce additional release requirements.
|
||||||
|
|
||||||
This policy governs releases of ReleaseKit itself. Go declarations and GoDoc
|
This policy governs releases of ReleaseKit itself. Go declarations and GoDoc
|
||||||
own supported consumer API contracts; the [product roadmap](../roadmap/releasekit.md)
|
own supported consumer API contracts. The maintainer runbook describes
|
||||||
records planned adoption and deployment work. The maintainer runbook describes
|
|
||||||
ReleaseKit's implemented source publication and hosted-page delivery procedure.
|
ReleaseKit's implemented source publication and hosted-page delivery procedure.
|
||||||
|
|
||||||
## Release Model And Completion
|
## Release Model And Completion
|
||||||
@@ -70,7 +72,8 @@ requires a deliberate update to the policy and procedure where needed.
|
|||||||
For releases at or after `v1.0.0`:
|
For releases at or after `v1.0.0`:
|
||||||
|
|
||||||
- a major release may make intentional incompatible changes;
|
- 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.
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
@@ -82,6 +85,43 @@ Additional compatibility promises and deliberate exceptions belong in the
|
|||||||
adopted policy or canonical contract documentation. Release notes explain
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
changes; they do not redefine current compatibility contracts.
|
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
|
## Release Documentation And Preparation
|
||||||
|
|
||||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||||
@@ -133,8 +173,8 @@ checks when their distinct protection justifies their cost. A one-time migration
|
|||||||
check must not silently become a permanent release requirement.
|
check must not silently become a permanent release requirement.
|
||||||
|
|
||||||
Release tests must not require real credentials, mutable application services,
|
Release tests must not require real credentials, mutable application services,
|
||||||
paid APIs, or live service probes. The procedure may describe dependency
|
paid APIs, live service probes, or inference calls. The procedure may describe
|
||||||
preparation separately from offline validation.
|
dependency preparation separately from offline validation.
|
||||||
|
|
||||||
Avoid duplicate validation across release phases. Successful checks for the
|
Avoid duplicate validation across release phases. Successful checks for the
|
||||||
same final candidate need not be repeated solely to satisfy another phase;
|
same final candidate need not be repeated solely to satisfy another phase;
|
||||||
@@ -201,7 +241,8 @@ release without replacing its contents or reusing its version.
|
|||||||
Create and maintain `docs/release.md` with the first working publication command
|
Create and maintain `docs/release.md` with the first working publication command
|
||||||
as a short, executable maintainer runbook. It must identify:
|
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;
|
2. version and release-note preparation;
|
||||||
3. one publication command with its selected local checks and automated guards;
|
3. one publication command with its selected local checks and automated guards;
|
||||||
4. declared deliverables, required build targets, and automation responsibilities;
|
4. declared deliverables, required build targets, and automation responsibilities;
|
||||||
|
|||||||
@@ -12,8 +12,32 @@ succeeded. CI and consumer installation are not completion checks.
|
|||||||
|
|
||||||
## Prepare a candidate
|
## Prepare a candidate
|
||||||
|
|
||||||
Use a local Go 1.25 toolchain and a clean clone. Start on `main`, choose an
|
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.8. Make that
|
||||||
unused stable `vMAJOR.MINOR.PATCH` version, and add
|
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 Go 1.26.0, declared in [go.mod](../go.mod).
|
||||||
|
Keep it for a release unless release preparation deliberately selects a newer
|
||||||
|
series under the [Go support policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
When increasing it, update `go.mod` to the first stable version of the selected
|
||||||
|
series, or to a higher patch when a dependency or specific fix requires one.
|
||||||
|
Make module changes before committing the candidate. 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
|
`docs/releases/<version>.md` with a first heading containing `ReleaseKit` and
|
||||||
the exact version. Summarize the change and any compatibility or upgrade impact.
|
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:
|
End the note with the three license links at the immutable source tag:
|
||||||
@@ -121,6 +145,7 @@ immutable version from its tag:
|
|||||||
```sh
|
```sh
|
||||||
git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z"
|
git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z"
|
||||||
git checkout --detach "vX.Y.Z"
|
git checkout --detach "vX.Y.Z"
|
||||||
|
GOTOOLCHAIN=local go mod download
|
||||||
mkdir -p .releasekit/bin
|
mkdir -p .releasekit/bin
|
||||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
||||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z"
|
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||||
|
|||||||
106
docs/releases/v0.2.0.md
Normal file
106
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ReleaseKit v0.2.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit adds declarative candidate validation and module-selected release
|
||||||
|
tooling for Woodpecker. Consumers can replace repeated release checks with typed
|
||||||
|
Go selections or equivalent profile settings, while retaining product-specific
|
||||||
|
assertions through `AdditionalChecks` and profile `checks`.
|
||||||
|
|
||||||
|
## New features
|
||||||
|
|
||||||
|
- A facade-owned `ValidationSpec` on `ReleaseRequest` and corresponding profile
|
||||||
|
`validation` object select shared checks for both `Check` and `Publish`.
|
||||||
|
Structured `ValidationError` diagnostics identify the failed check, relevant
|
||||||
|
paths and locations, compilation targets, directives, and size measurements.
|
||||||
|
Wrapping preserves underlying causes and cancellation.
|
||||||
|
- Race testing selects the race detector for the one standard Go suite. Separate
|
||||||
|
full vet automatically disables test-time vet and runs after successful tests.
|
||||||
|
Selected package-compilation targets run afterward with `CGO_ENABLED=0`,
|
||||||
|
independently of executable artifacts. Source-only libraries can check target
|
||||||
|
compatibility and retain page-only delivery.
|
||||||
|
- Go formatting checks inspect committed regular Go source and report formatting
|
||||||
|
differences while preserving files.
|
||||||
|
- Offline Markdown validation checks local destinations and supported heading
|
||||||
|
fragments, including inline/reference links, images, and autolinks. Heading
|
||||||
|
anchors support visible text, duplicate headings, and Unicode. Outside-checkout
|
||||||
|
destinations are reported as out of scope by default; consumers can select
|
||||||
|
boundary enforcement. Submodule contents remain out of scope.
|
||||||
|
- Independent module-distribution selections inspect committed workspace files
|
||||||
|
and filesystem module replacements. Versioned replacements are permitted.
|
||||||
|
- Optional file-size validation checks individual committed blobs, including
|
||||||
|
symlink text, with a configurable limit and literal file/directory exclusions.
|
||||||
|
Enabling the check selects a default limit of 1,000,000 bytes; only sizes
|
||||||
|
strictly greater than the selected limit fail.
|
||||||
|
- `ExpectedPushURL` and profile `expected_push_url` require an exact match with
|
||||||
|
Git's effective push destination during publication preflight. A mismatch
|
||||||
|
returns `ErrUnexpectedPushURL` before candidate validation or tag creation.
|
||||||
|
Local `Check` ignores the publication expectation.
|
||||||
|
- The maintained Woodpecker consumer template selects ReleaseKit from the
|
||||||
|
released application's module configuration. It verifies the event tag and
|
||||||
|
commit before building the tool, prepares tool dependencies in temporary module
|
||||||
|
metadata, and preserves tagged module files. Retries use the triggering tag's
|
||||||
|
dependency selection. ReleaseKit's self-bootstrap template also supports
|
||||||
|
fetching missing tool dependencies after tag verification.
|
||||||
|
|
||||||
|
See the [configuration reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/config.md),
|
||||||
|
[candidate check guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/check.md),
|
||||||
|
[publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/publish.md),
|
||||||
|
and [delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/delivery.md)
|
||||||
|
for supported behavior and adoption examples.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The minimum Go version remains 1.25.0. The root orchestration facade and separate
|
||||||
|
lightweight `buildinfo` runtime facade keep their existing responsibilities.
|
||||||
|
Goldmark and `golang.org/x/mod` are new internal dependencies; prepare the module
|
||||||
|
cache before offline validation or builds.
|
||||||
|
|
||||||
|
Omitted validation selections preserve v0.1.0 defaults. Existing profiles and
|
||||||
|
product-specific checks continue to work. Race testing requires the native
|
||||||
|
toolchain's race prerequisites and reports an error when they are unavailable.
|
||||||
|
Markdown and file-size checks are enabled by non-nil Go specifications or profile
|
||||||
|
objects, including empty objects; omission or `null` disables them.
|
||||||
|
|
||||||
|
`ReleaseRequest` gains fields, so callers using positional struct literals must
|
||||||
|
convert them to keyed literals. Validation diagnostics now include structured
|
||||||
|
detail and additional context. Go callers can inspect `ValidationError` with
|
||||||
|
`errors.As` and underlying causes with `errors.Is`; CLI integrations should use
|
||||||
|
exit classifications rather than complete diagnostic strings.
|
||||||
|
|
||||||
|
Source publication still completes at confirmed publication of the intended
|
||||||
|
annotated tag. Hosted Gitea delivery retains its separate status. ReleaseKit's
|
||||||
|
deliverables remain the source tag and hosted note page.
|
||||||
|
|
||||||
|
## Upgrade guidance
|
||||||
|
|
||||||
|
1. Update the module dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers using a separately installed executable can update it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use v0.2.0 tooling before adding new profile fields. Select the shared checks
|
||||||
|
appropriate to the product and remove the corresponding duplicate commands
|
||||||
|
from additional checks. Retain assertions about product behavior. Package
|
||||||
|
compilation belongs under validation; executable deliverables use `build`.
|
||||||
|
|
||||||
|
3. For module-based CI, adopt the updated
|
||||||
|
[tag-delivery template](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/examples/woodpecker/tag-delivery.yml)
|
||||||
|
and remove the independent `RELEASEKIT_VERSION` workflow pin. Ensure the
|
||||||
|
application module declares ReleaseKit, align local tool bootstrap with that
|
||||||
|
selection, and retain the product's Go image, delivery profile, and secret
|
||||||
|
mapping. The
|
||||||
|
[Woodpecker guidance](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/delivery.md#woodpecker-tag-workflows)
|
||||||
|
describes temporary module metadata, older-tag retries, self-bootstrap, and
|
||||||
|
separately installed tools.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/oss/attribution.md)
|
||||||
117
docs/releases/v0.3.0.md
Normal file
117
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ReleaseKit v0.3.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit reduces repeated Git processes, validation memory retention, artifact
|
||||||
|
hashing, output copying, and runtime metadata parsing. It also improves
|
||||||
|
cancellation during validation shutdown and documents module-selected CLI
|
||||||
|
adoption and checksum recovery.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Committed-file checks share one lazy Git batch reader per validation operation.
|
||||||
|
Metadata-only checks start no content reader. Cancellation remains active
|
||||||
|
through reader shutdown, and cleanup reaps the child before validation returns.
|
||||||
|
- Validation releases regular-file source bytes after inspection and reuses
|
||||||
|
compact or parsed results by blob identity for Go formatting, module
|
||||||
|
replacements, Markdown documents, and symlink targets. Findings retain each
|
||||||
|
source path's locations and destination context.
|
||||||
|
- Managed and prebuilt delivery use one local asset verification pass before
|
||||||
|
hosted requests, removing the adjacent declaration-time hashing pass. Changed
|
||||||
|
assets still fail verification; exact remote comparisons, additive retries,
|
||||||
|
and partial-delivery reporting retain their behavior.
|
||||||
|
- Go commands transfer completed output buffers without an additional full
|
||||||
|
copy. Status-only successes release their captures, while failures retain
|
||||||
|
complete diagnostics and underlying causes. Capture remains in memory;
|
||||||
|
allocation costs depend on output size and write chunking.
|
||||||
|
- The runtime version accessor caches its build-metadata fallback safely across
|
||||||
|
calls. Linker-injected versions retain precedence, and the development fallback
|
||||||
|
remains unchanged.
|
||||||
|
- Shutdown errors are attributed to the last validation check that actually read
|
||||||
|
content. Selecting a later check with no applicable files preserves that
|
||||||
|
attribution, and an earlier check failure remains the primary error.
|
||||||
|
- CLI-only consumers have documented adoption through versioned Go tool
|
||||||
|
dependencies. The Woodpecker template supports both tool dependencies and
|
||||||
|
ordinary package dependencies selected by the tagged consumer module.
|
||||||
|
- Synthetic-module workflow tests use isolated module caches. New checksum
|
||||||
|
investigation guidance explains how to distinguish stale local metadata from
|
||||||
|
a download failure while preserving checksum verification and published tags.
|
||||||
|
|
||||||
|
Performance gains depend on the consumer's workload. The changes preserve
|
||||||
|
complete in-memory diagnostics and existing validation selections.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The declared minimum Go version increases from 1.25.0 to 1.26.0. Consumers
|
||||||
|
require Go 1.26.0 or newer; dependency versions are unchanged from v0.2.0.
|
||||||
|
ReleaseKit's maintainer build toolchain and maintained Woodpecker template images
|
||||||
|
use Go 1.26.8; consumers select a local toolchain compatible with their module
|
||||||
|
and the selected ReleaseKit version.
|
||||||
|
|
||||||
|
The root orchestration facade, separate lightweight `buildinfo` facade, public
|
||||||
|
signatures, CLI commands, and profile schema are unchanged. Existing v0.2.0
|
||||||
|
callers and profiles require no source or configuration migration.
|
||||||
|
|
||||||
|
Validation cleanup may now return the caller's cancellation cause where shutdown
|
||||||
|
previously remained blocked. Cleanup failures identify the check that used the
|
||||||
|
reader. Inspect structured validation errors with `errors.As` and underlying
|
||||||
|
causes with `errors.Is`.
|
||||||
|
|
||||||
|
Source publication and hosted delivery retain their separate completion states.
|
||||||
|
ReleaseKit's deliverables remain the source tag and hosted Gitea note page.
|
||||||
|
|
||||||
|
## Upgrade guidance
|
||||||
|
|
||||||
|
1. Use Go 1.26.0 or newer for local tooling and CI before selecting v0.3.0.
|
||||||
|
Updating ReleaseKit in a shared module graph can raise the consumer's declared
|
||||||
|
Go minimum. Select the dependency form appropriate to the consumer.
|
||||||
|
|
||||||
|
Go API consumers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.3.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers using ReleaseKit for repository release commands can register or
|
||||||
|
upgrade the CLI as a Go tool dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
|
||||||
|
go mod tidy
|
||||||
|
go tool releasekit version
|
||||||
|
```
|
||||||
|
|
||||||
|
Review and commit the resulting module changes. API and tool dependencies
|
||||||
|
share the consumer's module graph and selected ReleaseKit version.
|
||||||
|
|
||||||
|
A separately installed executable can be updated with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Retain existing validation and delivery selections. The performance changes
|
||||||
|
apply automatically. Prepare dependencies before offline checks or builds,
|
||||||
|
and run the consumer's ordinary tests after upgrading.
|
||||||
|
|
||||||
|
3. For module-selected Woodpecker delivery, commit the dependency upgrade before
|
||||||
|
tagging the consumer. Choose a compatible local Go image; delivery and retries
|
||||||
|
continue to use the triggering tag's module selection. CLI-only consumers can
|
||||||
|
use the tool declaration to retain ReleaseKit through `go mod tidy`.
|
||||||
|
|
||||||
|
4. If dependency preparation reports a checksum mismatch, follow the
|
||||||
|
[checksum investigation guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md#investigating-module-checksum-mismatches).
|
||||||
|
Repair a confirmed stale cache entry while retaining checksum verification
|
||||||
|
and the application's recorded checksums.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[adoption guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/adoption.md),
|
||||||
|
[candidate check guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/check.md),
|
||||||
|
[delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md),
|
||||||
|
and [runtime version guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/runtime-version.md)
|
||||||
|
for supported behavior and usage.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/oss/attribution.md)
|
||||||
141
docs/releases/v0.4.0.md
Normal file
141
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# ReleaseKit v0.4.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit supports releasing individual Go modules within a repository, with
|
||||||
|
module-specific validation, builds, tags, and Gitea release pages. Root-module
|
||||||
|
consumers retain the simple default configuration. This release also improves
|
||||||
|
path resolution, offline source inspection, failure diagnostics, and efficiency.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- `Check`, `Publish`, `Build`, and `Deliver` accept `ModuleDir` and expose the
|
||||||
|
resolved module directory, declared module path, and complete tag in their
|
||||||
|
results. Each operation resolves its module identity once. Product commands
|
||||||
|
receive repository and module context, and candidate callbacks receive module
|
||||||
|
identity.
|
||||||
|
- Release tags follow the selected module's physical location and declared major
|
||||||
|
version. For example, `v2.1.0` for
|
||||||
|
`example.com/project/integrations/core/v2` uses
|
||||||
|
`integrations/core/v2.1.0` whether its physical directory is
|
||||||
|
`integrations/core` or `integrations/core/v2`. Its note remains at that physical
|
||||||
|
module's `docs/releases/v2.1.0.md`. Binary version output and asset names retain
|
||||||
|
the semantic version; Git publication and Gitea delivery use the complete tag.
|
||||||
|
- Profiles add `module_dir`. Go checks and configured product commands run from
|
||||||
|
the selected module. Automatic committed-file inputs stop at independent
|
||||||
|
nested modules; vendored files remain included. Nested fixtures remain
|
||||||
|
available to the parent module's product tests.
|
||||||
|
- `ValidationSpec.SharedFiles` and profile `validation.shared_files` select
|
||||||
|
repository-relative literal files for optional file checks. Omission selects
|
||||||
|
the available root copyright, license, and attribution defaults; an explicit
|
||||||
|
list replaces them, and an explicit empty list selects none. Explicit files
|
||||||
|
must exist even with checks disabled. Selection preserves the enabled checks
|
||||||
|
and Go test scope; reference inspection can read destinations elsewhere in
|
||||||
|
the captured repository.
|
||||||
|
- Module releases receive separate Gitea pages, including source-only modules.
|
||||||
|
Relative build output and prebuilt delivery paths share the selected module
|
||||||
|
as their base. Prebuilt receipts bind artifacts to module identity, semantic
|
||||||
|
version, complete tag, source commit, and normalized build settings.
|
||||||
|
- `ci-deliver` accepts the complete event tag. Prepared mode verifies a checkout
|
||||||
|
already detached at that tag, and expected-module and expected-tag checks bind
|
||||||
|
delivery to the CI selection before building or contacting Gitea. The
|
||||||
|
multi-module example shares a module map between its local launcher and
|
||||||
|
Woodpecker workflow. The workflow verifies the tag before reading the mapping,
|
||||||
|
profile, or tool selection, preserving the tagged configuration for retries.
|
||||||
|
|
||||||
|
## Reliability and efficiency
|
||||||
|
|
||||||
|
- Operations resolve the containing Git checkout root when invoked from a child
|
||||||
|
directory. Working-tree and committed module paths resolve symlinks consistently,
|
||||||
|
including paths containing `..`. Committed identity comes from Git objects;
|
||||||
|
page-only delivery can use committed module metadata absent from the worktree.
|
||||||
|
Valid local main-module paths are accepted.
|
||||||
|
- Build failures preserve available identity and settings alongside completed
|
||||||
|
artifacts. Invalid versions retain the public error identity and the CLI's
|
||||||
|
usage-error status. Prebuilt delivery accepts equivalent checkout paths while
|
||||||
|
retaining source and module verification.
|
||||||
|
- Committed release-note and validation reads remain offline in partial clones.
|
||||||
|
Validation reads blob contents and sizes as needed for selected inputs and
|
||||||
|
referenced destinations. Unavailable blobs outside that inspection scope do
|
||||||
|
not prevent applicable checks.
|
||||||
|
- Module-file selection uses ancestor boundary lookups. Operations reuse Go
|
||||||
|
environment discovery while its inputs remain suitable for reuse and acquire
|
||||||
|
Go settings only when Go work needs them. Delivery reuses its local asset
|
||||||
|
verification when uploading missing assets, removing a repeated hashing pass.
|
||||||
|
|
||||||
|
These improvements depend on the repository and workload. Artifact verification,
|
||||||
|
exact remote comparisons, additive retries, and partial-delivery reporting retain
|
||||||
|
their behavior.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This pre-1.0 minor release introduces a clean API and receipt cutover:
|
||||||
|
|
||||||
|
- `TaggedCheckoutRequest.Version` becomes `Tag`, containing the complete Git
|
||||||
|
tag. `VerifyOnly` adds verification of an already prepared checkout.
|
||||||
|
- `PrepareBuildDependencies` now accepts `DependencyPreparationRequest` with
|
||||||
|
repository and module directories.
|
||||||
|
- The private build receipt requires schema version 1 and module identity.
|
||||||
|
Existing receipts must be regenerated before prebuilt delivery.
|
||||||
|
- Root-module committed-file checks exclude independent nested modules. Select
|
||||||
|
those modules separately when their files need release validation.
|
||||||
|
|
||||||
|
The Go minimum remains 1.26.0. Root-module releases retain their existing note
|
||||||
|
path, unprefixed tags, profile defaults, and ordinary workflow without a module
|
||||||
|
map. The root orchestration facade and separate lightweight `buildinfo` facade
|
||||||
|
retain their roles; runtime version access requires no migration.
|
||||||
|
|
||||||
|
Source-tag publication remains the source-release completion point. Hosted page
|
||||||
|
and asset delivery remains asynchronous and can be retried from the same
|
||||||
|
immutable tag. ReleaseKit's deliverables remain its source tag and hosted note
|
||||||
|
page.
|
||||||
|
|
||||||
|
## Upgrade guidance
|
||||||
|
|
||||||
|
1. Select v0.4.0 in each consumer module that uses ReleaseKit. For API consumers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.4.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
For consumers using the CLI as a Go tool dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.4.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Review and commit the resulting module and checksum changes together with
|
||||||
|
caller, profile, and workflow updates. Keep historical tag retries paired
|
||||||
|
with the tool and workflow recorded at that tag.
|
||||||
|
|
||||||
|
2. Update preparation API calls for `Tag` and `DependencyPreparationRequest`.
|
||||||
|
For nested modules, set `ModuleDir` or profile `module_dir` to the physical
|
||||||
|
module location, with its own `go.mod` declaration. Keep ordinary `Version`
|
||||||
|
arguments as semantic versions. Use result `ModuleDir`, `ModulePath`, and
|
||||||
|
`Tag` where module identity is needed. Product commands can distinguish their
|
||||||
|
contexts through `RELEASEKIT_REPOSITORY_DIR` and `RELEASEKIT_MODULE_DIR`.
|
||||||
|
|
||||||
|
3. Update `ci-deliver` invocations to pass the full event tag and required
|
||||||
|
`--commit OID`. Use `--prepared` after bootstrap verification and
|
||||||
|
`--expected-module DIR` for mapped launchers. Prepare and commit each binary
|
||||||
|
module's checksums before tagging so dependency preparation leaves tagged
|
||||||
|
source clean. Root-only consumers can retain the ordinary tag workflow.
|
||||||
|
|
||||||
|
4. Rebuild existing prebuilt outputs with the v0.4.0 `build` command before
|
||||||
|
`deliver --assets DIR`. Relative output and asset paths both resolve from the
|
||||||
|
selected module. Review optional shared-file selections and nested-module
|
||||||
|
validation coverage, then run the consumer's ordinary checks.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[adoption guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/consumers/adoption.md),
|
||||||
|
[configuration reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/config.md),
|
||||||
|
[CLI contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/cli.md),
|
||||||
|
[delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/consumers/delivery.md),
|
||||||
|
and [multi-module example](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/examples/multi-module/README.md)
|
||||||
|
for supported usage.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/oss/attribution.md)
|
||||||
29
docs/releases/v0.4.1.md
Normal file
29
docs/releases/v0.4.1.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ReleaseKit v0.4.1
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This patch synchronizes shared state in the delivery and Gitea HTTP test
|
||||||
|
fixtures. It makes the test suite reliable under concurrent request handling
|
||||||
|
and the Go race detector. Production code and public behavior are unchanged.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Protect delivery fixture state with mutexes and independent snapshots; use
|
||||||
|
atomic observations for independent Gitea fixture counters and flags.
|
||||||
|
- Keep fatal test assertions in the test goroutine and ensure release test
|
||||||
|
servers close with their owning tests.
|
||||||
|
- Preserve existing delivery, conflict, retry, and source-tag assertions while
|
||||||
|
exercising the synchronized fixtures.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
There are no API, configuration, or runtime behavior changes, and no consumer
|
||||||
|
migration is required. The Go minimum remains 1.26.0. The published v0.4.0 tag
|
||||||
|
and its contents are unchanged.
|
||||||
|
|
||||||
|
Consumers may upgrade to v0.4.1 with their existing API, profile, and workflow
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/oss/attribution.md)
|
||||||
36
docs/releases/v0.4.2.md
Normal file
36
docs/releases/v0.4.2.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# ReleaseKit v0.4.2
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This patch corrects Woodpecker delivery bootstrap for events whose
|
||||||
|
`CI_COMMIT_SHA` identifies an annotated tag object. The maintained workflows
|
||||||
|
resolve the event object to its source commit before verification and delivery.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Correct module-selected, multi-module, and ReleaseKit source delivery
|
||||||
|
workflows to resolve event and tag commits independently and pass the
|
||||||
|
resolved event commit to `ci-deliver`.
|
||||||
|
- Report both commit identities when an event and its selected tag disagree.
|
||||||
|
- Extend executable-workflow regression coverage for annotated tag-object
|
||||||
|
events, annotated and lightweight commit events, resolved delivery arguments,
|
||||||
|
and rejection of unrelated commits.
|
||||||
|
|
||||||
|
## Compatibility and upgrade guidance
|
||||||
|
|
||||||
|
Public APIs, CLI contracts, configuration formats, and the Go 1.26.0 consumer
|
||||||
|
minimum retain their v0.4 definitions. `ci-deliver --commit` continues to accept
|
||||||
|
the expected source commit ID.
|
||||||
|
|
||||||
|
Update copied workflow YAML from the maintained examples when adopting this
|
||||||
|
patch. Upgrading the ReleaseKit dependency alone leaves copied workflows
|
||||||
|
unchanged. The [delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/consumers/delivery.md#woodpecker-tag-workflows)
|
||||||
|
describes event resolution, workflow adoption, and delivery recovery.
|
||||||
|
|
||||||
|
Existing published tags and contents remain unchanged. Recover a failed older
|
||||||
|
workflow from its original tagged source using the resolved event commit through
|
||||||
|
the documented delivery command, preserving existing hosted content.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/oss/attribution.md)
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# ReleaseKit: shared release process
|
|
||||||
|
|
||||||
Status: Initial implementation, policy adoption, and architecture remediation
|
|
||||||
are complete. Consumer adoption and live CI integration work remain.
|
|
||||||
|
|
||||||
This roadmap records the shared release-process opportunity identified in
|
|
||||||
PromptKit, ConfigKit, Audita, Notarius, Narratio, and Scriptorium on 2026-09-29,
|
|
||||||
and the remaining adoption goals. The [internal package inventory](../internal/overview.md)
|
|
||||||
records implemented capability owners and behavioral evidence. The
|
|
||||||
[architecture policy](../policy/architecture.md) and
|
|
||||||
[ADR 0001](../adr/0001-public-facade.md) own the facade boundaries and rationale.
|
|
||||||
The completed implementation, policy adoption, and remediation plans are
|
|
||||||
preserved in Git history.
|
|
||||||
|
|
||||||
## Opportunity
|
|
||||||
|
|
||||||
All six repositories follow the same base release policy. Their release scripts
|
|
||||||
repeat stable-version parsing, candidate and release-note checks, local and
|
|
||||||
remote tag checks, validation of one captured commit, explicit Git publication,
|
|
||||||
and recovery guidance for uncertain pushes. They differ in publication approach,
|
|
||||||
tag type, validation commands, and artifact delivery. The mechanics of embedding
|
|
||||||
a release version and cross-building an application are further candidates for
|
|
||||||
standardization.
|
|
||||||
|
|
||||||
If the projects deliberately standardize publication approach and tag type,
|
|
||||||
ReleaseKit can own most of the repeated release-process code. The current six
|
|
||||||
repositories contain 942 lines of release shell scripts and 742 lines of
|
|
||||||
dedicated release tests, or 1,684 lines total. A directional estimate is that
|
|
||||||
roughly 1,000–1,250 lines could leave those repositories: about 450–500 lines
|
|
||||||
of publication scripts, 150–250 lines of checks and builders, and 400–500 lines
|
|
||||||
of repeated publication tests. ReleaseKit would gain its own implementation and
|
|
||||||
tests, so this is a reduction in local code, not a net line-count saving.
|
|
||||||
|
|
||||||
## Selected publication standard
|
|
||||||
|
|
||||||
1. Prepare and commit a version-matched note at `docs/releases/<version>.md`.
|
|
||||||
2. Publish the candidate commit to authoritative `main` before invoking the
|
|
||||||
release command.
|
|
||||||
3. Run one command that checks the candidate and unused stable version, runs
|
|
||||||
that repository's offline candidate validation once, and rechecks mutable
|
|
||||||
state.
|
|
||||||
4. Create an annotated tag at the validated commit with a message derived from
|
|
||||||
the checked-in note. Push only that exact tag object to the authoritative
|
|
||||||
endpoint. A successful tag push completes the source release.
|
|
||||||
5. Treat hosted release pages and binary assets as asynchronous delivery with
|
|
||||||
their own status. Do not poll CI or require artifact availability to declare
|
|
||||||
the source release complete.
|
|
||||||
|
|
||||||
This would replace Audita's atomic branch-and-tag push, ConfigKit's and
|
|
||||||
Notarius's branch push during the release command, and Audita's, Notarius's,
|
|
||||||
and Narratio's lightweight tags. Those changes require updates to each
|
|
||||||
repository's adopted release policy and procedure; they are not merely code
|
|
||||||
cleanup.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
[ADR 0001](../adr/0001-public-facade.md) owns the accepted facade decision.
|
|
||||||
The [architecture policy](../policy/architecture.md) owns the adopted
|
|
||||||
responsibility and dependency rules, including the runtime version boundary.
|
|
||||||
The root `releasekit` package provides the public facade for candidate
|
|
||||||
validation, source publication, selected-target builds, and tagged-source
|
|
||||||
delivery. The Gitea page and asset-delivery client is integrated through that
|
|
||||||
facade and the CLI. The separate public `buildinfo` package provides lightweight
|
|
||||||
runtime version access.
|
|
||||||
|
|
||||||
Each facade owns its public named types and documented contracts. Public
|
|
||||||
operations, inputs, outcomes, errors, and callbacks express deliberately
|
|
||||||
selected consumer needs. Handles retain private implementation state; explicit
|
|
||||||
forwarding and conversions connect the public contracts to internal
|
|
||||||
representations.
|
|
||||||
|
|
||||||
Git operations, Go toolchain execution, Gitea integration, and orchestration
|
|
||||||
bookkeeping belong under Go's `internal/` boundary. Dependency direction runs
|
|
||||||
from the facades into internal implementation, with internal collaborators using
|
|
||||||
contracts defined inside that boundary. Implementation packages can evolve
|
|
||||||
while the supported public contracts retain their compatibility obligations.
|
|
||||||
|
|
||||||
The `buildinfo` facade keeps runtime version access lightweight. Applications
|
|
||||||
read their embedded version through its supported accessor. ReleaseKit's builder
|
|
||||||
and runtime implementation own the linker symbol and injection mechanism
|
|
||||||
together.
|
|
||||||
|
|
||||||
The `buildinfo` facade follows three constraints:
|
|
||||||
|
|
||||||
- Its public API exposes only runtime information justified by consumer needs.
|
|
||||||
- Its implementation uses the standard library and a small internal dependency
|
|
||||||
graph independent of release orchestration.
|
|
||||||
- Dependency checks protect that boundary, and build-and-run integration tests
|
|
||||||
verify that the builder injects the selected version and the public accessor
|
|
||||||
reports it correctly.
|
|
||||||
|
|
||||||
The release executable calls the orchestration facade and handles command
|
|
||||||
parsing, output, and exit codes. Library errors and outcomes report meaningful
|
|
||||||
publication state, including partial progress and uncertainty. Go declarations
|
|
||||||
and GoDoc own exact public API contracts; additional public packages and
|
|
||||||
extension interfaces require a deliberate decision grounded in durable consumer
|
|
||||||
needs.
|
|
||||||
|
|
||||||
Compiled-export and dependency checks protect facade ownership.
|
|
||||||
External-package behavioral tests protect error identity, value ownership,
|
|
||||||
callback lifetime, and observable outcomes across forwarding and conversions.
|
|
||||||
|
|
||||||
## Shared tool boundary
|
|
||||||
|
|
||||||
Provide a versioned ReleaseKit executable backed by a Go library. Existing
|
|
||||||
shell entry points can become short invocations of that executable. A library
|
|
||||||
alone would still require a caller in every shell-driven repository.
|
|
||||||
|
|
||||||
ReleaseKit should own:
|
|
||||||
|
|
||||||
- stable-version parsing and committed-note identity checks;
|
|
||||||
- authoritative Git endpoint resolution, candidate capture, clean `main` and
|
|
||||||
remote-branch agreement checks, and unused local/remote tag checks;
|
|
||||||
- one validation invocation bound to the captured candidate, followed by cheap
|
|
||||||
rechecks before publication;
|
|
||||||
- annotated-tag creation and verification, explicit tag-object push, and one
|
|
||||||
bounded authoritative-ref check after a failed or ambiguous push;
|
|
||||||
- a standard version accessor in the public `buildinfo` facade, backed by
|
|
||||||
release-build injection of the selected tag;
|
|
||||||
- a cross-build API that accepts the application's main package and selected
|
|
||||||
targets, initially Linux amd64, Linux arm64, and macOS arm64 (Go's
|
|
||||||
`darwin/arm64`), including an empty set for source-only projects;
|
|
||||||
- consistent release build flags and asset naming, plus a host-runnable build
|
|
||||||
that checks the executable's reported version; and
|
|
||||||
- Git publication tests using temporary local remotes, including remote
|
|
||||||
advancement, rejected pushes, lost acknowledgments, and unavailable remotes.
|
|
||||||
|
|
||||||
Each repository should retain its product name, compatibility decisions,
|
|
||||||
candidate-specific checks, main package, selected build targets, delivery
|
|
||||||
declaration, and a small test of its ReleaseKit wiring. For example, PromptKit
|
|
||||||
exercises its consumer examples. Applications would adapt their existing
|
|
||||||
version output to use the `buildinfo` accessor; one agreed version command
|
|
||||||
or flag would let ReleaseKit smoke-test a host-runnable build the same way.
|
|
||||||
Cross-built binaries would use the same injection mechanism, but compilation
|
|
||||||
alone would not establish that they run correctly on another platform.
|
|
||||||
ReleaseKit should run the selected checks and builds without silently adding
|
|
||||||
binary gates to source-only projects.
|
|
||||||
|
|
||||||
The build API should own the mechanics rather than require each application to
|
|
||||||
provide a custom linker symbol or shell loop. The initial target list reflects
|
|
||||||
the current projects, not a permanent limit on future platforms. It should
|
|
||||||
support both candidate cross-build checks and artifact builds from the tagged
|
|
||||||
source, using the same version and target selection.
|
|
||||||
|
|
||||||
## Woodpecker and Gitea delivery
|
|
||||||
|
|
||||||
A common tag-triggered Woodpecker workflow can run ReleaseKit against the
|
|
||||||
tagged source. Repository-local YAML should declare the pinned tool image and
|
|
||||||
credentials; each repository's ReleaseKit configuration should declare its
|
|
||||||
delivery destination and any binary main package and target set. ReleaseKit can
|
|
||||||
own version injection, cross-builds, checksum generation, and delivery. All
|
|
||||||
projects should receive hosted Gitea
|
|
||||||
release pages. Source-only repositories declare zero binary assets and deliver
|
|
||||||
the committed release note without a binary build or checksum manifest.
|
|
||||||
|
|
||||||
The shared delivery command should create or find the release for the tag,
|
|
||||||
use the checked-in note, and add declared binary assets and their checksum
|
|
||||||
manifest where applicable. A retry should inspect existing assets, verify
|
|
||||||
matching content, add only missing items, and stop on a conflict.
|
|
||||||
An existing checksum manifest must remain consistent with any later addition.
|
|
||||||
The release and its attachments are separate remote operations, so partial
|
|
||||||
delivery must be expected and reported clearly.
|
|
||||||
|
|
||||||
Audita, Narratio, and Scriptorium currently use the Woodpecker release plugin
|
|
||||||
with `file-exists: skip` and `overwrite: false`. Those settings avoid deliberate
|
|
||||||
replacement by filename, but they do not prove that a rebuilt file has the
|
|
||||||
same bytes as a previously published file. Content comparison would be a
|
|
||||||
reason to implement a shared Gitea delivery client rather than rely solely on
|
|
||||||
the plugin's skip behavior.
|
|
||||||
|
|
||||||
Hosted pages are selected for source-only projects, including PromptKit,
|
|
||||||
ConfigKit, Notarius, and ReleaseKit. Adoption adds a page deliverable and delivery
|
|
||||||
credentials to their procedures while preserving successful tag publication as
|
|
||||||
source completion. The initial implementation should support both page-only and
|
|
||||||
binary delivery through the same facade and command.
|
|
||||||
|
|
||||||
## Failure states
|
|
||||||
|
|
||||||
ReleaseKit should report source publication and artifact delivery separately:
|
|
||||||
|
|
||||||
| Event | Result |
|
|
||||||
| --- | --- |
|
|
||||||
| Candidate validation or a pre-push guard fails | No release was published; correct and validate the candidate again. |
|
|
||||||
| Tag push reports success | Source release is complete; delivery may still be pending. |
|
|
||||||
| Tag push fails or its acknowledgment is lost | Query the authoritative ref once. Confirm success only if the exact intended tag object is present on the intended commit; otherwise report failed or unknown status and retain the local tag. |
|
|
||||||
| Woodpecker or Gitea delivery fails after tag publication | Keep the source release successful. Retry missing delivery from the immutable tag after checking existing content. |
|
|
||||||
| Published source or content needs correction | Publish a new version; never replace content under the published version. |
|
|
||||||
|
|
||||||
## Consumer adoption target
|
|
||||||
|
|
||||||
All six projects should use the same selected publication, runtime version,
|
|
||||||
build, and delivery conventions where those responsibilities apply. Repository
|
|
||||||
entry points should contain only tool invocation and product-specific settings
|
|
||||||
or checks. Shared mechanics and their repeated tests belong in ReleaseKit.
|
|
||||||
|
|
||||||
Application runtime code should obtain version information through `buildinfo`.
|
|
||||||
Source-only libraries need no runtime import or automatic binary deliverable.
|
|
||||||
Projects declaring binaries should use the common target selection and version
|
|
||||||
injection; hosted delivery should use content-aware retries for both page-only
|
|
||||||
and binary releases.
|
|
||||||
Existing published tags, notes, pages, and assets retain their historical contents.
|
|
||||||
|
|
||||||
Consumer policy and procedure changes should describe the deliberately adopted
|
|
||||||
standard and its compatibility impact. Adoption should demonstrate reduced local
|
|
||||||
infrastructure and one coherent release model.
|
|
||||||
|
|
||||||
## Representative migration review
|
|
||||||
|
|
||||||
Read-only reviews of the available PromptKit and Narratio checkouts were
|
|
||||||
verified on 2026-10-06 at `4b8169d` and `8afb49c`; both worktrees were clean.
|
|
||||||
These are migration assessments, not changes to either consumer.
|
|
||||||
|
|
||||||
| Consumer | Retain in its profile and product checks | Adaptation and procedure changes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| PromptKit, source-only | Product identity; its `Summary`, `Compatibility`, `Upgrade`, and `Changes` release-note sections; checks that exercise the three maintained Go examples and their product outputs. No runtime `buildinfo` import or binary targets are needed. | Its current release scripts total 209 lines. The projected deletion is about 120–160 lines of shared publication and generic validation machinery; retain the note-content rule and product-example checks as a configured additional check plus a small wiring test. PromptKit already pushes `main` before publication and creates annotated tags. Its release policy and procedure still need to add the hosted note page, delivery credentials, versioned CI invocation, and separate delivery status and recovery. |
|
|
||||||
| Narratio, binary | Product/module identity, the `./cmd/narratio` main package, `narratio` executable name, Linux amd64, Linux arm64, and macOS arm64 targets, and the `narratio <version>` command. Keep the module-graph check (`go mod tidy -diff`) if it remains useful as a product-specific candidate check. | Its release, check, asset-builder, and helper scripts total 304 lines; two dedicated release test files total 741 lines. Projected deletions are about 220–280 shell lines and 450–650 repeated test lines, leaving a small consumer wiring test and any retained module/note checks. Replace the private linker-version variable with `buildinfo.Version()` while preserving the current command output. Its policy and procedure need to change lightweight tags to annotated tags, state that a successful tag push completes source publication, and move plugin-based page/assets delivery to the shared versioned CI command with content-aware retries and separately provisioned delivery credentials. |
|
|
||||||
|
|
||||||
The deletion figures are estimates from the reviewed files, not measured
|
|
||||||
migration results or a promise that each line will disappear. Product checks,
|
|
||||||
version output, target choices, and at least one ReleaseKit integration check
|
|
||||||
remain with each consumer. The existing cross-project estimate of 1,000–1,250
|
|
||||||
potentially removable lines remains directional until migrations are completed.
|
|
||||||
The self-contained ReleaseKit fixtures exercise a meaningful source-only
|
|
||||||
compatibility check through local Git publication, and a `buildinfo` binary
|
|
||||||
consumer with selected targets through local HTTP delivery. These are separate
|
|
||||||
publication and delivery operations.
|
|
||||||
|
|
||||||
## Remaining work
|
|
||||||
|
|
||||||
- Use the [v0.1.0 baseline](../releases/v0.1.0.md) for initial consumer integration
|
|
||||||
under the [maintainer procedure](../release.md).
|
|
||||||
- Choose the broader consumer integration test plan and migration sequence.
|
|
||||||
Evaluate API clarity, product-specific checks, and the infrastructure removed
|
|
||||||
from each consuming repository.
|
|
||||||
- Provision delivery credentials and activate consumer Woodpecker workflows
|
|
||||||
using the maintained example's `v0.1.0` pin or a later selected immutable
|
|
||||||
version as part of deployment.
|
|
||||||
- Verify live Gitea delivery and Woodpecker execution. Existing verification uses
|
|
||||||
local Git and HTTP fixtures; no live deployment has been verified.
|
|
||||||
- Keep ReleaseKit executable binary distribution deferred until deliverables
|
|
||||||
are deliberately selected. Selecting binaries would require delivery
|
|
||||||
declarations, target checks, automation responsibilities, and corresponding
|
|
||||||
release policy and procedure updates.
|
|
||||||
- Evaluate a maintained ReleaseKit CI image or specialized validation/build
|
|
||||||
settings when a demonstrated consumer need justifies their maintenance cost
|
|
||||||
under [ADR 0002](../adr/0002-routine-releases.md).
|
|
||||||
|
|
||||||
## Reviewed implementations
|
|
||||||
|
|
||||||
- [PromptKit release script](../../../promptkit/scripts/release.sh),
|
|
||||||
[checker](../../../promptkit/scripts/release-check.sh)
|
|
||||||
- [ConfigKit release script](../../../ConfigKit/scripts/release.sh),
|
|
||||||
[checker](../../../ConfigKit/scripts/release-check.sh)
|
|
||||||
- [Audita release script](../../../audita/scripts/release.sh),
|
|
||||||
[Woodpecker job](../../../audita/.woodpecker/release.yml)
|
|
||||||
- [Notarius release script](../../../notarius/scripts/release.sh),
|
|
||||||
[checker](../../../notarius/scripts/check-release-source.sh)
|
|
||||||
- [Narratio release script](../../../narratio/scripts/release.sh),
|
|
||||||
[Woodpecker job](../../../narratio/.woodpecker/release.yml)
|
|
||||||
- [Scriptorium release script](../../../scriptorium/scripts/release.sh),
|
|
||||||
[Woodpecker job](../../../scriptorium/.woodpecker/release.yml)
|
|
||||||
132
examples/multi-module/README.md
Normal file
132
examples/multi-module/README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Multi-module release example
|
||||||
|
|
||||||
|
This example maps selectors and complete event tags to a physical Go module
|
||||||
|
directory and a repository-root profile. The library derives the release tag
|
||||||
|
from the selected module declaration and physical location; the table only
|
||||||
|
selects the corresponding profile and checkout directory.
|
||||||
|
|
||||||
|
Copy the contents of this directory into a consumer repository root, keeping
|
||||||
|
the listed module directories and profile paths at those locations, then run
|
||||||
|
the launcher from that checkout.
|
||||||
|
|
||||||
|
The sample modules declare Go 1.26.0, matching the current ReleaseKit consumer
|
||||||
|
minimum.
|
||||||
|
|
||||||
|
## Select the release tool
|
||||||
|
|
||||||
|
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
|
||||||
|
ReleaseKit version containing module-release support, then register that tool in
|
||||||
|
each consumer module before running the launcher or workflow:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
|
||||||
|
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
||||||
|
go -C "$module_dir" get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
|
go -C "$module_dir" mod tidy
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Review and commit the generated tool, dependency, and checksum declarations.
|
||||||
|
The [adoption guide](../../docs/consumers/adoption.md) owns tool registration and
|
||||||
|
coordinated tool/workflow upgrades. The
|
||||||
|
[v0.4.0 release note](../../docs/releases/v0.4.0.md) describes module-release
|
||||||
|
support and migration from earlier versions.
|
||||||
|
|
||||||
|
## Module mapping and publication
|
||||||
|
|
||||||
|
`release-modules.tsv` is literal tab-separated text with four columns:
|
||||||
|
|
||||||
|
1. Local selector.
|
||||||
|
2. Shell pattern matched against a complete event tag.
|
||||||
|
3. Repository-relative physical module directory (`.` means the root module).
|
||||||
|
4. Repository-relative profile path.
|
||||||
|
|
||||||
|
The checked-in patterns are `v[01].*`, `integrations/ckconfig/v*`, and
|
||||||
|
`integrations/core/v2.*`. The helper passes pattern text to the shell's `case`
|
||||||
|
matcher without `eval`. A tag must match exactly one row; missing and ambiguous
|
||||||
|
matches are configuration errors. Module and profile paths may contain spaces.
|
||||||
|
The `core-v2` row places the Go module physically under `integrations/core/v2`
|
||||||
|
and declares a `/v2` module path. ReleaseKit derives the versioned tag under
|
||||||
|
`integrations/core`, while the table retains the full physical path.
|
||||||
|
|
||||||
|
Before running candidate checks offline, prepare each module's dependencies in
|
||||||
|
its own directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go -C . mod download
|
||||||
|
go -C integrations/ckconfig mod download
|
||||||
|
go -C integrations/core/v2 mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit each generated `go.sum` with its module files. The binary delivery
|
||||||
|
profile prepares application dependencies in the tagged module before building;
|
||||||
|
the committed sums keep that prepared checkout clean for delivery verification.
|
||||||
|
|
||||||
|
Then select a module and semantic version with the shared launcher:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./release.sh root v1.2.3
|
||||||
|
./release.sh ckconfig v1.2.3
|
||||||
|
./release.sh core-v2 v2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
|
||||||
|
alternate module files outside the checkout, resolves ReleaseKit from that
|
||||||
|
module's local dependency selection, and builds the tool there. It invokes
|
||||||
|
`publish` with the repository profile and `--expected-module` so the library
|
||||||
|
can verify that the profile agrees with the selected table row. The temporary
|
||||||
|
files and tool are removed on exit. As with any `publish` invocation, commit the
|
||||||
|
selected module's release note and prepare its repository before publishing.
|
||||||
|
|
||||||
|
If an integration depends on a sibling release, publish the dependency first,
|
||||||
|
then publish the integration with the version that consumes it. For example,
|
||||||
|
publish the core release before an integration that requires that release.
|
||||||
|
This declaration does not infer dependency order or rerun the standard suite.
|
||||||
|
|
||||||
|
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
|
||||||
|
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
|
||||||
|
the literal event tag before selecting the module, copies that module's metadata
|
||||||
|
outside the checkout, and builds the ReleaseKit version selected there. It then
|
||||||
|
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
|
||||||
|
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
||||||
|
page-only. Before enabling the workflow, configure each selected profile's
|
||||||
|
`delivery` destination and add the Woodpecker repository secret
|
||||||
|
`releasekit_gitea_token`; the workflow exposes it as
|
||||||
|
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
|
||||||
|
|
||||||
|
Retries use the selected tag's module files and matching workflow. Tags whose
|
||||||
|
tool predates this interface use their corresponding historical workflow.
|
||||||
|
|
||||||
|
## Local source verification
|
||||||
|
|
||||||
|
For verification before a compatible release is published, copy this example
|
||||||
|
into a disposable consumer checkout. Set `RELEASEKIT_SOURCE` to the absolute
|
||||||
|
path of a prepared ReleaseKit checkout, then select its source in those fixture
|
||||||
|
modules:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
|
||||||
|
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
||||||
|
go -C "$module_dir" mod edit \
|
||||||
|
-require=gitea.maximumdirect.net/eric/ReleaseKit@v0.0.0 \
|
||||||
|
-replace="gitea.maximumdirect.net/eric/ReleaseKit=$RELEASEKIT_SOURCE" \
|
||||||
|
-tool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `v0.0.0` is a fixture placeholder resolved by the local replacement.
|
||||||
|
Keep these declarations in the disposable checkout. Add representative product
|
||||||
|
source and each selected product's module-local release note, then commit the
|
||||||
|
fixture and run the source-selected tool without publishing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go tool releasekit check --config releasekit-root.json v1.2.3
|
||||||
|
go -C integrations/ckconfig tool releasekit check --config releasekit-ckconfig.json v1.2.3
|
||||||
|
go -C integrations/core/v2 tool releasekit check --config releasekit-core-v2.json v2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands use the repository-root profile paths even when invoked from a
|
||||||
|
child module. The core profile also verifies the native binary and injected
|
||||||
|
version. Prepared dependencies allow this verification with `GOPROXY=off` and
|
||||||
|
`GOSUMDB=off`.
|
||||||
3
examples/multi-module/go.mod
Normal file
3
examples/multi-module/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module example.com/multi-module-consumer
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
3
examples/multi-module/integrations/ckconfig/go.mod
Normal file
3
examples/multi-module/integrations/ckconfig/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module example.com/multi-module-consumer/integrations/ckconfig
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
3
examples/multi-module/integrations/core/v2/go.mod
Normal file
3
examples/multi-module/integrations/core/v2/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module example.com/multi-module-consumer/integrations/core/v2
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
11
examples/multi-module/integrations/core/v2/main.go
Normal file
11
examples/multi-module/integrations/core/v2/main.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Printf("core %s\n", buildinfo.Version())
|
||||||
|
}
|
||||||
4
examples/multi-module/release-modules.tsv
Normal file
4
examples/multi-module/release-modules.tsv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# selector<TAB>tag pattern<TAB>module directory<TAB>profile path
|
||||||
|
root v[01].* . releasekit-root.json
|
||||||
|
ckconfig integrations/ckconfig/v* integrations/ckconfig releasekit-ckconfig.json
|
||||||
|
core-v2 integrations/core/v2.* integrations/core/v2 releasekit-core-v2.json
|
||||||
|
48
examples/multi-module/release.sh
Executable file
48
examples/multi-module/release.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: release.sh SELECTOR VERSION" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 2 ] || usage
|
||||||
|
selector=$1
|
||||||
|
version=$2
|
||||||
|
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||||
|
repository_dir=$(git -C "$script_dir" rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
bootstrap_dir=$(mktemp -d "$(dirname "$repository_dir")/.releasekit-bootstrap.XXXXXX")
|
||||||
|
selection_file=$bootstrap_dir/module-selection.tsv
|
||||||
|
trap 'rm -rf "$bootstrap_dir"' EXIT
|
||||||
|
"$script_dir/select-module.sh" selector "$selector" > "$selection_file"
|
||||||
|
|
||||||
|
tab=$(printf '\t')
|
||||||
|
IFS="$tab" read -r selected_selector tag_pattern module_dir profile_path < "$selection_file"
|
||||||
|
module_path=$repository_dir
|
||||||
|
if [ "$module_dir" != . ]; then
|
||||||
|
module_path=$repository_dir/$module_dir
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$module_path/go.mod" "$bootstrap_dir/releasekit.mod"
|
||||||
|
if [ -f "$module_path/go.sum" ]; then
|
||||||
|
cp "$module_path/go.sum" "$bootstrap_dir/releasekit.sum"
|
||||||
|
else
|
||||||
|
: > "$bootstrap_dir/releasekit.sum"
|
||||||
|
fi
|
||||||
|
|
||||||
|
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
(
|
||||||
|
cd "$module_path"
|
||||||
|
export GOWORK=off
|
||||||
|
export GOTOOLCHAIN=local
|
||||||
|
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
|
||||||
|
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \
|
||||||
|
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit"
|
||||||
|
)
|
||||||
|
|
||||||
|
"$bootstrap_dir/releasekit" publish \
|
||||||
|
--repo "$repository_dir" \
|
||||||
|
--config "$profile_path" \
|
||||||
|
--expected-module "$module_dir" \
|
||||||
|
"$version"
|
||||||
4
examples/multi-module/releasekit-ckconfig.json
Normal file
4
examples/multi-module/releasekit-ckconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"product": "Example Consumer Integration",
|
||||||
|
"module_dir": "integrations/ckconfig"
|
||||||
|
}
|
||||||
9
examples/multi-module/releasekit-core-v2.json
Normal file
9
examples/multi-module/releasekit-core-v2.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"product": "Example Consumer Core v2",
|
||||||
|
"module_dir": "integrations/core/v2",
|
||||||
|
"build": {
|
||||||
|
"main_package": ".",
|
||||||
|
"binary_name": "core",
|
||||||
|
"targets": ["linux/amd64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
examples/multi-module/releasekit-root.json
Normal file
4
examples/multi-module/releasekit-root.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"product": "Example Consumer",
|
||||||
|
"module_dir": "."
|
||||||
|
}
|
||||||
70
examples/multi-module/select-module.sh
Executable file
70
examples/multi-module/select-module.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: select-module.sh selector|tag VALUE" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 2 ] || usage
|
||||||
|
mode=$1
|
||||||
|
value=$2
|
||||||
|
case "$mode" in
|
||||||
|
selector|tag) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||||
|
mapping=${RELEASEKIT_MODULE_MAP:-$script_dir/release-modules.tsv}
|
||||||
|
[ -f "$mapping" ] || { echo "module map not found: $mapping" >&2; exit 2; }
|
||||||
|
|
||||||
|
tab=$(printf '\t')
|
||||||
|
matches=0
|
||||||
|
selected_selector=
|
||||||
|
selected_pattern=
|
||||||
|
selected_module=
|
||||||
|
selected_profile=
|
||||||
|
|
||||||
|
while IFS="$tab" read -r selector pattern module profile || [ -n "${selector:-}${pattern:-}${module:-}${profile:-}" ]; do
|
||||||
|
case "${selector:-}" in
|
||||||
|
''|\#*) continue ;;
|
||||||
|
esac
|
||||||
|
fields=$(awk -F '\t' '{ print NF }' <<EOF
|
||||||
|
$selector$tab$pattern$tab$module$tab$profile
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
[ "$fields" -eq 4 ] || { echo "module map row must have four tab-separated fields" >&2; exit 2; }
|
||||||
|
[ -n "$pattern" ] && [ -n "$module" ] && [ -n "$profile" ] || {
|
||||||
|
echo "module map row has an empty field for selector $selector" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
matched=false
|
||||||
|
if [ "$mode" = selector ]; then
|
||||||
|
[ "$selector" = "$value" ] && matched=true
|
||||||
|
else
|
||||||
|
# The value remains a shell pattern in this case statement; it is never eval'd.
|
||||||
|
case "$value" in
|
||||||
|
$pattern) matched=true ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ "$matched" = true ]; then
|
||||||
|
matches=$((matches + 1))
|
||||||
|
selected_selector=$selector
|
||||||
|
selected_pattern=$pattern
|
||||||
|
selected_module=$module
|
||||||
|
selected_profile=$profile
|
||||||
|
fi
|
||||||
|
done < "$mapping"
|
||||||
|
|
||||||
|
if [ "$matches" -ne 1 ]; then
|
||||||
|
if [ "$matches" -eq 0 ]; then
|
||||||
|
echo "no module map row matches $mode $value" >&2
|
||||||
|
else
|
||||||
|
echo "module map has ambiguous rows for $mode $value" >&2
|
||||||
|
fi
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\t%s\t%s\t%s\n' \
|
||||||
|
"$selected_selector" "$selected_pattern" "$selected_module" "$selected_profile"
|
||||||
67
examples/multi-module/woodpecker.yml
Normal file
67
examples/multi-module/woodpecker.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
ref:
|
||||||
|
include:
|
||||||
|
- refs/tags/v[01].*
|
||||||
|
- refs/tags/integrations/ckconfig/v*
|
||||||
|
- refs/tags/integrations/core/v2.*
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: deliver
|
||||||
|
image: golang:1.26.8-bookworm
|
||||||
|
environment:
|
||||||
|
RELEASEKIT_GITEA_TOKEN:
|
||||||
|
from_secret: releasekit_gitea_token
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
test -n "$${CI_COMMIT_TAG}"
|
||||||
|
test -n "$${CI_COMMIT_SHA}"
|
||||||
|
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||||
|
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||||
|
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||||
|
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||||
|
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git checkout --detach "$${tag_commit}"
|
||||||
|
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||||
|
test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
|
|
||||||
|
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
||||||
|
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
||||||
|
selection_file="$${bootstrap_dir}/module-selection.tsv"
|
||||||
|
"$${CI_WORKSPACE}/select-module.sh" tag "$${CI_COMMIT_TAG}" > "$${selection_file}"
|
||||||
|
tab=$(printf '\t')
|
||||||
|
IFS="$${tab}" read -r selector tag_pattern module_dir profile_path < "$${selection_file}"
|
||||||
|
module_path="$${CI_WORKSPACE}"
|
||||||
|
if [ "$${module_dir}" != . ]; then
|
||||||
|
module_path="$${CI_WORKSPACE}/$${module_dir}"
|
||||||
|
fi
|
||||||
|
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod"
|
||||||
|
if [ -f "$${module_path}/go.sum" ]; then
|
||||||
|
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum"
|
||||||
|
else
|
||||||
|
: > "$${bootstrap_dir}/go.sum"
|
||||||
|
fi
|
||||||
|
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
||||||
|
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
||||||
|
|
||||||
|
export GOWORK=off
|
||||||
|
export GOTOOLCHAIN=local
|
||||||
|
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
(
|
||||||
|
cd "$${module_path}"
|
||||||
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
|
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
||||||
|
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
||||||
|
)
|
||||||
|
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||||
|
--repo "$${CI_WORKSPACE}" \
|
||||||
|
--config "$${profile_path}" \
|
||||||
|
--expected-module "$${module_dir}" \
|
||||||
|
--prepared \
|
||||||
|
--commit "$${expected_commit}" \
|
||||||
|
"$${CI_COMMIT_TAG}"
|
||||||
@@ -3,27 +3,36 @@ when:
|
|||||||
ref: refs/tags/v*
|
ref: refs/tags/v*
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-tool
|
- name: deliver
|
||||||
image: golang:1.25.0-bookworm
|
image: golang:1.26.8-bookworm
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
test -n "$${CI_COMMIT_TAG}"
|
|
||||||
test -n "$${CI_COMMIT_SHA}"
|
|
||||||
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
|
||||||
test "$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}")" = "$${CI_COMMIT_SHA}"
|
|
||||||
git checkout --detach "$${CI_COMMIT_SHA}"
|
|
||||||
test "$(git rev-parse HEAD)" = "$${CI_COMMIT_SHA}"
|
|
||||||
mkdir -p "$${CI_WORKSPACE}/.releasekit/bin"
|
|
||||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o "$${CI_WORKSPACE}/.releasekit/bin/releasekit" ./cmd/releasekit
|
|
||||||
|
|
||||||
- name: deliver-page
|
|
||||||
image: golang:1.25.0-bookworm
|
|
||||||
environment:
|
environment:
|
||||||
RELEASEKIT_GITEA_TOKEN:
|
RELEASEKIT_GITEA_TOKEN:
|
||||||
from_secret: releasekit_gitea_token
|
from_secret: releasekit_gitea_token
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
"$${CI_WORKSPACE}/.releasekit/bin/releasekit" ci-deliver \
|
set -eu
|
||||||
|
test -n "$${CI_COMMIT_TAG}"
|
||||||
|
test -n "$${CI_COMMIT_SHA}"
|
||||||
|
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||||
|
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||||
|
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||||
|
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||||
|
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git checkout --detach "$${tag_commit}"
|
||||||
|
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||||
|
test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
|
|
||||||
|
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-source.XXXXXX")
|
||||||
|
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
||||||
|
export GOWORK=off
|
||||||
|
export GOTOOLCHAIN=local
|
||||||
|
go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit
|
||||||
|
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--commit "$${CI_COMMIT_SHA}" \
|
--prepared \
|
||||||
|
--commit "$${expected_commit}" \
|
||||||
"$${CI_COMMIT_TAG}"
|
"$${CI_COMMIT_TAG}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- GO_IMAGE: golang:1.25.0-bookworm
|
- GO_IMAGE: golang:1.26.8-bookworm
|
||||||
RELEASEKIT_VERSION: v0.1.0
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
@@ -15,7 +14,41 @@ steps:
|
|||||||
from_secret: releasekit_gitea_token
|
from_secret: releasekit_gitea_token
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
go run gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_VERSION} ci-deliver \
|
set -eu
|
||||||
|
test -n "$${CI_COMMIT_TAG}"
|
||||||
|
test -n "$${CI_COMMIT_SHA}"
|
||||||
|
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||||
|
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||||
|
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||||
|
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||||
|
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||||
|
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git checkout --detach "$${tag_commit}"
|
||||||
|
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||||
|
test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
|
|
||||||
|
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
||||||
|
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
||||||
|
cp go.mod "$${bootstrap_dir}/go.mod"
|
||||||
|
if [ -f go.sum ]; then
|
||||||
|
cp go.sum "$${bootstrap_dir}/go.sum"
|
||||||
|
else
|
||||||
|
: > "$${bootstrap_dir}/go.sum"
|
||||||
|
fi
|
||||||
|
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
||||||
|
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
||||||
|
|
||||||
|
export GOWORK=off
|
||||||
|
export GOTOOLCHAIN=local
|
||||||
|
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
|
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
||||||
|
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
||||||
|
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--commit "$${CI_COMMIT_SHA}" \
|
--prepared \
|
||||||
|
--commit "$${expected_commit}" \
|
||||||
"$${CI_COMMIT_TAG}"
|
"$${CI_COMMIT_TAG}"
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -1,3 +1,8 @@
|
|||||||
module gitea.maximumdirect.net/eric/ReleaseKit
|
module gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6
|
||||||
|
golang.org/x/mod v0.40.0
|
||||||
|
)
|
||||||
|
|||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||||
6
ideas.md
6
ideas.md
@@ -1,4 +1,6 @@
|
|||||||
# ReleaseKit ideas
|
# ReleaseKit ideas
|
||||||
|
|
||||||
The [product roadmap](docs/roadmap/releasekit.md) records shared release-process
|
The [architecture policy](docs/policy/architecture.md) and
|
||||||
adoption goals and remaining release and deployment work.
|
[ADRs](docs/adr/) record the adopted design decisions. The
|
||||||
|
[v0.2.0 release note](docs/releases/v0.2.0.md) summarizes the implemented
|
||||||
|
capabilities.
|
||||||
|
|||||||
@@ -70,10 +70,91 @@ func checkRepository(ctx context.Context, dir string) error {
|
|||||||
if violations := checkExports(pkg); len(violations) != 0 {
|
if violations := checkExports(pkg); len(violations) != 0 {
|
||||||
return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n "))
|
return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n "))
|
||||||
}
|
}
|
||||||
|
if path == modulePath {
|
||||||
|
if violations := checkValidationTypes(pkg); len(violations) != 0 {
|
||||||
|
return fmt.Errorf("facade %s has invalid validation diagnostics:\n %s", path, strings.Join(violations, "\n "))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkValidationTypes(pkg *types.Package) []string {
|
||||||
|
var violations []string
|
||||||
|
for _, name := range []string{"ValidationCheck", "ValidationFinding", "FileSizeMeasurement", "ValidationError"} {
|
||||||
|
object, ok := pkg.Scope().Lookup(name).(*types.TypeName)
|
||||||
|
if !ok {
|
||||||
|
violations = append(violations, fmt.Sprintf("missing facade-owned type %s", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
named, ok := types.Unalias(object.Type()).(*types.Named)
|
||||||
|
if !ok || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != pkg.Path() {
|
||||||
|
violations = append(violations, fmt.Sprintf("%s is not a facade-owned named type", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validationError, ok := pkg.Scope().Lookup("ValidationError").(*types.TypeName)
|
||||||
|
if !ok {
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
named, ok := types.Unalias(validationError.Type()).(*types.Named)
|
||||||
|
if !ok {
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
structure, ok := named.Underlying().(*types.Struct)
|
||||||
|
if !ok {
|
||||||
|
return append(violations, "ValidationError is not a struct")
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"Check": "ValidationCheck",
|
||||||
|
"Findings": "[]ValidationFinding",
|
||||||
|
"Measurement": "*FileSizeMeasurement",
|
||||||
|
}
|
||||||
|
for fieldName, expected := range fields {
|
||||||
|
fieldIndex := -1
|
||||||
|
for index := 0; index < structure.NumFields(); index++ {
|
||||||
|
if structure.Field(index).Name() == fieldName && structure.Field(index).Exported() {
|
||||||
|
fieldIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fieldIndex < 0 || !validationFieldMatches(structure.Field(fieldIndex).Type(), expected, pkg.Path()) {
|
||||||
|
violations = append(violations, fmt.Sprintf("ValidationError.%s must expose %s", fieldName, expected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cause := fieldByName(structure, "Cause"); cause == nil || !cause.Exported() || !types.Identical(cause.Type(), types.Universe.Lookup("error").Type()) {
|
||||||
|
violations = append(violations, "ValidationError.Cause must expose error")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldByName(structure *types.Struct, name string) *types.Var {
|
||||||
|
for index := 0; index < structure.NumFields(); index++ {
|
||||||
|
if structure.Field(index).Name() == name {
|
||||||
|
return structure.Field(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validationFieldMatches(fieldType types.Type, expected, packagePath string) bool {
|
||||||
|
switch expected {
|
||||||
|
case "[]ValidationFinding":
|
||||||
|
slice, ok := types.Unalias(fieldType).(*types.Slice)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fieldType = slice.Elem()
|
||||||
|
case "*FileSizeMeasurement":
|
||||||
|
pointer, ok := types.Unalias(fieldType).(*types.Pointer)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fieldType = pointer.Elem()
|
||||||
|
}
|
||||||
|
named, ok := types.Unalias(fieldType).(*types.Named)
|
||||||
|
return ok && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == packagePath && named.Obj().Name() == strings.TrimLeft(expected, "*[]")
|
||||||
|
}
|
||||||
|
|
||||||
func listPackages(ctx context.Context, dir string) ([]listedPackage, error) {
|
func listPackages(ctx context.Context, dir string) ([]listedPackage, error) {
|
||||||
cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...")
|
cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...")
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
|
|||||||
@@ -101,6 +101,35 @@ func Open(context.Context) *Handle { return nil }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidationDiagnosticTypesBelongToFacade(t *testing.T) {
|
||||||
|
valid := `package facade
|
||||||
|
type ValidationCheck string
|
||||||
|
type ValidationFinding struct{}
|
||||||
|
type FileSizeMeasurement struct{}
|
||||||
|
type ValidationError struct {
|
||||||
|
Check ValidationCheck
|
||||||
|
Findings []ValidationFinding
|
||||||
|
Measurement *FileSizeMeasurement
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
`
|
||||||
|
pkg := typecheck(t, token.NewFileSet(), modulePath, valid)
|
||||||
|
if violations := checkValidationTypes(pkg); len(violations) != 0 {
|
||||||
|
t.Fatalf("checkValidationTypes() = %v, want facade-owned diagnostics", violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation := typecheck(t, token.NewFileSet(), "example.com/implementation", `package implementation
|
||||||
|
type State string
|
||||||
|
`)
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
fixtureImporter := singlePackageImporter{path: "example.com/implementation", pkg: implementation}
|
||||||
|
invalid := strings.Replace(valid, "type ValidationCheck string", "import \"example.com/implementation\"\ntype ValidationCheck = implementation.State", 1)
|
||||||
|
pkg = typecheckWithImporter(t, fset, modulePath, invalid, fixtureImporter)
|
||||||
|
if violations := checkValidationTypes(pkg); !strings.Contains(strings.Join(violations, "\n"), "ValidationCheck is not a facade-owned named type") {
|
||||||
|
t.Fatalf("checkValidationTypes() = %v, want ownership violation", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestActualRepositoryBoundaries(t *testing.T) {
|
func TestActualRepositoryBoundaries(t *testing.T) {
|
||||||
if err := checkRepository(t.Context(), "../.."); err != nil {
|
if err := checkRepository(t.Context(), "../.."); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -3,21 +3,51 @@ package artifact
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ManifestFilename = "SHA256SUMS"
|
const ManifestFilename = "SHA256SUMS"
|
||||||
|
|
||||||
|
// OutputDirectory resolves build output and prebuilt asset directories from
|
||||||
|
// the absolute selected module directory.
|
||||||
|
func OutputDirectory(moduleDir, directory string) string {
|
||||||
|
if directory == "" {
|
||||||
|
directory = "dist"
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(directory) {
|
||||||
|
directory = filepath.Join(moduleDir, directory)
|
||||||
|
}
|
||||||
|
return filepath.Clean(directory)
|
||||||
|
}
|
||||||
|
|
||||||
type Target struct {
|
type Target struct {
|
||||||
OS string
|
OS string
|
||||||
Arch string
|
Arch string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings contains normalized inputs shared by build execution and evidence.
|
||||||
|
type Settings struct {
|
||||||
|
MainPackage string
|
||||||
|
BinaryName string
|
||||||
|
Targets []Target
|
||||||
|
}
|
||||||
|
|
||||||
type Declaration struct {
|
type Declaration struct {
|
||||||
Target Target
|
Target Target
|
||||||
Filename string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeSettings copies build inputs and removes duplicate targets while
|
||||||
|
// preserving their first occurrence.
|
||||||
|
func NormalizeSettings(mainPackage, binaryName string, targets []Target) Settings {
|
||||||
|
return Settings{
|
||||||
|
MainPackage: mainPackage,
|
||||||
|
BinaryName: binaryName,
|
||||||
|
Targets: NormalizeTargets(targets),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeTargets removes duplicate targets while preserving their first
|
// NormalizeTargets removes duplicate targets while preserving their first
|
||||||
// occurrence.
|
// occurrence.
|
||||||
func NormalizeTargets(targets []Target) []Target {
|
func NormalizeTargets(targets []Target) []Target {
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ func TestNormalizeTargetsPreservesOrderAndFirstOccurrence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets(t *testing.T) {
|
||||||
|
settings := NormalizeSettings("./cmd/app", "app", []Target{
|
||||||
|
{OS: "linux", Arch: "amd64"},
|
||||||
|
{OS: "windows", Arch: "arm64"},
|
||||||
|
{OS: "linux", Arch: "amd64"},
|
||||||
|
})
|
||||||
|
want := Settings{
|
||||||
|
MainPackage: "./cmd/app",
|
||||||
|
BinaryName: "app",
|
||||||
|
Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "arm64"}},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(settings, want) {
|
||||||
|
t.Fatalf("NormalizeSettings() = %#v, want %#v", settings, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
|
func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
|
||||||
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
|
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
|
||||||
want := []Declaration{
|
want := []Declaration{
|
||||||
|
|||||||
@@ -16,23 +16,20 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Target = artifact.Target
|
type Target = artifact.Target
|
||||||
|
type Spec = artifact.Settings
|
||||||
type Spec struct {
|
|
||||||
MainPackage string
|
|
||||||
BinaryName string
|
|
||||||
Targets []Target
|
|
||||||
}
|
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RepositoryDir string
|
Identity releaseidentity.Identity
|
||||||
Version string
|
|
||||||
Spec Spec
|
Spec Spec
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Output io.Writer
|
Output io.Writer
|
||||||
|
ToolOwner *gotool.Owner
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artifact struct {
|
type Artifact struct {
|
||||||
@@ -44,8 +41,12 @@ type Artifact struct {
|
|||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
|
ModulePath string
|
||||||
|
Tag string
|
||||||
CandidateCommit string
|
CandidateCommit string
|
||||||
SourceVerified bool
|
SourceVerified bool
|
||||||
|
Settings artifact.Settings
|
||||||
Version string
|
Version string
|
||||||
MainPackage string
|
MainPackage string
|
||||||
BinaryName string
|
BinaryName string
|
||||||
@@ -55,25 +56,32 @@ type Result struct {
|
|||||||
Compiler string
|
Compiler string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Build(ctx context.Context, request Request) (Result, error) {
|
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||||
|
identity := request.Identity
|
||||||
|
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||||
result := Result{
|
result := Result{
|
||||||
Version: request.Version,
|
RepositoryDir: identity.RepositoryDir,
|
||||||
MainPackage: request.Spec.MainPackage,
|
ModuleDir: identity.ModuleDir,
|
||||||
BinaryName: request.Spec.BinaryName,
|
ModulePath: identity.ModulePath,
|
||||||
Targets: artifact.NormalizeTargets(request.Spec.Targets),
|
Tag: identity.Tag,
|
||||||
|
Settings: settings,
|
||||||
|
Version: identity.Version,
|
||||||
|
MainPackage: settings.MainPackage,
|
||||||
|
BinaryName: settings.BinaryName,
|
||||||
|
Targets: settings.Targets,
|
||||||
}
|
}
|
||||||
if !versioncontract.ValidStableVersion(request.Version) {
|
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
|
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
||||||
}
|
}
|
||||||
if request.RepositoryDir == "" {
|
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||||
return result, errors.New("build requires a repository directory")
|
return result, errors.New("build requires a resolved module identity")
|
||||||
}
|
}
|
||||||
if len(result.Targets) != 0 {
|
if len(result.Targets) != 0 {
|
||||||
if request.Spec.MainPackage == "" {
|
if settings.MainPackage == "" {
|
||||||
return result, errors.New("build requires a main package when targets are selected")
|
return result, errors.New("build requires a main package when targets are selected")
|
||||||
}
|
}
|
||||||
if !artifact.ValidComponent(request.Spec.BinaryName) {
|
if !artifact.ValidComponent(settings.BinaryName) {
|
||||||
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
|
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
||||||
}
|
}
|
||||||
for _, target := range result.Targets {
|
for _, target := range result.Targets {
|
||||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||||
@@ -84,11 +92,16 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return result, fmt.Errorf("build canceled: %w", err)
|
return result, fmt.Errorf("build canceled: %w", err)
|
||||||
}
|
}
|
||||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
repositoryDir, err := filepath.Abs(identity.RepositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||||
}
|
}
|
||||||
result.RepositoryDir = repositoryDir
|
result.RepositoryDir = repositoryDir
|
||||||
|
moduleDir, err := filepath.Abs(identity.ModuleDir)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||||
|
}
|
||||||
|
result.ModuleDir = moduleDir
|
||||||
commit, err := gitCommit(ctx, repositoryDir)
|
commit, err := gitCommit(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
@@ -100,12 +113,12 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Targets) == 0 {
|
if len(result.Targets) == 0 {
|
||||||
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
|
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
|
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||||
declarations := artifact.ExpectedBuild(request.Spec.BinaryName, request.Version, result.Targets)
|
declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.Targets)
|
||||||
filenames := make([]string, len(declarations))
|
filenames := make([]string, len(declarations))
|
||||||
for i, declaration := range declarations {
|
for i, declaration := range declarations {
|
||||||
filenames[i] = declaration.Filename
|
filenames[i] = declaration.Filename
|
||||||
@@ -114,11 +127,15 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
|
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
goTool, err := gotool.New(ctx, repositoryDir)
|
owner := request.ToolOwner
|
||||||
|
if owner == nil {
|
||||||
|
owner = gotool.NewOwner(moduleDir)
|
||||||
|
}
|
||||||
|
goTool, err := owner.Acquire(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("prepare Go toolchain: %w", err)
|
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, moduleDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("identify Go compiler: %w", err)
|
return result, fmt.Errorf("identify Go compiler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -135,7 +152,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
if request.Output != nil {
|
if request.Output != nil {
|
||||||
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
|
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
|
||||||
}
|
}
|
||||||
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
|
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, path, identity.Version, target); err != nil {
|
||||||
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||||
}
|
}
|
||||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||||
@@ -153,26 +170,26 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if smokePath == "" {
|
if smokePath == "" {
|
||||||
temporaryDir, err := os.MkdirTemp("", "releasekit-native-smoke-")
|
temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-native-smoke-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
return result, fmt.Errorf("create native smoke directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer os.RemoveAll(temporaryDir)
|
||||||
smokePath = filepath.Join(temporaryDir, request.Spec.BinaryName)
|
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||||
if host.OS == "windows" {
|
if host.OS == "windows" {
|
||||||
smokePath += ".exe"
|
smokePath += ".exe"
|
||||||
}
|
}
|
||||||
if request.Output != nil {
|
if request.Output != nil {
|
||||||
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch)
|
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch)
|
||||||
}
|
}
|
||||||
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
|
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, smokePath, identity.Version, host); err != nil {
|
||||||
return result, fmt.Errorf("build native version check: %w", err)
|
return result, fmt.Errorf("build native version check: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request.Output != nil {
|
if request.Output != nil {
|
||||||
fmt.Fprintf(request.Output, "checking %s version output\n", request.Spec.BinaryName)
|
fmt.Fprintf(request.Output, "checking %s version output\n", settings.BinaryName)
|
||||||
}
|
}
|
||||||
if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil {
|
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
commitAfter, err := gitCommit(ctx, repositoryDir)
|
||||||
@@ -187,19 +204,6 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedOutputDir(repositoryDir, outputDir string) string {
|
|
||||||
if outputDir == "" {
|
|
||||||
outputDir = filepath.Join(repositoryDir, "dist")
|
|
||||||
} else if !filepath.IsAbs(outputDir) {
|
|
||||||
outputDir = filepath.Join(repositoryDir, outputDir)
|
|
||||||
}
|
|
||||||
resolved, err := filepath.Abs(outputDir)
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Clean(outputDir)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
output, err := command.Output()
|
output, err := command.Output()
|
||||||
@@ -212,9 +216,9 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
|||||||
return strings.TrimSpace(string(output)), nil
|
return strings.TrimSpace(string(output)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
|
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
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, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||||
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
|
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,47 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error {
|
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||||
if spec == nil || len(spec.Targets) == 0 {
|
if spec == nil || len(spec.Targets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selected := *spec
|
selected := *spec
|
||||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
selected.Targets = append([]Target(nil), spec.Targets...)
|
||||||
return func(ctx context.Context, captured candidate.Candidate) error {
|
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error {
|
||||||
|
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve captured module path: %w", err)
|
||||||
|
}
|
||||||
|
identity := releaseidentity.Identity{
|
||||||
|
Selection: releaseidentity.Selection{
|
||||||
|
RepositoryDir: captured.RepositoryDir,
|
||||||
|
ModuleDir: captured.ModuleDir,
|
||||||
|
ModuleSubdir: filepath.ToSlash(moduleSubdir),
|
||||||
|
},
|
||||||
|
ModulePath: captured.ModulePath,
|
||||||
|
Version: captured.Version,
|
||||||
|
Tag: captured.Tag,
|
||||||
|
NotePath: filepath.ToSlash(filepath.Join(moduleSubdir, "docs", "releases", captured.Version+".md")),
|
||||||
|
}
|
||||||
temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-")
|
temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer os.RemoveAll(temporaryDir)
|
||||||
_, err = Build(ctx, Request{
|
_, err = BuildResolved(ctx, Request{
|
||||||
RepositoryDir: captured.RepositoryDir,
|
Identity: identity,
|
||||||
Version: captured.Version,
|
|
||||||
Spec: selected,
|
Spec: selected,
|
||||||
OutputDir: temporaryDir,
|
OutputDir: temporaryDir,
|
||||||
Output: output,
|
Output: output,
|
||||||
|
ToolOwner: owner,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build candidate: %w", err)
|
return fmt.Errorf("build candidate: %w", err)
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@ var ErrInvalidVersion = errors.New("invalid stable version")
|
|||||||
|
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
|
ModulePath string
|
||||||
|
Tag string
|
||||||
Commit string
|
Commit string
|
||||||
Version string
|
Version string
|
||||||
Note string
|
Note string
|
||||||
@@ -27,28 +32,33 @@ type Candidate struct {
|
|||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
Product string
|
Product string
|
||||||
Version string
|
Version string
|
||||||
|
Validation validation.Settings
|
||||||
AdditionalChecks func(context.Context, Candidate) error
|
AdditionalChecks func(context.Context, Candidate) error
|
||||||
AfterValidate func(context.Context, Candidate) error
|
AfterValidate func(context.Context, Candidate, *gotool.Owner) error
|
||||||
Output io.Writer
|
Output io.Writer
|
||||||
|
ToolOwner *gotool.Owner
|
||||||
}
|
}
|
||||||
|
|
||||||
func Check(ctx context.Context, request Request) (Candidate, error) {
|
func Check(ctx context.Context, request Request) (Candidate, error) {
|
||||||
captured, err := Capture(ctx, request)
|
captured, err := Capture(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Candidate{}, err
|
return captured, err
|
||||||
}
|
}
|
||||||
|
request.ToolOwner = gotool.NewOwner(captured.ModuleDir)
|
||||||
if err := Validate(ctx, request, captured); err != nil {
|
if err := Validate(ctx, request, captured); err != nil {
|
||||||
return Candidate{}, err
|
return captured, err
|
||||||
}
|
}
|
||||||
if request.AfterValidate != nil {
|
if request.AfterValidate != nil {
|
||||||
if err := request.AfterValidate(ctx, captured); err != nil {
|
callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner)
|
||||||
return Candidate{}, fmt.Errorf("after candidate validation: %w", err)
|
if callbackErr != nil {
|
||||||
|
return captured, fmt.Errorf("after candidate validation: %w", callbackErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := Recheck(ctx, captured, ""); err != nil {
|
if err := Recheck(ctx, captured, ""); err != nil {
|
||||||
return Candidate{}, err
|
return captured, err
|
||||||
}
|
}
|
||||||
return captured, nil
|
return captured, nil
|
||||||
}
|
}
|
||||||
@@ -56,54 +66,76 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
|
|||||||
// Capture validates request identity and records one clean local candidate,
|
// Capture validates request identity and records one clean local candidate,
|
||||||
// including the note bytes from the captured commit. It does not run checks.
|
// including the note bytes from the captured commit. It does not run checks.
|
||||||
func Capture(ctx context.Context, request Request) (Candidate, error) {
|
func Capture(ctx context.Context, request Request) (Candidate, error) {
|
||||||
|
captured := Candidate{Version: request.Version}
|
||||||
if !validStableVersion(request.Version) {
|
if !validStableVersion(request.Version) {
|
||||||
return Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
|
return captured, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(request.Product) == "" {
|
if strings.TrimSpace(request.Product) == "" {
|
||||||
return Candidate{}, errors.New("product is required")
|
return captured, errors.New("product is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Candidate{}, fmt.Errorf("resolve repository directory: %w", err)
|
return captured, fmt.Errorf("resolve repository directory: %w", err)
|
||||||
}
|
}
|
||||||
|
captured.RepositoryDir = repositoryDir
|
||||||
state, err := inspectLocalState(ctx, repositoryDir)
|
state, err := inspectLocalState(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Candidate{}, err
|
return captured, err
|
||||||
}
|
}
|
||||||
|
captured.Commit = state.commit
|
||||||
if state.branch != "main" {
|
if state.branch != "main" {
|
||||||
return Candidate{}, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
|
return captured, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
|
||||||
}
|
}
|
||||||
if state.status != "" {
|
if state.status != "" {
|
||||||
return Candidate{}, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := "docs/releases/" + request.Version + ".md"
|
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit)
|
||||||
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
|
captured.ModuleDir = identity.ModuleDir
|
||||||
|
captured.ModulePath = identity.ModulePath
|
||||||
|
captured.Tag = identity.Tag
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Candidate{}, fmt.Errorf("read committed release note %q: %w", path, err)
|
return captured, fmt.Errorf("resolve selected module release identity: %w", err)
|
||||||
|
}
|
||||||
|
note, err := git.ReadCommitFile(ctx, identity.RepositoryDir, state.commit, identity.NotePath)
|
||||||
|
if err != nil {
|
||||||
|
return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err)
|
||||||
}
|
}
|
||||||
if err := validateNote(note, request.Product, request.Version); err != nil {
|
if err := validateNote(note, request.Product, request.Version); err != nil {
|
||||||
return Candidate{}, fmt.Errorf("release note %q: %w", path, err)
|
return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||||
}
|
}
|
||||||
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
|
if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
|
||||||
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err)
|
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
|
||||||
} else if exists {
|
} else if exists {
|
||||||
return Candidate{}, fmt.Errorf("local tag %q already exists", request.Version)
|
return captured, fmt.Errorf("local tag %q already exists", identity.Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil
|
captured.Note = string(note)
|
||||||
|
return captured, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate runs the standard offline Go suite, followed by AdditionalChecks
|
// Validate runs the selected offline Go suite, followed by AdditionalChecks
|
||||||
// when supplied, for the captured candidate.
|
// when supplied, for the captured candidate.
|
||||||
func Validate(ctx context.Context, request Request, captured Candidate) error {
|
func Validate(ctx context.Context, request Request, captured Candidate) error {
|
||||||
if err := runDefaultValidation(ctx, captured.RepositoryDir, request.Output); err != nil {
|
owner := request.ToolOwner
|
||||||
|
if owner == nil {
|
||||||
|
owner = gotool.NewOwner(captured.ModuleDir)
|
||||||
|
}
|
||||||
|
if err := validation.Run(ctx, validation.Request{
|
||||||
|
RepositoryDir: captured.RepositoryDir,
|
||||||
|
ModuleDir: captured.ModuleDir,
|
||||||
|
Commit: captured.Commit,
|
||||||
|
Output: request.Output,
|
||||||
|
Settings: request.Validation,
|
||||||
|
ToolOwner: owner,
|
||||||
|
}); err != nil {
|
||||||
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
||||||
}
|
}
|
||||||
if request.AdditionalChecks != nil {
|
if request.AdditionalChecks != nil {
|
||||||
if err := request.AdditionalChecks(ctx, captured); err != nil {
|
callbackErr := request.AdditionalChecks(ctx, captured)
|
||||||
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
if callbackErr != nil {
|
||||||
|
return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -127,23 +159,23 @@ func Recheck(ctx context.Context, captured Candidate, expectedTagObject string)
|
|||||||
return fmt.Errorf("worktree or index changed:\n%s", current.status)
|
return fmt.Errorf("worktree or index changed:\n%s", current.status)
|
||||||
}
|
}
|
||||||
if expectedTagObject == "" {
|
if expectedTagObject == "" {
|
||||||
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil {
|
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Tag); err != nil {
|
||||||
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err)
|
return fmt.Errorf("recheck local tag %q: %w", captured.Tag, err)
|
||||||
} else if exists {
|
} else if exists {
|
||||||
return fmt.Errorf("local tag %q appeared during validation", captured.Version)
|
return fmt.Errorf("local tag %q appeared during validation", captured.Tag)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Version)
|
object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read local tag %q: %w", captured.Version, err)
|
return fmt.Errorf("read local tag %q: %w", captured.Tag, err)
|
||||||
}
|
}
|
||||||
tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Version)
|
tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect local tag %q: %w", captured.Version, err)
|
return fmt.Errorf("inspect local tag %q: %w", captured.Tag, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" {
|
if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" {
|
||||||
return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Version, expectedTagObject)
|
return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Tag, expectedTagObject)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -193,10 +225,10 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, error) {
|
func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + version},
|
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
@@ -214,16 +246,6 @@ func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, erro
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
|
|
||||||
tool, err := gotool.New(ctx, repositoryDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("prepare Go toolchain: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, output,
|
|
||||||
"test", "-mod=readonly", "-count=1", "./...")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func validStableVersion(version string) bool {
|
func validStableVersion(version string) bool {
|
||||||
return versioncontract.ValidStableVersion(version)
|
return versioncontract.ValidStableVersion(version)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ package candidate
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testVersion = "v1.2.3"
|
const testVersion = "v1.2.3"
|
||||||
@@ -43,12 +49,16 @@ func TestValidStableVersion(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
||||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n")
|
||||||
|
gitRun(t, repository, "add", "guide.md")
|
||||||
|
gitRun(t, repository, "commit", "-m", "add Markdown guide")
|
||||||
calls := 0
|
calls := 0
|
||||||
var seen Candidate
|
var seen Candidate
|
||||||
candidate, err := Check(context.Background(), Request{
|
candidate, err := Check(context.Background(), Request{
|
||||||
RepositoryDir: repository,
|
RepositoryDir: repository,
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
|
Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}},
|
||||||
AdditionalChecks: func(_ context.Context, captured Candidate) error {
|
AdditionalChecks: func(_ context.Context, captured Candidate) error {
|
||||||
calls++
|
calls++
|
||||||
seen = captured
|
seen = captured
|
||||||
@@ -65,7 +75,7 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
|||||||
if seen != candidate {
|
if seen != candidate {
|
||||||
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
|
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
|
||||||
}
|
}
|
||||||
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion {
|
if candidate.RepositoryDir != repository || candidate.ModuleDir != repository || candidate.ModulePath != "example.com/candidate" || candidate.Tag != testVersion || candidate.Commit != commit || candidate.Version != testVersion {
|
||||||
t.Fatalf("Check() candidate = %#v", candidate)
|
t.Fatalf("Check() candidate = %#v", candidate)
|
||||||
}
|
}
|
||||||
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
|
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
|
||||||
@@ -76,6 +86,175 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCaptureUsesNestedMajorModuleNoteAndTag(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRoot release.\n")
|
||||||
|
moduleDir := "integrations/ckconfig/v2"
|
||||||
|
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig/v2\n\ngo 1.25.0\n")
|
||||||
|
writeTestFile(t, repository, moduleDir+"/docs/releases/v2.3.4.md", "# Example Product v2.3.4\n\nNested release.\n")
|
||||||
|
gitRun(t, repository, "add", moduleDir)
|
||||||
|
gitRun(t, repository, "commit", "-m", "add nested major module")
|
||||||
|
|
||||||
|
captured, err := Capture(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: "v2.3.4",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if captured.RepositoryDir != repository || captured.ModuleDir != filepath.Join(repository, moduleDir) || captured.ModulePath != "example.com/ckconfig/v2" || captured.Tag != "integrations/ckconfig/v2.3.4" {
|
||||||
|
t.Fatalf("nested candidate identity = %#v", captured)
|
||||||
|
}
|
||||||
|
if !strings.Contains(captured.Note, "Nested release.") || strings.Contains(captured.Note, "Root release.") {
|
||||||
|
t.Fatalf("captured note = %q, want nested module note", captured.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
gitRun(t, repository, "tag", captured.Tag)
|
||||||
|
conflicted, err := Capture(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: "v2.3.4",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), captured.Tag) {
|
||||||
|
t.Fatalf("Capture() with existing nested tag returned %#v, %v", conflicted, err)
|
||||||
|
}
|
||||||
|
if conflicted.ModulePath != captured.ModulePath || conflicted.Tag != captured.Tag || conflicted.Commit != captured.Commit {
|
||||||
|
t.Fatalf("capture failure lost identity evidence: %#v", conflicted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureKeepsRepositoryWideCleanlinessGuardForNestedSelection(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||||
|
moduleDir := "integrations/ckconfig"
|
||||||
|
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig\n")
|
||||||
|
writeTestFile(t, repository, moduleDir+"/docs/releases/"+testVersion+".md", "# Example Product v1.2.3\n")
|
||||||
|
gitRun(t, repository, "add", moduleDir)
|
||||||
|
gitRun(t, repository, "commit", "-m", "add nested module")
|
||||||
|
writeTestFile(t, repository, "sibling/untracked.txt", "dirty\n")
|
||||||
|
captured, err := Capture(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "must be clean") {
|
||||||
|
t.Fatalf("Capture() = %#v, %v; want repository-wide dirty-state rejection", captured, err)
|
||||||
|
}
|
||||||
|
if captured.Commit == "" {
|
||||||
|
t.Fatalf("dirty-state error lost captured commit: %#v", captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n")
|
||||||
|
gitRun(t, repository, "add", "unformatted.go")
|
||||||
|
gitRun(t, repository, "commit", "-m", "add unformatted Go source")
|
||||||
|
productCalls := 0
|
||||||
|
buildCalls := 0
|
||||||
|
_, err := Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
Validation: validation.Settings{GoFormat: true},
|
||||||
|
AdditionalChecks: func(context.Context, Candidate) error {
|
||||||
|
productCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
|
||||||
|
buildCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var failure *validation.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != validation.GoFormat {
|
||||||
|
t.Fatalf("Check error = %v, want go-format failure", err)
|
||||||
|
}
|
||||||
|
if productCalls != 0 || buildCalls != 0 {
|
||||||
|
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
writeTestFile(t, repository, "guide.md", "[outside](../../outside.md)\n")
|
||||||
|
gitRun(t, repository, "add", "guide.md")
|
||||||
|
gitRun(t, repository, "commit", "-m", "add outside Markdown destination")
|
||||||
|
productCalls := 0
|
||||||
|
buildCalls := 0
|
||||||
|
_, err := Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
Validation: validation.Settings{
|
||||||
|
Markdown: &validation.MarkdownSettings{EnforceCheckoutBoundary: true},
|
||||||
|
},
|
||||||
|
AdditionalChecks: func(context.Context, Candidate) error {
|
||||||
|
productCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
|
||||||
|
buildCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var failure *validation.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != validation.MarkdownLinks {
|
||||||
|
t.Fatalf("Check() error = %v, want markdown-links failure", err)
|
||||||
|
}
|
||||||
|
if productCalls != 0 || buildCalls != 0 {
|
||||||
|
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after Markdown failure", productCalls, buildCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) {
|
||||||
|
t.Setenv("GOWORK", "off")
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
writeTestFile(t, repository, "go.work", "go 1.25.0\n")
|
||||||
|
writeTestFile(t, repository, "nested/go.work.sum", "workspace-sum\n")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repository, "linked"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../go.work", filepath.Join(repository, "linked", "go.work")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitRun(t, repository, "add", "go.work", "nested/go.work.sum", "linked/go.work")
|
||||||
|
gitRun(t, repository, "commit", "-m", "add workspace files")
|
||||||
|
_, err := Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
Validation: validation.Settings{ModuleDistribution: validation.ModuleDistributionSettings{
|
||||||
|
WorkspaceFiles: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
var failure *validation.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != validation.WorkspaceFiles {
|
||||||
|
t.Fatalf("Check error = %v, want workspace-files failure", err)
|
||||||
|
}
|
||||||
|
want := map[string]bool{
|
||||||
|
"go.work": false,
|
||||||
|
"linked/go.work": false,
|
||||||
|
"nested/go.work.sum": false,
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != len(want) {
|
||||||
|
t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want))
|
||||||
|
}
|
||||||
|
for _, finding := range failure.Findings {
|
||||||
|
if _, ok := want[finding.Path]; !ok {
|
||||||
|
t.Errorf("unexpected workspace path %q", finding.Path)
|
||||||
|
}
|
||||||
|
want[finding.Path] = true
|
||||||
|
}
|
||||||
|
for path, found := range want {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("workspace path %q was not reported", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
|
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
|
||||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
@@ -90,6 +269,147 @@ func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckRefreshesGoSettingsAfterAdditionalChecks(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
t.Setenv("GOENV", "off")
|
||||||
|
t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "initial-module-cache"))
|
||||||
|
selectedCache := filepath.Join(t.TempDir(), "callback-module-cache")
|
||||||
|
var observed string
|
||||||
|
_, err := Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
AdditionalChecks: func(context.Context, Candidate) error {
|
||||||
|
t.Setenv("GOMODCACHE", selectedCache)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error {
|
||||||
|
tool, err := owner.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output, err := tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "env", "GOMODCACHE")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
observed = strings.TrimSpace(string(output))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if observed != selectedCache {
|
||||||
|
t.Fatalf("Go settings after additional checks = %q, want %q", observed, selectedCache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNeedsNoGoDiscoveryAfterFinalCallback(t *testing.T) {
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitOnly := t.TempDir()
|
||||||
|
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository, Product: "Example Product", Version: testVersion,
|
||||||
|
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
|
||||||
|
t.Setenv("PATH", gitOnly)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("completed Go work was rediscovered after final callback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the controlled Go command fixture uses a POSIX shell")
|
||||||
|
}
|
||||||
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
logPath := installCandidateGoShim(t)
|
||||||
|
t.Setenv("GOENV", "off")
|
||||||
|
_, err := Check(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Product: "Example Product",
|
||||||
|
Version: testVersion,
|
||||||
|
AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error {
|
||||||
|
tool, err := owner.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
commands := strings.Split(strings.TrimSpace(string(contents)), "\n")
|
||||||
|
var discoveries, suites, callbacks int
|
||||||
|
for _, command := range commands {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(command, "env "):
|
||||||
|
discoveries++
|
||||||
|
case strings.HasPrefix(command, "test "):
|
||||||
|
suites++
|
||||||
|
case command == "version":
|
||||||
|
callbacks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if discoveries != 2 || suites != 1 || callbacks != 1 {
|
||||||
|
t.Fatalf("Go invocations = %q; discovery=%d suite=%d callback=%d, want 2, 1, 1", commands, discoveries, suites, callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCandidateGoShim(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
directory := t.TempDir()
|
||||||
|
logPath := filepath.Join(directory, "go-commands")
|
||||||
|
settings := map[string]string{
|
||||||
|
"GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, "GOENV": "off",
|
||||||
|
"CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "", "CGO_CXXFLAGS": "",
|
||||||
|
"CGO_ENABLED": "1", "CGO_LDFLAGS": "", "GOCACHE": "", "GOCACHEPROG": "",
|
||||||
|
"GOMODCACHE": "", "GOPATH": "", "GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "",
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
|
if [ "${1-}" = "env" ]; then
|
||||||
|
printf '%%s\n' '%s'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "${1-}" = "version" ]; then
|
||||||
|
echo 'go version fixture'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`, encoded)
|
||||||
|
shimPath := filepath.Join(directory, "go")
|
||||||
|
if err := os.WriteFile(shimPath, []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := directory
|
||||||
|
if current := os.Getenv("PATH"); current != "" {
|
||||||
|
path += string(os.PathListSeparator) + current
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", path)
|
||||||
|
t.Setenv("RELEASEKIT_GO_LOG", logPath)
|
||||||
|
return logPath
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
|
func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
|
||||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n")
|
writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n")
|
||||||
@@ -123,6 +443,14 @@ func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
|
|||||||
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") {
|
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") {
|
||||||
t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String())
|
t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String())
|
||||||
}
|
}
|
||||||
|
var failure *validation.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != validation.GoTest {
|
||||||
|
t.Fatalf("Check() error = %v; want a go-test validation failure", err)
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("Check() error = %v; want the Go process exit error preserved", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,7 +629,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||||
_, err := Check(context.Background(), Request{
|
captured, err := Check(context.Background(), Request{
|
||||||
RepositoryDir: repository,
|
RepositoryDir: repository,
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
@@ -313,6 +641,9 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
|||||||
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
|
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
|
||||||
t.Fatalf("Check() error = %v, want tag recheck error", err)
|
t.Fatalf("Check() error = %v, want tag recheck error", err)
|
||||||
}
|
}
|
||||||
|
if captured.RepositoryDir != repository || captured.ModuleDir != repository || captured.ModulePath != "example.com/candidate" || captured.Tag != testVersion || captured.Commit == "" {
|
||||||
|
t.Fatalf("tag recheck failure lost candidate evidence: %#v", captured)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validRequest(repository string) Request {
|
func validRequest(repository string) Request {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -14,16 +11,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
||||||
if len(result.Targets) == 0 {
|
settings := result.Settings
|
||||||
|
if len(settings.Targets) == 0 {
|
||||||
if len(result.Artifacts) != 0 {
|
if len(result.Artifacts) != 0 {
|
||||||
return nil, fmt.Errorf("source-only build result contains artifacts")
|
return nil, fmt.Errorf("source-only build result contains artifacts")
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(result.Artifacts) != len(result.Targets) {
|
if len(result.Artifacts) != len(settings.Targets) {
|
||||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
||||||
}
|
}
|
||||||
declarations := artifact.ExpectedBuild(result.BinaryName, result.Version, result.Targets)
|
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
|
||||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
||||||
for _, declaration := range declarations {
|
for _, declaration := range declarations {
|
||||||
target := declaration.Target
|
target := declaration.Target
|
||||||
@@ -53,22 +51,6 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
|||||||
if !info.Mode().IsRegular() {
|
if !info.Mode().IsRegular() {
|
||||||
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
|
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{
|
assets = append(assets, gitea.ExpectedAsset{
|
||||||
Filename: artifact.Filename,
|
Filename: artifact.Filename,
|
||||||
SHA256: artifact.SHA256,
|
SHA256: artifact.SHA256,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||||
)
|
)
|
||||||
@@ -25,8 +26,10 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result, err := declaredBuildAssets(internalbuild.Result{
|
result, err := declaredBuildAssets(internalbuild.Result{
|
||||||
Version: "v1.2.3", BinaryName: "app",
|
Version: "v1.2.3",
|
||||||
Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
|
Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{
|
||||||
|
{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"},
|
||||||
|
}),
|
||||||
Artifacts: []internalbuild.Artifact{
|
Artifacts: []internalbuild.Artifact{
|
||||||
{Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)},
|
{Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)},
|
||||||
{Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)},
|
{Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)},
|
||||||
@@ -47,20 +50,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) {
|
func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64")
|
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64")
|
||||||
contents := []byte("built application")
|
contents := []byte("built application")
|
||||||
@@ -68,7 +57,7 @@ func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := declaredBuildAssets(internalbuild.Result{
|
_, err := declaredBuildAssets(internalbuild.Result{
|
||||||
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
|
Version: "v1.2.3", Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{{OS: "linux", Arch: "amd64"}}),
|
||||||
Artifacts: []internalbuild.Artifact{{
|
Artifacts: []internalbuild.Artifact{{
|
||||||
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents),
|
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents),
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
@@ -29,8 +31,10 @@ const (
|
|||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
Product string
|
Product string
|
||||||
Version string
|
Version string
|
||||||
|
ExpectedTag string
|
||||||
Destination gitea.Destination
|
Destination gitea.Destination
|
||||||
Build *internalbuild.Spec
|
Build *internalbuild.Spec
|
||||||
Prebuilt *internalbuild.Result
|
Prebuilt *internalbuild.Result
|
||||||
@@ -38,6 +42,10 @@ type Request struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
|
ModulePath string
|
||||||
|
Tag string
|
||||||
CandidateCommit string
|
CandidateCommit string
|
||||||
ReleaseURL string
|
ReleaseURL string
|
||||||
State State
|
State State
|
||||||
@@ -59,28 +67,49 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
if request.Build != nil && request.Prebuilt != nil {
|
if request.Build != nil && request.Prebuilt != nil {
|
||||||
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
||||||
}
|
}
|
||||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||||
}
|
}
|
||||||
source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
result.RepositoryDir = repositoryDir
|
||||||
if err != nil {
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
return result, fmt.Errorf("inspect tagged source: %w", err)
|
|
||||||
}
|
|
||||||
result.CandidateCommit = source.TagCommit
|
|
||||||
if source.Status != "" {
|
|
||||||
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
|
||||||
}
|
|
||||||
if source.Head != source.TagCommit {
|
|
||||||
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit)
|
|
||||||
}
|
|
||||||
notePath := "docs/releases/" + request.Version + ".md"
|
|
||||||
note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
result.CandidateCommit = commit
|
||||||
|
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, commit)
|
||||||
|
result.ModuleDir = identity.ModuleDir
|
||||||
|
result.ModulePath = identity.ModulePath
|
||||||
|
result.Tag = identity.Tag
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
|
||||||
|
}
|
||||||
|
result.RepositoryDir = identity.RepositoryDir
|
||||||
|
result.ModuleDir = identity.ModuleDir
|
||||||
|
result.ModulePath = identity.ModulePath
|
||||||
|
result.Tag = identity.Tag
|
||||||
|
if request.ExpectedTag != "" && request.ExpectedTag != identity.Tag {
|
||||||
|
return result, fmt.Errorf("expected delivery tag %q does not match selected module tag %q", request.ExpectedTag, identity.Tag)
|
||||||
|
}
|
||||||
|
source, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("inspect tagged source: %w", err)
|
||||||
|
}
|
||||||
|
if source.Status != "" {
|
||||||
|
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
||||||
|
}
|
||||||
|
if source.Head != commit {
|
||||||
|
return result, fmt.Errorf("checkout HEAD changed from captured commit %s to %s", commit, source.Head)
|
||||||
|
}
|
||||||
|
if source.TagCommit != commit {
|
||||||
|
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, identity.Tag, source.TagCommit)
|
||||||
|
}
|
||||||
|
note, err := git.ReadCommitFile(ctx, repositoryDir, commit, identity.NotePath)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("read release note %q: %w", identity.NotePath, err)
|
||||||
|
}
|
||||||
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
||||||
return result, fmt.Errorf("release note %q: %w", notePath, err)
|
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
build := request.Prebuilt
|
build := request.Prebuilt
|
||||||
@@ -90,12 +119,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporaryDir)
|
defer os.RemoveAll(temporaryDir)
|
||||||
built, err := internalbuild.Build(ctx, internalbuild.Request{
|
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||||
RepositoryDir: repositoryDir,
|
Identity: identity,
|
||||||
Version: request.Version,
|
|
||||||
Spec: *request.Build,
|
Spec: *request.Build,
|
||||||
OutputDir: filepath.Join(temporaryDir, "assets"),
|
OutputDir: filepath.Join(temporaryDir, "assets"),
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
|
ToolOwner: gotool.NewOwner(identity.ModuleDir),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("build delivery assets: %w", err)
|
return result, fmt.Errorf("build delivery assets: %w", err)
|
||||||
@@ -109,7 +138,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil {
|
if err := validateBuild(identity, commit, build, assets); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if len(assets) > 0 {
|
if len(assets) > 0 {
|
||||||
@@ -117,23 +146,23 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("verify build assets: %w", err)
|
return result, fmt.Errorf("verify build assets: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if build != nil && len(build.Targets) != 0 {
|
if build != nil && len(build.Settings.Targets) != 0 {
|
||||||
current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
current, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("recheck tagged source after build: %w", err)
|
return result, fmt.Errorf("recheck tagged source after build: %w", err)
|
||||||
}
|
}
|
||||||
if current.Status != "" {
|
if current.Status != "" {
|
||||||
return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status)
|
return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status)
|
||||||
}
|
}
|
||||||
if current.Head != source.TagCommit || current.TagCommit != source.TagCommit {
|
if current.Head != commit || current.TagCommit != commit {
|
||||||
return result, errors.New("tagged source changed during build")
|
return result, errors.New("tagged source changed during build")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
||||||
Destination: request.Destination,
|
Destination: request.Destination,
|
||||||
Version: request.Version,
|
Tag: identity.Tag,
|
||||||
Commit: source.TagCommit,
|
Commit: commit,
|
||||||
Note: string(note),
|
Note: string(note),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,7 +183,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
result.Uncertain = assetIdentities(assets)
|
result.Uncertain = assetIdentities(assets)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
|
uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
|
||||||
result.Matching = uploadResult.Matching
|
result.Matching = uploadResult.Matching
|
||||||
result.Uploaded = uploadResult.Uploaded
|
result.Uploaded = uploadResult.Uploaded
|
||||||
result.Pending = uploadResult.Pending
|
result.Pending = uploadResult.Pending
|
||||||
@@ -167,14 +196,25 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, uploadErr
|
return result, uploadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBuild(repositoryDir, commit, version string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
||||||
if build == nil {
|
if build == nil {
|
||||||
if len(assets) != 0 {
|
if len(assets) != 0 {
|
||||||
return errors.New("assets require a build identity")
|
return errors.New("assets require a build identity")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(build.Targets) == 0 {
|
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve build repository: %w", err)
|
||||||
|
}
|
||||||
|
buildModule, err := filepath.Abs(build.ModuleDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve build module: %w", err)
|
||||||
|
}
|
||||||
|
if buildRepository != identity.RepositoryDir || buildModule != identity.ModuleDir || build.ModulePath != identity.ModulePath || build.Tag != identity.Tag || build.CandidateCommit != commit || build.Version != identity.Version {
|
||||||
|
return errors.New("build result does not identify the selected tagged module")
|
||||||
|
}
|
||||||
|
if len(build.Settings.Targets) == 0 {
|
||||||
if len(assets) != 0 {
|
if len(assets) != 0 {
|
||||||
return errors.New("page-only delivery cannot include binary assets")
|
return errors.New("page-only delivery cannot include binary assets")
|
||||||
}
|
}
|
||||||
@@ -183,14 +223,7 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R
|
|||||||
if !build.SourceVerified {
|
if !build.SourceVerified {
|
||||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
return errors.New("build source was not verified as clean and unchanged during compilation")
|
||||||
}
|
}
|
||||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("resolve build repository: %w", err)
|
|
||||||
}
|
|
||||||
if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version {
|
|
||||||
return errors.New("build result does not identify the selected tagged source")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(build.MainPackage) == "" || strings.TrimSpace(build.BinaryName) == "" || len(assets) != len(build.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
|
||||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
85
internal/delivery/performance_test.go
Normal file
85
internal/delivery/performance_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||||
|
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",
|
||||||
|
Settings: artifact.NormalizeSettings("./cmd/releasekit", "releasekit", []artifact.Target{
|
||||||
|
{OS: "linux", Arch: "amd64"},
|
||||||
|
{OS: "windows", Arch: "amd64"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for _, target := range result.Settings.Targets {
|
||||||
|
name := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
|
||||||
|
if target.OS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
path := filepath.Join(outputDir, name)
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
result.Artifacts = append(result.Artifacts, internalbuild.Artifact{
|
||||||
|
Path: path, Filename: name, Target: target,
|
||||||
|
SHA256: hex.EncodeToString(digest[:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, 2 * performanceArtifactSize
|
||||||
|
}
|
||||||
222
internal/git/blob_reader.go
Normal file
222
internal/git/blob_reader.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errBlobObjectMissing = errors.New("Git object is missing")
|
||||||
|
|
||||||
|
// BlobReader reads requested blobs through one lazy git cat-file --batch
|
||||||
|
// process. Reads are serialized; callers must close the reader when finished.
|
||||||
|
type BlobReader struct {
|
||||||
|
repositoryDir string
|
||||||
|
parent context.Context
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
child *process.PipedProcess
|
||||||
|
stdout *bufio.Reader
|
||||||
|
stderr bytes.Buffer
|
||||||
|
closed bool
|
||||||
|
aborted bool
|
||||||
|
terminal error
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlobReader creates a lazy reader for one repository. It starts no child
|
||||||
|
// process until the first valid blob is requested.
|
||||||
|
func NewBlobReader(ctx context.Context, repositoryDir string) *BlobReader {
|
||||||
|
return &BlobReader{repositoryDir: repositoryDir, parent: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns one blob body from the candidate repository. The returned bytes
|
||||||
|
// are newly allocated and owned by the caller.
|
||||||
|
func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, error) {
|
||||||
|
if !validObjectID(objectID) {
|
||||||
|
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.mu.Lock()
|
||||||
|
defer reader.mu.Unlock()
|
||||||
|
if reader.terminal != nil {
|
||||||
|
return nil, reader.terminal
|
||||||
|
}
|
||||||
|
if reader.closed {
|
||||||
|
return nil, fmt.Errorf("read blob %s: Git blob reader is closed", objectID)
|
||||||
|
}
|
||||||
|
if err := reader.startLocked(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
child := reader.child
|
||||||
|
callbackDone := make(chan struct{})
|
||||||
|
stopCancellation := context.AfterFunc(ctx, func() {
|
||||||
|
defer close(callbackDone)
|
||||||
|
child.Cancel()
|
||||||
|
_ = child.Stdin().Close()
|
||||||
|
_ = child.Stdout().Close()
|
||||||
|
})
|
||||||
|
body, err := reader.exchange(objectID)
|
||||||
|
if !stopCancellation() {
|
||||||
|
<-callbackDone
|
||||||
|
}
|
||||||
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
|
err = contextErr
|
||||||
|
} else if contextErr := reader.parent.Err(); contextErr != nil {
|
||||||
|
err = contextErr
|
||||||
|
}
|
||||||
|
if errors.Is(err, errBlobObjectMissing) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, reader.abortLocked(objectID, err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes a healthy session cleanly or reaps an aborted session. It is
|
||||||
|
// safe to call more than once.
|
||||||
|
func (reader *BlobReader) Close() error {
|
||||||
|
reader.mu.Lock()
|
||||||
|
defer reader.mu.Unlock()
|
||||||
|
if reader.closed {
|
||||||
|
return reader.closeErr
|
||||||
|
}
|
||||||
|
reader.closed = true
|
||||||
|
if reader.aborted {
|
||||||
|
reader.closeErr = reader.terminal
|
||||||
|
return reader.closeErr
|
||||||
|
}
|
||||||
|
if reader.child == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := reader.child.Stdin().Close(); err != nil {
|
||||||
|
reader.closeErr = reader.abortLocked("", fmt.Errorf("close Git cat-file input: %w", err))
|
||||||
|
return reader.closeErr
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(io.Discard, reader.child.Stdout()); err != nil {
|
||||||
|
reader.closeErr = reader.abortLocked("", fmt.Errorf("finish Git cat-file output: %w", err))
|
||||||
|
return reader.closeErr
|
||||||
|
}
|
||||||
|
if err := reader.child.Wait(); err != nil {
|
||||||
|
reader.closeErr = reader.processError("close Git cat-file batch", err)
|
||||||
|
}
|
||||||
|
return reader.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *BlobReader) startLocked() error {
|
||||||
|
if reader.child != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := reader.parent.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
child, err := process.StartPiped(reader.parent, process.Invocation{
|
||||||
|
Args: []string{"git", "cat-file", "--batch"},
|
||||||
|
Dir: reader.repositoryDir,
|
||||||
|
Env: map[string]string{
|
||||||
|
"GIT_NO_LAZY_FETCH": "1",
|
||||||
|
"GIT_ALLOW_PROTOCOL": "",
|
||||||
|
},
|
||||||
|
Stderr: &reader.stderr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader.child = child
|
||||||
|
reader.stdout = bufio.NewReader(child.Stdout())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *BlobReader) exchange(objectID string) ([]byte, error) {
|
||||||
|
if _, err := io.WriteString(reader.child.Stdin(), objectID+"\n"); err != nil {
|
||||||
|
return nil, fmt.Errorf("write object request: %w", err)
|
||||||
|
}
|
||||||
|
header, err := reader.stdout.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read object header: %w", err)
|
||||||
|
}
|
||||||
|
fields := strings.Fields(strings.TrimSuffix(header, "\n"))
|
||||||
|
if len(fields) == 2 && strings.EqualFold(fields[0], objectID) && fields[1] == "missing" {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, errBlobObjectMissing)
|
||||||
|
}
|
||||||
|
if len(fields) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid Git blob header %q", strings.TrimSuffix(header, "\n"))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(fields[0], objectID) {
|
||||||
|
return nil, fmt.Errorf("Git blob response object ID %q does not match request %q", fields[0], objectID)
|
||||||
|
}
|
||||||
|
if fields[1] != "blob" {
|
||||||
|
return nil, fmt.Errorf("Git object %s has type %q, want blob", objectID, fields[1])
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseInt(fields[2], 10, strconv.IntSize)
|
||||||
|
if err != nil || size < 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New("negative object size")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid Git blob size %q: %w", fields[2], err)
|
||||||
|
}
|
||||||
|
body := make([]byte, int(size))
|
||||||
|
if _, err := io.ReadFull(reader.stdout, body); err != nil {
|
||||||
|
return nil, fmt.Errorf("read %d-byte Git blob body: %w", size, err)
|
||||||
|
}
|
||||||
|
framing, err := reader.stdout.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read Git blob framing: %w", err)
|
||||||
|
}
|
||||||
|
if framing != '\n' {
|
||||||
|
return nil, fmt.Errorf("invalid Git blob framing byte %q", framing)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *BlobReader) abortLocked(objectID string, cause error) error {
|
||||||
|
if reader.aborted {
|
||||||
|
return reader.terminal
|
||||||
|
}
|
||||||
|
reader.aborted = true
|
||||||
|
var waitErr error
|
||||||
|
if reader.child != nil {
|
||||||
|
reader.child.Cancel()
|
||||||
|
_ = reader.child.Stdin().Close()
|
||||||
|
_ = reader.child.Stdout().Close()
|
||||||
|
waitErr = reader.child.Wait()
|
||||||
|
}
|
||||||
|
if contextErr := reader.parent.Err(); contextErr != nil {
|
||||||
|
cause = contextErr
|
||||||
|
}
|
||||||
|
reader.terminal = reader.processError("read blob "+objectID, errors.Join(cause, relevantProcessError(waitErr, cause)))
|
||||||
|
return reader.terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *BlobReader) processError(operation string, cause error) error {
|
||||||
|
message := strings.TrimSpace(reader.stderr.String())
|
||||||
|
if message != "" {
|
||||||
|
return fmt.Errorf("%s: %s: %w", operation, message, cause)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", operation, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relevantProcessError(waitErr, cause error) error {
|
||||||
|
if waitErr == nil || errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return waitErr
|
||||||
|
}
|
||||||
195
internal/git/blob_reader_test.go
Normal file
195
internal/git/blob_reader_test.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBlobReaderReadsLooseAndPackedObjects(t *testing.T) {
|
||||||
|
for _, packed := range []bool{false, true} {
|
||||||
|
name := "loose"
|
||||||
|
if packed {
|
||||||
|
name = "packed"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
binary := []byte{0, '\n', 0xff, 'b', '\n', 0}
|
||||||
|
writeRepoFile(t, fixture.work, "empty.dat", "")
|
||||||
|
writeRepoFile(t, fixture.work, "binary.dat", string(binary))
|
||||||
|
gitRun(t, fixture.work, "add", ".")
|
||||||
|
gitRun(t, fixture.work, "commit", "-m", "add binary fixtures")
|
||||||
|
want := map[string][]byte{
|
||||||
|
gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"): []byte("candidate"),
|
||||||
|
gitText(t, fixture.work, "rev-parse", "HEAD:empty.dat"): {},
|
||||||
|
gitText(t, fixture.work, "rev-parse", "HEAD:binary.dat"): binary,
|
||||||
|
}
|
||||||
|
if packed {
|
||||||
|
gitRun(t, fixture.work, "gc", "--aggressive", "--prune=now")
|
||||||
|
}
|
||||||
|
reader := NewBlobReader(t.Context(), fixture.work)
|
||||||
|
defer reader.Close()
|
||||||
|
for repeat := 0; repeat < 2; repeat++ {
|
||||||
|
for objectID, expected := range want {
|
||||||
|
got, err := reader.Read(t.Context(), objectID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read(%s) error = %v", objectID, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, expected) {
|
||||||
|
t.Errorf("Read(%s) = %v, want %v", objectID, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderMissingObjectKeepsBatchSessionUsable(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
objectID := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||||
|
reader := NewBlobReader(t.Context(), fixture.work)
|
||||||
|
defer reader.Close()
|
||||||
|
missing := strings.Repeat("0", 40)
|
||||||
|
if _, err := reader.Read(t.Context(), missing); err == nil || !strings.Contains(err.Error(), "missing") {
|
||||||
|
t.Fatalf("Read(missing) error = %v, want contextual missing-object error", err)
|
||||||
|
}
|
||||||
|
contents, err := reader.Read(t.Context(), objectID)
|
||||||
|
if err != nil || string(contents) != "candidate" {
|
||||||
|
t.Fatalf("Read(existing) = %q, %v after missing request", contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderPreservesHexCaseAndArbitraryBodyBytes(t *testing.T) {
|
||||||
|
installFakeGit(t, `while IFS= read -r object; do
|
||||||
|
upper=$(printf '%s' "$object" | tr 'a-f' 'A-F')
|
||||||
|
printf '%s blob 4\n' "$upper"
|
||||||
|
printf 'a\000b\n\n'
|
||||||
|
done`)
|
||||||
|
objectID := strings.Repeat("abcdef0123", 4)
|
||||||
|
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||||
|
contents, err := reader.Read(t.Context(), objectID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := []byte{'a', 0, 'b', '\n'}; !bytes.Equal(contents, want) {
|
||||||
|
t.Fatalf("Read() = %v, want %v", contents, want)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderRejectsInvalidHeadersAndFraming(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "wrong object", response: `printf '%040d blob 0\n\n' 0`, want: "does not match request"},
|
||||||
|
{name: "wrong type", response: `printf '%s tree 0\n\n' "$object"`, want: `has type "tree"`},
|
||||||
|
{name: "invalid size", response: `printf '%s blob -1\n' "$object"`, want: "invalid Git blob size"},
|
||||||
|
{name: "invalid framing", response: `printf '%s blob 0\nx' "$object"`, want: "invalid Git blob framing"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
installFakeGit(t, "IFS= read -r object\n"+testCase.response)
|
||||||
|
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||||
|
_, err := reader.Read(t.Context(), strings.Repeat("d", 40))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("Read() error = %v, want %q", err, testCase.want)
|
||||||
|
}
|
||||||
|
if closeErr := reader.Close(); !strings.Contains(closeErr.Error(), testCase.want) {
|
||||||
|
t.Fatalf("Close() error = %v, want original protocol error", closeErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderTruncatedBodyAbortsAndKeepsOriginalCause(t *testing.T) {
|
||||||
|
installFakeGit(t, `IFS= read -r object
|
||||||
|
printf '%s blob 8\n' "$object"
|
||||||
|
printf 'short'
|
||||||
|
exit 0`)
|
||||||
|
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||||
|
_, err := reader.Read(t.Context(), strings.Repeat("a", 40))
|
||||||
|
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
t.Fatalf("Read() error = %v, want truncated-body cause", err)
|
||||||
|
}
|
||||||
|
if closeErr := reader.Close(); !errors.Is(closeErr, io.ErrUnexpectedEOF) {
|
||||||
|
t.Fatalf("Close() error = %v, want original read cause", closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderCancellationDuringBodyReadReapsChild(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "ready")
|
||||||
|
installFakeGit(t, `IFS= read -r object
|
||||||
|
printf '%s blob 1048576\npartial' "$object"
|
||||||
|
: > "$RELEASEKIT_GIT_READER_READY"
|
||||||
|
exec sleep 60`)
|
||||||
|
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
reader := NewBlobReader(ctx, t.TempDir())
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := reader.Read(ctx, strings.Repeat("b", 40))
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
waitForFile(t, ready)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Read() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Read() did not return after cancellation")
|
||||||
|
}
|
||||||
|
if err := reader.Close(); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Close() error = %v, want original cancellation cause", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
||||||
|
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
||||||
|
exit 23`)
|
||||||
|
reader := NewBlobReader(t.Context(), t.TempDir())
|
||||||
|
_, err := reader.Read(t.Context(), strings.Repeat("c", 40))
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
|
||||||
|
t.Fatalf("Read() error = %v, want Git exit code 23", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fixture Git failure") {
|
||||||
|
t.Fatalf("Read() error = %v, want captured Git stderr", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installFakeGit(t *testing.T, script string) {
|
||||||
|
t.Helper()
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "git")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal(fmt.Errorf("inspect readiness signal: %w", err))
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("child did not create readiness signal")
|
||||||
|
}
|
||||||
155
internal/git/path.go
Normal file
155
internal/git/path.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathResolver traverses committed paths, sharing reference rules between
|
||||||
|
// release identity and validation. Lookups include both files and directories.
|
||||||
|
type PathResolver struct {
|
||||||
|
RepositoryDir string
|
||||||
|
Lookup func(context.Context, string) (TreeEntry, bool, error)
|
||||||
|
ReadLink func(context.Context, TreeEntry) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestinationKind classifies a committed local destination.
|
||||||
|
type DestinationKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DestinationEntry DestinationKind = "entry"
|
||||||
|
DestinationDirectory DestinationKind = "directory"
|
||||||
|
DestinationMissing DestinationKind = "missing"
|
||||||
|
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
||||||
|
DestinationSubmodule DestinationKind = "submodule"
|
||||||
|
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Destination records the resolved identity and its classification.
|
||||||
|
type Destination struct {
|
||||||
|
Kind DestinationKind
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDestination resolves local destinations using the captured Git tree.
|
||||||
|
// Relative destinations start at the source directory; absolute destinations
|
||||||
|
// within the repository directory map back to committed paths.
|
||||||
|
func (resolver PathResolver) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
directory := path.Dir(sourcePath)
|
||||||
|
if path.IsAbs(destination) {
|
||||||
|
relative, inside, err := resolver.relativeAbsoluteDestination(destination)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
if !inside {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
|
||||||
|
}
|
||||||
|
directory, destination = ".", relative
|
||||||
|
}
|
||||||
|
return resolver.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resolver PathResolver) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) {
|
||||||
|
for index, component := range components {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
remaining := components[index+1:]
|
||||||
|
switch component {
|
||||||
|
case "", ".":
|
||||||
|
continue
|
||||||
|
case "..":
|
||||||
|
if directory == "." {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil
|
||||||
|
}
|
||||||
|
directory = path.Dir(directory)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partial := path.Join(directory, component)
|
||||||
|
entry, exists, err := resolver.Lookup(ctx, partial)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
if entry.Type == "tree" {
|
||||||
|
directory = partial
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Mode == "160000" {
|
||||||
|
return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
if entry.Mode == "120000" {
|
||||||
|
if active[partial] {
|
||||||
|
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
||||||
|
}
|
||||||
|
linkText, err := resolver.ReadLink(ctx, entry)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
||||||
|
}
|
||||||
|
target := linkText
|
||||||
|
targetDirectory := directory
|
||||||
|
if path.IsAbs(target) {
|
||||||
|
relative, inside, err := resolver.relativeAbsoluteDestination(target)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
if !inside {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
|
||||||
|
}
|
||||||
|
targetDirectory, target = ".", relative
|
||||||
|
}
|
||||||
|
active[partial] = true
|
||||||
|
destination, err := resolver.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
|
||||||
|
delete(active, partial)
|
||||||
|
if err != nil || len(remaining) == 0 {
|
||||||
|
return destination, err
|
||||||
|
}
|
||||||
|
switch destination.Kind {
|
||||||
|
case DestinationDirectory:
|
||||||
|
return resolver.resolveDestination(ctx, destination.Path, remaining, active)
|
||||||
|
case DestinationEntry:
|
||||||
|
destination.Kind = DestinationMissing
|
||||||
|
case DestinationSymlinkCycle:
|
||||||
|
return destination, nil
|
||||||
|
}
|
||||||
|
destination.Path = unresolvedPath(destination.Path, remaining)
|
||||||
|
return destination, nil
|
||||||
|
}
|
||||||
|
if len(remaining) != 0 {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationDirectory, Path: directory}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resolver PathResolver) relativeAbsoluteDestination(destination string) (string, bool, error) {
|
||||||
|
root, err := filepath.Abs(resolver.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||||
|
}
|
||||||
|
root = filepath.ToSlash(root)
|
||||||
|
if destination == root {
|
||||||
|
return ".", true, nil
|
||||||
|
}
|
||||||
|
// Preserve the suffix for component traversal, including symlinks before '..'.
|
||||||
|
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
|
||||||
|
return relative, inside, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unresolvedPath preserves components that could not be traversed. Cleaning
|
||||||
|
// them would hide missing entries, non-directories, or checkout boundaries.
|
||||||
|
func unresolvedPath(base string, remaining []string) string {
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "/" + strings.Join(remaining, "/")
|
||||||
|
}
|
||||||
59
internal/git/performance_test.go
Normal file
59
internal/git/performance_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkPerformanceBlobReader(b *testing.B) {
|
||||||
|
fixture := newRemoteFixture(b)
|
||||||
|
objectID := gitText(b, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||||
|
const expectedBytes = int64(len("candidate"))
|
||||||
|
|
||||||
|
b.Run("new-session-per-read", func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(expectedBytes)
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportMetric(float64(expectedBytes), "bytes/op")
|
||||||
|
b.ReportMetric(1, "blobs/op")
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
reader := NewBlobReader(context.Background(), fixture.work)
|
||||||
|
contents, err := reader.Read(context.Background(), objectID)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if int64(len(contents)) != expectedBytes {
|
||||||
|
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
runtime.KeepAlive(contents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("shared-session", func(b *testing.B) {
|
||||||
|
reader := NewBlobReader(context.Background(), fixture.work)
|
||||||
|
b.Cleanup(func() {
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
b.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(expectedBytes)
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportMetric(float64(expectedBytes), "bytes/op")
|
||||||
|
b.ReportMetric(1, "blobs/op")
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
contents, err := reader.Read(context.Background(), objectID)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if int64(len(contents)) != expectedBytes {
|
||||||
|
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
|
||||||
|
}
|
||||||
|
runtime.KeepAlive(contents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,30 +8,51 @@ import (
|
|||||||
|
|
||||||
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
||||||
// against expectedCommit, and checks out that commit detached.
|
// against expectedCommit, and checks out that commit detached.
|
||||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error {
|
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
|
||||||
if !validVersionRef(version) || !validObjectID(expectedCommit) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version)
|
return err
|
||||||
}
|
}
|
||||||
ref := "refs/tags/" + version
|
if !validObjectID(expectedCommit) {
|
||||||
|
return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag)
|
||||||
|
}
|
||||||
|
ref := "refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
||||||
return fmt.Errorf("fetch tag %s from remote %s: %w", version, remote, err)
|
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
|
||||||
}
|
}
|
||||||
source, err := InspectTaggedSource(ctx, repositoryDir, version)
|
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(source.TagCommit, expectedCommit) {
|
if !strings.EqualFold(source.TagCommit, expectedCommit) {
|
||||||
return fmt.Errorf("tag %s resolves to commit %s, expected %s", version, source.TagCommit, expectedCommit)
|
return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil {
|
if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil {
|
||||||
return fmt.Errorf("check out tag %s at %s: %w", version, source.TagCommit, err)
|
return fmt.Errorf("check out tag %s at %s: %w", tag, source.TagCommit, err)
|
||||||
}
|
}
|
||||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
return VerifyTaggedCheckout(ctx, repositoryDir, tag, expectedCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean
|
||||||
|
// worktree all identify the expected commit. It does not fetch or check out.
|
||||||
|
func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error {
|
||||||
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !validObjectID(expectedCommit) {
|
||||||
|
return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag)
|
||||||
|
}
|
||||||
|
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("verify prepared checkout HEAD: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(head), expectedCommit) {
|
if source.Status != "" {
|
||||||
return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), expectedCommit)
|
return fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(source.TagCommit, expectedCommit) {
|
||||||
|
return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(source.Head, expectedCommit) {
|
||||||
|
return fmt.Errorf("checkout HEAD is %s, expected %s", source.Head, expectedCommit)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var (
|
|||||||
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
||||||
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
||||||
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
||||||
ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote")
|
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
||||||
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
|
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ type RemoteRef struct {
|
|||||||
Peeled string
|
Peeled string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot is the result of querying refs/heads/main and one exact version tag.
|
// Snapshot is the result of querying refs/heads/main and one exact tag.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Main RemoteRef
|
Main RemoteRef
|
||||||
Tag RemoteRef
|
Tag RemoteRef
|
||||||
@@ -97,14 +97,14 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
|||||||
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query reads exact main and version-tag refs. A command failure is unreadable,
|
// Query reads exact main and tag refs. A command failure is unreadable,
|
||||||
// never evidence that a ref is absent.
|
// never evidence that a ref is absent.
|
||||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) {
|
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
|
||||||
if !validVersionRef(version) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return Snapshot{}, fmt.Errorf("invalid version tag name %q", version)
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
mainRef := "refs/heads/main"
|
mainRef := "refs/heads/main"
|
||||||
tagRef := "refs/tags/" + version
|
tagRef := "refs/tags/" + tag
|
||||||
peeledRef := tagRef + "^{}"
|
peeledRef := tagRef + "^{}"
|
||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,7 +112,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
|
|||||||
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
|
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
|
||||||
}
|
}
|
||||||
main := RemoteRef{State: RefAbsent}
|
main := RemoteRef{State: RefAbsent}
|
||||||
tag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
|
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
@@ -125,19 +125,19 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
|
|||||||
case mainRef:
|
case mainRef:
|
||||||
main = mergeRef(main, object, false)
|
main = mergeRef(main, object, false)
|
||||||
case tagRef:
|
case tagRef:
|
||||||
tag = mergeRef(tag, object, false)
|
remoteTag = mergeRef(remoteTag, object, false)
|
||||||
case peeledRef:
|
case peeledRef:
|
||||||
tag = mergeRef(tag, object, true)
|
remoteTag = mergeRef(remoteTag, object, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Snapshot{Main: main, Tag: tag}, nil
|
return Snapshot{Main: main, Tag: remoteTag}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireCandidate requires exact main equality and an absent remote version
|
// RequireCandidate requires exact main equality and an absent remote
|
||||||
// tag. It deliberately does not accept a main branch that merely contains the
|
// tag. It deliberately does not accept a main branch that merely contains the
|
||||||
// candidate as an ancestor.
|
// candidate as an ancestor.
|
||||||
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) {
|
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, tag, commit string) (Snapshot, error) {
|
||||||
snapshot, err := endpoint.Query(ctx, repositoryDir, version)
|
snapshot, err := endpoint.Query(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return snapshot, err
|
return snapshot, err
|
||||||
}
|
}
|
||||||
@@ -294,25 +294,14 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
|||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
func validVersionRef(version string) bool {
|
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||||
if len(version) < 6 || !strings.HasPrefix(version, "v") {
|
if tag == "" {
|
||||||
return false
|
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||||
}
|
}
|
||||||
parts := strings.Split(version[1:], ".")
|
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
|
||||||
if len(parts) != 3 {
|
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
for _, part := range parts {
|
return nil
|
||||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, char := range part {
|
|
||||||
if char < '0' || char > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validObjectID(object string) bool {
|
func validObjectID(object string) bool {
|
||||||
|
|||||||
@@ -42,6 +42,32 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tag := "integrations/check/v1.2.3"
|
||||||
|
if _, err := endpoint.RequireCandidate(context.Background(), fixture.work, tag, fixture.commit); err != nil {
|
||||||
|
t.Fatalf("RequireCandidate(%q): %v", tag, err)
|
||||||
|
}
|
||||||
|
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := endpoint.PushTag(context.Background(), fixture.work, tag, object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
remote, err := endpoint.QueryTag(context.Background(), fixture.work, tag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if remote.State != RefPresent || remote.Object != object || remote.Peeled != fixture.commit {
|
||||||
|
t.Fatalf("remote tag = %#v", remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAppliesGitURLRewrites(t *testing.T) {
|
func TestResolveAppliesGitURLRewrites(t *testing.T) {
|
||||||
for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
|
for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
|
||||||
t.Run(kind, func(t *testing.T) {
|
t.Run(kind, func(t *testing.T) {
|
||||||
@@ -164,7 +190,7 @@ type remoteFixture struct {
|
|||||||
commit string
|
commit string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRemoteFixture(t *testing.T) remoteFixture {
|
func newRemoteFixture(t testing.TB) remoteFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
work := filepath.Join(base, "work")
|
work := filepath.Join(base, "work")
|
||||||
@@ -194,7 +220,7 @@ func resolveFixture(t *testing.T, repo remoteFixture) Endpoint {
|
|||||||
return endpoint
|
return endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitRun(t *testing.T, dir string, args ...string) {
|
func gitRun(t testing.TB, dir string, args ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
@@ -203,7 +229,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()
|
t.Helper()
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
@@ -214,7 +240,7 @@ func gitText(t *testing.T, dir string, args ...string) string {
|
|||||||
return strings.TrimSuffix(string(outputBytes), "\n")
|
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()
|
t.Helper()
|
||||||
path := filepath.Join(repoDir, name)
|
path := filepath.Join(repoDir, name)
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TreeEntry records one committed path and its Git object metadata.
|
||||||
|
type TreeEntry struct {
|
||||||
|
Path string
|
||||||
|
ObjectID string
|
||||||
|
Mode string
|
||||||
|
Type string
|
||||||
|
Size int64
|
||||||
|
HasSize bool
|
||||||
|
}
|
||||||
|
|
||||||
type TaggedSource struct {
|
type TaggedSource struct {
|
||||||
Head string
|
Head string
|
||||||
Status string
|
Status string
|
||||||
@@ -15,11 +31,43 @@ type TaggedSource struct {
|
|||||||
TagObjectType string
|
TagObjectType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepositoryRoot resolves the containing checkout, including independent
|
||||||
|
// worktrees and directories reached through filesystem aliases.
|
||||||
|
func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
||||||
|
if directory == "" {
|
||||||
|
directory = "."
|
||||||
|
}
|
||||||
|
root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve Git repository root: %w", err)
|
||||||
|
}
|
||||||
|
root = strings.TrimSuffix(root, "\n")
|
||||||
|
resolved, err := filepath.EvalSymlinks(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve Git repository directory: %w", err)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureHead records the checkout commit before identity derived from files
|
||||||
|
// in that commit is inspected.
|
||||||
|
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
||||||
|
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read checkout HEAD: %w", err)
|
||||||
|
}
|
||||||
|
head = strings.TrimSpace(head)
|
||||||
|
if !validObjectID(head) {
|
||||||
|
return "", fmt.Errorf("Git returned an invalid checkout commit %q", head)
|
||||||
|
}
|
||||||
|
return head, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
||||||
// Both lightweight historical tags and annotated tags are supported.
|
// Both lightweight historical tags and annotated tags are supported.
|
||||||
func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) {
|
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
||||||
if !validVersionRef(version) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("invalid tag version %q", version)
|
return TaggedSource{}, err
|
||||||
}
|
}
|
||||||
var source TaggedSource
|
var source TaggedSource
|
||||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
@@ -35,10 +83,10 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
|||||||
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
||||||
}
|
}
|
||||||
source.Status = strings.TrimSpace(status)
|
source.Status = strings.TrimSpace(status)
|
||||||
ref := "refs/tags/" + version
|
ref := "refs/tags/" + tag
|
||||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err)
|
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
source.TagObject = strings.TrimSpace(object)
|
source.TagObject = strings.TrimSpace(object)
|
||||||
if !validObjectID(source.TagObject) {
|
if !validObjectID(source.TagObject) {
|
||||||
@@ -46,7 +94,7 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
|||||||
}
|
}
|
||||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err)
|
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err)
|
||||||
}
|
}
|
||||||
source.TagCommit = strings.TrimSpace(peeled)
|
source.TagCommit = strings.TrimSpace(peeled)
|
||||||
if !validObjectID(source.TagCommit) {
|
if !validObjectID(source.TagCommit) {
|
||||||
@@ -54,11 +102,11 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
|
|||||||
}
|
}
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err)
|
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
source.TagObjectType = strings.TrimSpace(tagType)
|
source.TagObjectType = strings.TrimSpace(tagType)
|
||||||
if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
|
if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
|
||||||
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType)
|
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType)
|
||||||
}
|
}
|
||||||
return source, nil
|
return source, nil
|
||||||
}
|
}
|
||||||
@@ -68,9 +116,175 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
|
|||||||
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") {
|
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") {
|
||||||
return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit)
|
return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit)
|
||||||
}
|
}
|
||||||
content, err := run(ctx, repositoryDir, nil, "show", commit+":"+name)
|
content, err := readLocalObjects(ctx, repositoryDir, nil, "show", commit+":"+name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read committed file %q: %w", name, err)
|
return nil, fmt.Errorf("read committed file %q: %w", name, err)
|
||||||
}
|
}
|
||||||
return []byte(content), nil
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommittedPaths resolves individual paths from captured objects. It requests
|
||||||
|
// only metadata and link contents needed for traversal.
|
||||||
|
func CommittedPaths(repositoryDir, commit string) PathResolver {
|
||||||
|
metadata := make(map[string]TreeEntry)
|
||||||
|
return PathResolver{
|
||||||
|
RepositoryDir: repositoryDir,
|
||||||
|
Lookup: func(ctx context.Context, name string) (TreeEntry, bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return TreeEntry{}, false, err
|
||||||
|
}
|
||||||
|
if entry, seen := metadata[name]; seen {
|
||||||
|
return entry, entry.ObjectID != "", nil
|
||||||
|
}
|
||||||
|
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") {
|
||||||
|
return TreeEntry{}, false, fmt.Errorf("invalid committed path identity %q at %q", name, commit)
|
||||||
|
}
|
||||||
|
output, err := readLocalObjects(ctx, repositoryDir, nil, "--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--", name)
|
||||||
|
if err != nil {
|
||||||
|
return TreeEntry{}, false, fmt.Errorf("inspect committed path %q: %w", name, err)
|
||||||
|
}
|
||||||
|
entries, err := parseTreeEntries(output)
|
||||||
|
if err != nil {
|
||||||
|
return TreeEntry{}, false, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Path == name {
|
||||||
|
metadata[name] = entry
|
||||||
|
return entry, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata[name] = TreeEntry{}
|
||||||
|
return TreeEntry{}, false, nil
|
||||||
|
},
|
||||||
|
ReadLink: func(ctx context.Context, entry TreeEntry) (string, error) {
|
||||||
|
contents, err := ReadBlob(ctx, repositoryDir, entry.ObjectID)
|
||||||
|
return string(contents), err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTree enumerates path, type, mode, and object metadata for a commit.
|
||||||
|
// Blob sizes and contents are inspected separately. Paths are NUL-delimited
|
||||||
|
// by Git so whitespace in a filename is preserved.
|
||||||
|
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
|
||||||
|
if !validObjectID(commit) {
|
||||||
|
return nil, fmt.Errorf("invalid commit object ID %q", commit)
|
||||||
|
}
|
||||||
|
output, err := readLocalObjects(ctx, repositoryDir, nil, "ls-tree", "-r", "-z", "--full-tree", commit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("enumerate committed tree %s: %w", commit, err)
|
||||||
|
}
|
||||||
|
entries, err := parseTreeEntries(output)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse committed tree %s: %w", commit, err)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBlob reads the bytes for a Git blob object ID without consulting the
|
||||||
|
// worktree.
|
||||||
|
func ReadBlob(ctx context.Context, repositoryDir, objectID string) ([]byte, error) {
|
||||||
|
if !validObjectID(objectID) {
|
||||||
|
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
|
||||||
|
}
|
||||||
|
content, err := readLocalObjects(ctx, repositoryDir, nil, "cat-file", "blob", objectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTreeEntries(output []byte) ([]TreeEntry, error) {
|
||||||
|
if len(output) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var entries []TreeEntry
|
||||||
|
for _, record := range bytes.Split(output, []byte{0}) {
|
||||||
|
if len(record) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tab := bytes.IndexByte(record, '\t')
|
||||||
|
if tab < 0 {
|
||||||
|
return nil, fmt.Errorf("tree record has no path separator")
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(record[:tab]))
|
||||||
|
if len(fields) != 3 || !validObjectID(fields[2]) {
|
||||||
|
return nil, fmt.Errorf("invalid tree record metadata %q", record[:tab])
|
||||||
|
}
|
||||||
|
entries = append(entries, TreeEntry{
|
||||||
|
Mode: fields[0],
|
||||||
|
Type: fields[1],
|
||||||
|
ObjectID: fields[2],
|
||||||
|
Path: string(record[tab+1:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopulateBlobSizes fills unknown sizes for the supplied blob entries using
|
||||||
|
// local objects. Callers select the entries whose sizes their check needs.
|
||||||
|
func PopulateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var blobIndexes []int
|
||||||
|
var input strings.Builder
|
||||||
|
for index := range entries {
|
||||||
|
if entries[index].Type != "blob" || entries[index].HasSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blobIndexes = append(blobIndexes, index)
|
||||||
|
input.WriteString(entries[index].ObjectID)
|
||||||
|
input.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if len(blobIndexes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
output, err := readLocalObjects(ctx, repositoryDir, strings.NewReader(input.String()),
|
||||||
|
"cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSuffix(string(output), "\n"), "\n")
|
||||||
|
if len(lines) != len(blobIndexes) {
|
||||||
|
return fmt.Errorf("git returned %d blob sizes for %d blobs", len(lines), len(blobIndexes))
|
||||||
|
}
|
||||||
|
for index, line := range lines {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
entry := &entries[blobIndexes[index]]
|
||||||
|
if len(fields) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" {
|
||||||
|
return fmt.Errorf("committed blob %s for %q is unavailable locally", entry.ObjectID, entry.Path)
|
||||||
|
}
|
||||||
|
if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" {
|
||||||
|
return fmt.Errorf("unexpected blob size record %q", line)
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseInt(fields[2], 10, 64)
|
||||||
|
if err != nil || size < 0 {
|
||||||
|
return fmt.Errorf("invalid blob size in record %q", line)
|
||||||
|
}
|
||||||
|
entry.Size = size
|
||||||
|
entry.HasSize = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLocalObjects keeps source inspection separate from network preparation.
|
||||||
|
func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader, args ...string) ([]byte, error) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := process.Run(ctx, process.Invocation{
|
||||||
|
Args: append([]string{"git"}, args...),
|
||||||
|
Dir: repositoryDir,
|
||||||
|
// Disable transports as well for Git versions predating lazy-fetch control.
|
||||||
|
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""},
|
||||||
|
Stdin: input,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if message := strings.TrimSpace(stderr.String()); message != "" {
|
||||||
|
return nil, fmt.Errorf("read local Git objects: %s: %w", message, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -28,6 +32,100 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
tag := "integrations/check/v2.3.4"
|
||||||
|
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
|
||||||
|
t.Fatalf("checkTagRef(%q): %v", tag, err)
|
||||||
|
}
|
||||||
|
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if source.TagCommit != fixture.commit || source.TagObject != object || source.TagObjectType != "tag" {
|
||||||
|
t.Fatalf("source for %q = %#v", tag, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
||||||
|
if err := checkTagRef(context.Background(), fixture.work, tag); err == nil {
|
||||||
|
t.Errorf("checkTagRef(%q) succeeded", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true")
|
||||||
|
blob := strings.TrimSpace(gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"))
|
||||||
|
for _, filter := range []string{"blob:none", "tree:0"} {
|
||||||
|
t.Run(filter, func(t *testing.T) {
|
||||||
|
repository := filepath.Join(t.TempDir(), "partial")
|
||||||
|
gitRun(t, fixture.work, "clone", "--filter="+filter, "--no-checkout", "file://"+filepath.ToSlash(fixture.work), repository)
|
||||||
|
before := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
|
||||||
|
if strings.Contains(before, blob) {
|
||||||
|
t.Fatal("fixture contains the filtered blob")
|
||||||
|
}
|
||||||
|
gitRun(t, repository, "config", "remote.origin.url", "ssh://releasekit-test.invalid/source")
|
||||||
|
attempts := filepath.Join(t.TempDir(), "attempts")
|
||||||
|
transport := filepath.Join(t.TempDir(), "ssh")
|
||||||
|
if err := os.WriteFile(transport, []byte("#!/bin/sh\nprintf 'remote accessed\\n' >> \"$RELEASEKIT_GIT_ATTEMPTS\"\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("GIT_SSH_COMMAND", transport)
|
||||||
|
t.Setenv("RELEASEKIT_GIT_ATTEMPTS", attempts)
|
||||||
|
t.Setenv("GIT_NO_LAZY_FETCH", "0")
|
||||||
|
t.Setenv("GIT_ALLOW_PROTOCOL", "ssh")
|
||||||
|
entries, treeErr := ReadTree(t.Context(), repository, fixture.commit)
|
||||||
|
if filter == "tree:0" {
|
||||||
|
if treeErr == nil {
|
||||||
|
t.Fatal("tree inspection succeeded with unavailable trees")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if treeErr != nil {
|
||||||
|
t.Fatalf("metadata inspection required unavailable blobs: %v", treeErr)
|
||||||
|
}
|
||||||
|
if err := PopulateBlobSizes(t.Context(), repository, entries); err == nil {
|
||||||
|
t.Fatal("size inspection succeeded with unavailable blobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, fileErr := ReadCommitFile(t.Context(), repository, fixture.commit, "source.txt")
|
||||||
|
var fileExitError *exec.ExitError
|
||||||
|
if !errors.As(fileErr, &fileExitError) {
|
||||||
|
t.Fatalf("committed file error %v lost its Git process cause", fileErr)
|
||||||
|
}
|
||||||
|
_, err := ReadBlob(t.Context(), repository, blob)
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
after := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
|
||||||
|
if before != after {
|
||||||
|
t.Fatal("object inspection populated the object database")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
|
func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
gitRun(t, fixture.work, "tag", "v1.2.3")
|
gitRun(t, fixture.work, "tag", "v1.2.3")
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
|
|
||||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
||||||
// on the captured commit and returns the tag object's ID.
|
// on the captured commit and returns the tag object's ID.
|
||||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit string, note []byte) (string, error) {
|
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||||
if !validVersionRef(version) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return "", fmt.Errorf("invalid version tag name %q", version)
|
return "", err
|
||||||
}
|
}
|
||||||
if !validObjectID(commit) {
|
if !validObjectID(commit) {
|
||||||
return "", fmt.Errorf("invalid candidate commit object ID %q", commit)
|
return "", fmt.Errorf("invalid candidate commit object ID %q", commit)
|
||||||
@@ -30,64 +30,67 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit stri
|
|||||||
if err := message.Close(); err != nil {
|
if err := message.Close(); err != nil {
|
||||||
return "", fmt.Errorf("close temporary release note: %w", err)
|
return "", fmt.Errorf("close temporary release note: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, version, commit); err != nil {
|
if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, tag, commit); err != nil {
|
||||||
return "", fmt.Errorf("create annotated tag %s: %w", version, err)
|
return "", fmt.Errorf("create annotated tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ref := "refs/tags/" + version
|
ref := "refs/tags/" + tag
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("inspect created tag %s: %w", version, err)
|
return "", fmt.Errorf("inspect created tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
if strings.TrimSpace(tagType) != "tag" {
|
||||||
return "", fmt.Errorf("created tag %s has object type %q, want tag", version, strings.TrimSpace(tagType))
|
return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType))
|
||||||
}
|
}
|
||||||
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read created tag object %s: %w", version, err)
|
return "", fmt.Errorf("read created tag object %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
tagObject = strings.TrimSpace(tagObject)
|
tagObject = strings.TrimSpace(tagObject)
|
||||||
if !validObjectID(tagObject) {
|
if !validObjectID(tagObject) {
|
||||||
return "", fmt.Errorf("created tag %s has invalid object ID %q", version, tagObject)
|
return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject)
|
||||||
}
|
}
|
||||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read peeled commit for tag %s: %w", version, err)
|
return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(peeled) != commit {
|
if strings.TrimSpace(peeled) != commit {
|
||||||
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", version, strings.TrimSpace(peeled), commit)
|
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit)
|
||||||
}
|
}
|
||||||
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read annotated tag %s: %w", version, err)
|
return "", fmt.Errorf("read annotated tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
||||||
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", version)
|
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag)
|
||||||
}
|
}
|
||||||
return tagObject, nil
|
return tagObject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectLocalTag confirms the selected tag still points to the exact
|
// InspectLocalTag confirms the selected tag still points to the exact
|
||||||
// annotated tag object captured before publication.
|
// annotated tag object captured before publication.
|
||||||
func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject string) error {
|
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
||||||
if !validVersionRef(version) || !validObjectID(expectedObject) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version)
|
return err
|
||||||
}
|
}
|
||||||
ref := "refs/tags/" + version
|
if !validObjectID(expectedObject) {
|
||||||
|
return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag)
|
||||||
|
}
|
||||||
|
ref := "refs/tags/" + tag
|
||||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect local tag %s: %w", version, err)
|
return fmt.Errorf("inspect local tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tagType) != "tag" {
|
if strings.TrimSpace(tagType) != "tag" {
|
||||||
return fmt.Errorf("local tag %s has type %q, want annotated tag", version, strings.TrimSpace(tagType))
|
return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType))
|
||||||
}
|
}
|
||||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read local tag %s: %w", version, err)
|
return fmt.Errorf("read local tag %s: %w", tag, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(object) != expectedObject {
|
if strings.TrimSpace(object) != expectedObject {
|
||||||
return fmt.Errorf("local tag %s identifies %s, want %s", version, strings.TrimSpace(object), expectedObject)
|
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -95,18 +98,18 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
|
|||||||
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||||
// one exact authoritative version tag and its peeled commit. Failed
|
// one exact authoritative version tag and its peeled commit. Failed
|
||||||
// verification or reads are unreadable and never interpreted as absence.
|
// verification or reads are unreadable and never interpreted as absence.
|
||||||
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
|
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
|
||||||
if !validVersionRef(version) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
|
return RemoteRef{}, err
|
||||||
}
|
}
|
||||||
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err)
|
||||||
}
|
}
|
||||||
ref := "refs/tags/" + version
|
ref := "refs/tags/" + tag
|
||||||
peeledRef := ref + "^{}"
|
peeledRef := ref + "^{}"
|
||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err)
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
|
||||||
}
|
}
|
||||||
remoteTag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
for _, line := range outputLines(output) {
|
for _, line := range outputLines(output) {
|
||||||
@@ -125,13 +128,16 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PushTag publishes only the captured tag object to its exact tag ref.
|
// PushTag publishes only the captured tag object to its exact tag ref.
|
||||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
|
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
|
||||||
if !validVersionRef(version) || !validObjectID(tagObject) {
|
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return fmt.Errorf("invalid tag publication identity %q at %q", tagObject, version)
|
return err
|
||||||
}
|
}
|
||||||
refspec := tagObject + ":refs/tags/" + version
|
if !validObjectID(tagObject) {
|
||||||
|
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
||||||
|
}
|
||||||
|
refspec := tagObject + ":refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
||||||
return fmt.Errorf("push annotated tag %s to %s: %w", version, endpoint.PushURL, err)
|
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
|
|||||||
case "/files/notes":
|
case "/files/notes":
|
||||||
_, _ = w.Write([]byte("unrelated"))
|
_, _ = w.Write([]byte("unrelated"))
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -107,7 +108,8 @@ func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path != "/files/manifest" {
|
if r.URL.Path != "/files/manifest" {
|
||||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write(manifest.Content)
|
_, _ = w.Write(manifest.Content)
|
||||||
}))
|
}))
|
||||||
@@ -237,7 +239,8 @@ func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
|||||||
_, _ = w.Write(content)
|
_, _ = w.Write(content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
return
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
||||||
@@ -253,9 +256,9 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
|||||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
expected := assetFor("app.bin", []byte("matching bytes"))
|
||||||
externalGets := 0
|
var externalGets atomic.Int64
|
||||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
externalGets++
|
externalGets.Add(1)
|
||||||
if r.Header.Get("Authorization") != "" {
|
if r.Header.Get("Authorization") != "" {
|
||||||
t.Error("Gitea token reached external host")
|
t.Error("Gitea token reached external host")
|
||||||
}
|
}
|
||||||
@@ -287,8 +290,8 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
|||||||
if mode == "conflicting" {
|
if mode == "conflicting" {
|
||||||
want = AssetConflicting
|
want = AssetConflicting
|
||||||
}
|
}
|
||||||
if inspection.Assets[0].State != want || externalGets != 1 {
|
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
|
||||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets)
|
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FailureKind string
|
type FailureKind string
|
||||||
@@ -59,7 +57,7 @@ type Destination struct {
|
|||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Destination Destination
|
Destination Destination
|
||||||
Version string
|
Tag string
|
||||||
Commit string
|
Commit string
|
||||||
Note string
|
Note string
|
||||||
}
|
}
|
||||||
@@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
|||||||
}
|
}
|
||||||
client := c.httpClient()
|
client := c.httpClient()
|
||||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version)
|
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
||||||
|
|
||||||
var tag tagResponse
|
var tag tagResponse
|
||||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
if tag.Commit.SHA != request.Commit {
|
if tag.Commit.SHA != request.Commit {
|
||||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit))
|
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version)
|
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
||||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return acceptExisting(existing, request)
|
return acceptExisting(existing, request)
|
||||||
@@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
|||||||
|
|
||||||
title := noteTitle(request.Note)
|
title := noteTitle(request.Note)
|
||||||
body, err := json.Marshal(createReleaseRequest{
|
body, err := json.Marshal(createReleaseRequest{
|
||||||
TagName: request.Version, TargetCommitish: request.Commit,
|
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||||
Name: title, Body: request.Note,
|
Name: title, Body: request.Note,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateRequest(request Request) error {
|
func validateRequest(request Request) error {
|
||||||
if !versioncontract.ValidStableVersion(request.Version) {
|
if strings.TrimSpace(request.Tag) == "" {
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required"))
|
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||||
}
|
}
|
||||||
if !validObjectID(request.Commit) {
|
if !validObjectID(request.Commit) {
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||||
@@ -305,14 +303,14 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acceptExisting(release Release, request Request) (Result, error) {
|
func acceptExisting(release Release, request Request) (Result, error) {
|
||||||
if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease {
|
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||||
}
|
}
|
||||||
return Result{Release: release}, nil
|
return Result{Release: release}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptCreated(release Release, request Request) (Result, error) {
|
func acceptCreated(release Release, request Request) (Result, error) {
|
||||||
if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||||
}
|
}
|
||||||
return Result{Release: release, Created: true}, nil
|
return Result{Release: release, Created: true}, nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,49 @@ func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
|
||||||
|
tag := "integrations/ckconfig/v1.2.3"
|
||||||
|
state := &fixtureState{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertAuth(t, r)
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
writeTag(t, w, testCommit)
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||||
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case "/api/v1/repos/owner/project/releases":
|
||||||
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
|
state.posts.Add(1)
|
||||||
|
var payload createReleaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||||
|
t.Errorf("create payload = %#v", payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
request := testRequest(server.URL)
|
||||||
|
request.Tag = tag
|
||||||
|
result, err := testClient(server).EnsureRelease(context.Background(), request)
|
||||||
|
if err != nil || !result.Created || state.posts.Load() != 1 {
|
||||||
|
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
@@ -43,7 +87,7 @@ func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
|
|||||||
if failureKind(t, err) != FailureConflict {
|
if failureKind(t, err) != FailureConflict {
|
||||||
t.Fatalf("error = %v", err)
|
t.Fatalf("error = %v", err)
|
||||||
}
|
}
|
||||||
if got := state.posts; got != 0 {
|
if got := state.posts.Load(); got != 0 {
|
||||||
t.Fatalf("release creates = %d, want 0", got)
|
t.Fatalf("release creates = %d, want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,68 +108,68 @@ func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
||||||
posts := 0
|
var posts atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
posts++
|
posts.Add(1)
|
||||||
}
|
}
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
_, _ = w.Write([]byte("{"))
|
_, _ = w.Write([]byte("{"))
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
if failureKind(t, err) != FailureMalformed || posts != 0 {
|
if failureKind(t, err) != FailureMalformed || posts.Load() != 0 {
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
||||||
posts := 0
|
var posts atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
posts++
|
posts.Add(1)
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
if failureKind(t, err) != FailureConflict || posts != 0 {
|
if failureKind(t, err) != FailureConflict || posts.Load() != 0 {
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
||||||
posts := 0
|
var posts atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
posts++
|
posts.Add(1)
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
if failureKind(t, err) != FailureNotFound || posts != 0 {
|
if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
||||||
called := false
|
var called atomic.Bool
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) }))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
||||||
if !errors.Is(err, context.Canceled) || called {
|
if !errors.Is(err, context.Canceled) || called.Load() {
|
||||||
t.Fatalf("error = %v, server called = %t", err, called)
|
t.Fatalf("error = %v, server called = %t", err, called.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
||||||
created := false
|
var created atomic.Bool
|
||||||
lookups := 0
|
var lookups atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAuth(t, r)
|
assertAuth(t, r)
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -134,8 +178,8 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
|
|||||||
writeTag(t, w, testCommit)
|
writeTag(t, w, testCommit)
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
lookups++
|
lookups.Add(1)
|
||||||
if !created {
|
if !created.Load() {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,23 +188,28 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
|
|||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
var payload createReleaseRequest
|
var payload createReleaseRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
t.Fatal(err)
|
t.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||||
t.Fatalf("create payload = %#v", payload)
|
t.Errorf("create payload = %#v", payload)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
created = true
|
created.Store(true)
|
||||||
hijacker, ok := w.(http.Hijacker)
|
hijacker, ok := w.(http.Hijacker)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("HTTP writer does not support hijacking")
|
t.Error("HTTP writer does not support hijacking")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
connection, _, err := hijacker.Hijack()
|
connection, _, err := hijacker.Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -168,13 +217,13 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Created || lookups != 2 {
|
if result.Created || lookups.Load() != 2 {
|
||||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
||||||
lookups := 0
|
var lookups atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAuth(t, r)
|
assertAuth(t, r)
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -183,8 +232,8 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
|||||||
writeTag(t, w, testCommit)
|
writeTag(t, w, testCommit)
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
lookups++
|
lookups.Add(1)
|
||||||
if lookups == 1 {
|
if lookups.Load() == 1 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,7 +242,8 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
|||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -201,13 +251,13 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Created || result.Release.Title != "Created by another request" || lookups != 2 {
|
if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 {
|
||||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
||||||
lookups := 0
|
var lookups atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAuth(t, r)
|
assertAuth(t, r)
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -216,8 +266,8 @@ func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *test
|
|||||||
writeTag(t, w, testCommit)
|
writeTag(t, w, testCommit)
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||||
lookups++
|
lookups.Add(1)
|
||||||
if lookups == 1 {
|
if lookups.Load() == 1 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -226,13 +276,14 @@ func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *test
|
|||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
if failureKind(t, err) != FailureUnknown || lookups != 2 {
|
if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
|
||||||
t.Fatalf("error = %v, release lookups = %d", err, lookups)
|
t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +294,7 @@ type releaseFixture struct {
|
|||||||
pre bool
|
pre bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type fixtureState struct{ posts int }
|
type fixtureState struct{ posts atomic.Int64 }
|
||||||
|
|
||||||
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -263,13 +314,15 @@ func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool)
|
|||||||
writeRelease(t, w, existing)
|
writeRelease(t, w, existing)
|
||||||
case "/api/v1/repos/owner/project/releases":
|
case "/api/v1/repos/owner/project/releases":
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||||
state.posts++
|
state.posts.Add(1)
|
||||||
var payload createReleaseRequest
|
var payload createReleaseRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
t.Fatal(err)
|
t.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||||
t.Fatalf("create payload = %#v", payload)
|
t.Errorf("create payload = %#v", payload)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if !createMissing {
|
if !createMissing {
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
@@ -277,10 +330,12 @@ func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool)
|
|||||||
}
|
}
|
||||||
writeRelease(t, w, releaseFixture{title: payload.Name})
|
writeRelease(t, w, releaseFixture{title: payload.Name})
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
server := httptest.NewServer(handler)
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
return server, state
|
return server, state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +362,8 @@ func (fixture releaseFixture) bodyOrDefault() string {
|
|||||||
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if request.Method != method || request.URL.Path != path {
|
if request.Method != method || request.URL.Path != path {
|
||||||
t.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
assertAuth(t, request)
|
assertAuth(t, request)
|
||||||
}
|
}
|
||||||
@@ -315,7 +371,8 @@ func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
|||||||
func assertAuth(t *testing.T, request *http.Request) {
|
func assertAuth(t *testing.T, request *http.Request) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
||||||
t.Fatalf("Authorization = %q", got)
|
t.Errorf("Authorization = %q", got)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +381,7 @@ func testClient(server *httptest.Server) Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRequest(baseURL string) Request {
|
func testRequest(baseURL string) Request {
|
||||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote}
|
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||||
}
|
}
|
||||||
|
|
||||||
func failureKind(t *testing.T, err error) FailureKind {
|
func failureKind(t *testing.T, err error) FailureKind {
|
||||||
@@ -343,14 +400,14 @@ func contains(value, substring string) bool {
|
|||||||
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
||||||
for _, mode := range []string{"matching", "conflicting", "unavailable"} {
|
for _, mode := range []string{"matching", "conflicting", "unavailable"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
posts, lookups := 0, 0
|
var posts, lookups atomic.Int64
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
|
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
|
||||||
writeTag(t, w, testCommit)
|
writeTag(t, w, testCommit)
|
||||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
||||||
lookups++
|
lookups.Add(1)
|
||||||
if posts == 0 {
|
if posts.Load() == 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -364,7 +421,7 @@ func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
|
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
|
||||||
case r.Method == http.MethodPost:
|
case r.Method == http.MethodPost:
|
||||||
posts++
|
posts.Add(1)
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
|
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
|
||||||
default:
|
default:
|
||||||
@@ -373,8 +430,8 @@ func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||||
if posts != 1 || lookups != 2 {
|
if posts.Load() != 1 || lookups.Load() != 2 {
|
||||||
t.Fatalf("posts=%d lookups=%d", posts, lookups)
|
t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
|
||||||
}
|
}
|
||||||
switch mode {
|
switch mode {
|
||||||
case "matching":
|
case "matching":
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ func VerifyLocalAssets(assets []ExpectedAsset) error {
|
|||||||
// before the first write if any declared existing asset conflicts or cannot be
|
// before the first write if any declared existing asset conflicts or cannot be
|
||||||
// verified, and never replaces an existing attachment.
|
// verified, and never replaces an existing attachment.
|
||||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||||
|
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
||||||
|
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
||||||
|
// inspection, conflict handling, and upload reconciliation.
|
||||||
|
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||||
|
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
||||||
if releaseID <= 0 {
|
if releaseID <= 0 {
|
||||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||||
}
|
}
|
||||||
@@ -127,6 +138,7 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
|
|||||||
missing[observation.Expected.Filename] = observation.Expected
|
missing[observation.Expected.Filename] = observation.Expected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if verifyLocal {
|
||||||
for _, item := range expected {
|
for _, item := range expected {
|
||||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||||
continue
|
continue
|
||||||
@@ -135,6 +147,7 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||||
client := c.httpClient()
|
client := c.httpClient()
|
||||||
|
|||||||
@@ -15,6 +15,31 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
||||||
|
fixture := &uploadFixture{files: make(map[string][]byte)}
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
|
||||||
|
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents := readExpectedFile(t, assets[1])
|
||||||
|
contents[0] ^= 0xff
|
||||||
|
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
|
||||||
|
if err == nil || result.State != DeliveryPartial {
|
||||||
|
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
|
||||||
|
}
|
||||||
|
if posts := fixture.postNames(); len(posts) != 0 {
|
||||||
|
t.Fatalf("uploaded %v before rejecting local content", posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||||
server := httptest.NewServer(fixture)
|
server := httptest.NewServer(fixture)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Target struct {
|
|||||||
type Tool struct {
|
type Tool struct {
|
||||||
host Target
|
host Target
|
||||||
settings map[string]string
|
settings map[string]string
|
||||||
|
settingsFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
var operationalSettings = []string{
|
var operationalSettings = []string{
|
||||||
@@ -55,8 +56,9 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
|||||||
var host struct {
|
var host struct {
|
||||||
OS string `json:"GOHOSTOS"`
|
OS string `json:"GOHOSTOS"`
|
||||||
Arch string `json:"GOHOSTARCH"`
|
Arch string `json:"GOHOSTARCH"`
|
||||||
|
SettingsFile string `json:"GOENV"`
|
||||||
}
|
}
|
||||||
output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH")
|
output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH", "GOENV")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("identify Go host: %w", err)
|
return nil, fmt.Errorf("identify Go host: %w", err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
|||||||
if host.OS == "" || host.Arch == "" {
|
if host.OS == "" || host.Arch == "" {
|
||||||
return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output)))
|
return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output)))
|
||||||
}
|
}
|
||||||
tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}}
|
tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}, settingsFile: host.SettingsFile}
|
||||||
|
|
||||||
environment := discoveryEnvironment()
|
environment := discoveryEnvironment()
|
||||||
environment["GOOS"] = tool.host.OS
|
environment["GOOS"] = tool.host.OS
|
||||||
@@ -105,7 +107,7 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
|||||||
Stderr: captured,
|
Stderr: captured,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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: %s", err, output)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("go mod download: %w", err)
|
return fmt.Errorf("go mod download: %w", err)
|
||||||
@@ -121,8 +123,19 @@ func (tool *Tool) HostTarget() Target {
|
|||||||
// Run executes a Go command with ReleaseKit's shared offline baseline. Native
|
// Run executes a Go command with ReleaseKit's shared offline baseline. Native
|
||||||
// commands use the host target and effective cgo configuration. Static commands
|
// commands use the host target and effective cgo configuration. Static commands
|
||||||
// use target and disable cgo. Output, when supplied, receives stdout and stderr
|
// use target and disable cgo. Output, when supplied, receives stdout and stderr
|
||||||
// as the command runs; the returned bytes retain both streams for diagnostics.
|
// as the command runs.
|
||||||
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
|
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 {
|
if len(args) == 0 {
|
||||||
return nil, errors.New("Go command requires arguments")
|
return nil, errors.New("Go command requires arguments")
|
||||||
}
|
}
|
||||||
@@ -136,14 +149,24 @@ func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target T
|
|||||||
Stdout: captured,
|
Stdout: captured,
|
||||||
Stderr: 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 err != nil {
|
||||||
if len(contents) != 0 {
|
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 {
|
func (tool *Tool) environment(mode Mode, target Target) map[string]string {
|
||||||
@@ -209,6 +232,7 @@ type combinedWriter struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
output io.Writer
|
output io.Writer
|
||||||
buffer bytes.Buffer
|
buffer bytes.Buffer
|
||||||
|
forwardErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||||
@@ -217,11 +241,29 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
|||||||
if writer.output == nil {
|
if writer.output == nil {
|
||||||
return writer.buffer.Write(contents)
|
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()
|
writer.mu.Lock()
|
||||||
defer writer.mu.Unlock()
|
defer writer.mu.Unlock()
|
||||||
return append([]byte(nil), writer.buffer.Bytes()...)
|
return writer.forwardErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// take transfers the completed capture to the caller. It must only be used
|
||||||
|
// after the child process has stopped writing.
|
||||||
|
func (writer *combinedWriter) take() []byte {
|
||||||
|
writer.mu.Lock()
|
||||||
|
defer writer.mu.Unlock()
|
||||||
|
return writer.buffer.Bytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gotool
|
package gotool
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -62,6 +63,14 @@ func runGoPreparationFixture() int {
|
|||||||
case "fail":
|
case "fail":
|
||||||
fmt.Fprintln(os.Stderr, "fixture dependency failure")
|
fmt.Fprintln(os.Stderr, "fixture dependency failure")
|
||||||
return 23
|
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":
|
case "wait":
|
||||||
for {
|
for {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
@@ -292,10 +301,88 @@ func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) {
|
||||||
|
clearGoSettings(t)
|
||||||
|
t.Setenv("GOENV", "off")
|
||||||
|
owner := NewOwner(t.TempDir())
|
||||||
|
|
||||||
|
first, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatal("unchanged Go inputs caused settings discovery to be repeated")
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := filepath.Join(t.TempDir(), "changed-cache")
|
||||||
|
t.Setenv("GOCACHE", cache)
|
||||||
|
third, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if third == first || third.settings["GOCACHE"] != cache {
|
||||||
|
t.Fatalf("changed inherited environment kept stale settings: tool=%p GOCACHE=%q", third, third.settings["GOCACHE"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerAcceptsGoConfigurationWithoutReadableSnapshot(t *testing.T) {
|
||||||
|
clearGoSettings(t)
|
||||||
|
t.Setenv("GOENV", t.TempDir())
|
||||||
|
directory := t.TempDir()
|
||||||
|
if _, err := New(context.Background(), directory); err != nil {
|
||||||
|
t.Fatalf("underlying Go discovery rejected fixture: %v", err)
|
||||||
|
}
|
||||||
|
owner := NewOwner(directory)
|
||||||
|
first, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner added a snapshot prerequisite: %v", err)
|
||||||
|
}
|
||||||
|
second, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil || first == second {
|
||||||
|
t.Fatalf("unavailable snapshot should rediscover on demand: first=%p second=%p error=%v", first, second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) {
|
||||||
|
clearGoSettings(t)
|
||||||
|
firstCache := filepath.Join(t.TempDir(), "first-cache")
|
||||||
|
secondCache := filepath.Join(t.TempDir(), "second-cache")
|
||||||
|
settingsFile := filepath.Join(t.TempDir(), "go-env")
|
||||||
|
writeSettings := func(cache string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(settingsFile, []byte("GOCACHE="+cache+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeSettings(firstCache)
|
||||||
|
t.Setenv("GOENV", settingsFile)
|
||||||
|
owner := NewOwner(t.TempDir())
|
||||||
|
|
||||||
|
first, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.settings["GOCACHE"] != firstCache {
|
||||||
|
t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache)
|
||||||
|
}
|
||||||
|
writeSettings(secondCache)
|
||||||
|
second, err := owner.Acquire(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second == first || second.settings["GOCACHE"] != secondCache {
|
||||||
|
t.Fatalf("changed persistent settings were not rediscovered: tool=%p GOCACHE=%q", second, second.settings["GOCACHE"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
|
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
args := append([]string{"env", "-json"}, names...)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -306,6 +393,122 @@ func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...s
|
|||||||
return settings
|
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) {
|
func clearGoSettings(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
|
|||||||
122
internal/gotool/owner.go
Normal file
122
internal/gotool/owner.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package gotool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Owner lazily discovers and reuses one Go tool while its inherited
|
||||||
|
// environment and persistent settings file remain unchanged.
|
||||||
|
type Owner struct {
|
||||||
|
directory string
|
||||||
|
tool *Tool
|
||||||
|
env map[string]string
|
||||||
|
file string
|
||||||
|
contents []byte
|
||||||
|
fileFound bool
|
||||||
|
snapshotAvailable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOwner creates an operation-local Go tool owner without running Go.
|
||||||
|
func NewOwner(directory string) *Owner {
|
||||||
|
return &Owner{directory: directory}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire returns the operation's Go tool, rediscovering settings when their
|
||||||
|
// process or persistent inputs have changed.
|
||||||
|
func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) {
|
||||||
|
if owner.tool != nil && owner.snapshotAvailable {
|
||||||
|
changed, err := owner.inputsChanged()
|
||||||
|
if err == nil && !changed {
|
||||||
|
return owner.tool, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := owner.discover(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return owner.tool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (owner *Owner) discover(ctx context.Context) error {
|
||||||
|
environment := inheritedEnvironment()
|
||||||
|
tool, err := New(ctx, owner.directory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settingsFile, snapshotErr := resolveSettingsFile(owner.directory, tool.settingsFile)
|
||||||
|
var contents []byte
|
||||||
|
var found bool
|
||||||
|
if snapshotErr == nil {
|
||||||
|
contents, found, snapshotErr = readSettingsFile(settingsFile)
|
||||||
|
}
|
||||||
|
owner.tool = tool
|
||||||
|
owner.env = environment
|
||||||
|
owner.file = settingsFile
|
||||||
|
owner.contents = contents
|
||||||
|
owner.fileFound = found
|
||||||
|
owner.snapshotAvailable = snapshotErr == nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (owner *Owner) inputsChanged() (bool, error) {
|
||||||
|
if !sameEnvironment(owner.env, inheritedEnvironment()) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
contents, found, err := readSettingsFile(owner.file)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return found != owner.fileFound || !bytes.Equal(contents, owner.contents), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritedEnvironment() map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
key, value, ok := strings.Cut(entry, "=")
|
||||||
|
if ok {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameEnvironment(left, right map[string]string) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key, value := range left {
|
||||||
|
other, ok := right[key]
|
||||||
|
if !ok || other != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSettingsFile(directory, filename string) (string, error) {
|
||||||
|
if filename == "" || filename == "off" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(filename) {
|
||||||
|
filename = filepath.Join(directory, filename)
|
||||||
|
}
|
||||||
|
return filepath.Abs(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSettingsFile(filename string) ([]byte, bool, error) {
|
||||||
|
if filename == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(filename)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return contents, true, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user